diff --git a/.gitignore b/.gitignore index d515122..04bf0a3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ lib*.a # Python ck-tap emulator /emulator_env/ +*.log diff --git a/Justfile b/Justfile index 72460ae..73cd487 100644 --- a/Justfile +++ b/Justfile @@ -11,17 +11,48 @@ fmt: # lint the project clippy: fmt - cargo clippy --all-features --tests + cargo clippy --all-features --all-targets # build the project build: fmt - cargo build --all-features --tests + cargo build --all-features --all-targets + +# setup the cktap emulator venv +setup: + (test -d emulator_env || python3 -m venv emulator_env) && \ + source emulator_env/bin/activate; pip install -r {{emulator_dir}}/requirements.txt > /dev/null 2>&1 + +# get cktap emulator options help +help: + source emulator_env/bin/activate; python3 coinkite/coinkite-tap-proto/emulator/ecard.py emulate --help + +# start the cktap emulator on /tmp/ecard-pipe +start *OPTS: setup + source emulator_env/bin/activate; python3 coinkite/coinkite-tap-proto/emulator/ecard.py emulate {{OPTS}} &> emulator_env/output.log & \ + echo $! > emulator_env/ecard.pid + echo "started emulator, pid:" `cat emulator_env/ecard.pid` + +# stop the cktap emulator +stop: + if [ -f emulator_env/ecard.pid ]; then \ + echo "killing emulator, pid:" `cat emulator_env/ecard.pid`; \ + kill `cat emulator_env/ecard.pid` && rm emulator_env/ecard.pid; \ + else \ + echo "emulator pid file not found."; \ + fi # test the rust-cktap lib with the coinkite cktap card emulator -test: fmt - (test -d emulator_env || python3 -m venv emulator_env) && source emulator_env/bin/activate && pip install -r {{emulator_dir}}/requirements.txt - source emulator_env/bin/activate && cargo test -p rust-cktap --features emulator +test: fmt setup + source emulator_env/bin/activate && cargo test -p rust-cktap --features emulator -- --nocapture # clean the project target directory clean: cargo clean + +# run the cli locally with a usb pcsc card reader (HID OMNIKEY 5022 CL Rev:C) +run *CMD: + cargo run -p cktap-cli {{CMD}} + +# run the cli locally with an emulated satscard (see start and stop recipes) +run_emu *CMD: + cargo run -p cktap-cli --features emulator -- {{CMD}} \ No newline at end of file diff --git a/README.md b/README.md index b108137..921c7ca 100644 --- a/README.md +++ b/README.md @@ -20,15 +20,15 @@ It is up to the crate user to send and receive the raw cktap APDU messages via N #### Shared Commands - [x] [status](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#status) -- [x] [read](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#status) (messages) - - [x] response verification -- [x] [derive](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive) (messages) +- [x] [read](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#read) - [x] response verification +- [x] [derive](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive) + - [ ] response verification - [x] [certs](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#certs) - [x] [new](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#new) -- [x] [nfc](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#nfc) -- [x] [sign](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#sign) (messages) - - [ ] response verification +- [ ] [nfc](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#nfc) +- [x] [sign](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#sign) + - [x] response verification - [x] [wait](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#wait) #### SATSCARD-Only Commands @@ -39,31 +39,49 @@ It is up to the crate user to send and receive the raw cktap APDU messages via N #### TAPSIGNER-Only Commands - [x] [change](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#change) -- [x] [xpub](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#xpub) +- [ ] [xpub](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#xpub) - [x] [backup](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#backup) -### Automated Testing with Emulator +### Automated and CLI Testing with Emulator + +#### Prerequisites 1. Install dependencies for [cktap emulator](https://github.com/coinkite/coinkite-tap-proto/blob/master/emulator/README.md) -2. run tests with emulator: `just test` + +#### Run tests with emulator + +``` +just test +``` + +#### Run CLI with emulated card reader + +``` +just start # for SATSCARD emulator +just start -t # for TAPSIGNER emulator +just run_emu --help +just run_emu certs +just run_emu read +just stop # stop emulator +``` ### Manual Testing with real cards #### Prerequisites -1. USB PCSC NFC card reader, for example: +1. Get USB PCSC NFC card reader, for example: - [OMNIKEY 5022 CL](https://www.hidglobal.com/products/omnikey-5022-reader) -2. Coinkite SATSCARD, TAPSIGNER, or SATSCHIP cards - Install vendor PCSC driver -3. Connect NFC reader to desktop system -4. Place SATSCARD, TAPSIGNER, or SATSCHIP on reader +2. Get Coinkite SATSCARD, TAPSIGNER, or SATSCHIP cards +3. Install card reader PCSC driver +4. Connect USB PCSC NFC reader to desktop system +5. Place SATSCARD, TAPSIGNER, or SATSCHIP on reader -#### Run CLI +#### Run CLI with desktop USB PCSC NFC card reader ``` -cargo run -p cktap-cli -- --help -cargo run -p cktap-cli -- certs -cargo run -p cktap-cli -- read +just run --help +just run certs +just run read ``` ## Minimum Supported Rust Version (MSRV) diff --git a/cktap-ffi/Cargo.toml b/cktap-ffi/Cargo.toml index e96aabb..52f4fd0 100644 --- a/cktap-ffi/Cargo.toml +++ b/cktap-ffi/Cargo.toml @@ -8,12 +8,19 @@ repository.workspace = true [lib] name = "cktap_ffi" -crate-type = ["staticlib", "cdylib"] +crate-type = ["lib", "staticlib", "cdylib"] + +[[bin]] +name = "cktap-uniffi-bindgen" +path = "cktap-uniffi-bindgen.rs" [dependencies] rust-cktap = { path = "../lib" } -uniffi = { version = "0.29", features = ["cli"] } + +async-trait = "0.1.81" +futures = "0.3.30" thiserror = "1.0" +uniffi = { git = "https://github.com/mozilla/uniffi-rs", features = ["cli", "tokio"] } [features] default = [] \ No newline at end of file diff --git a/cktap-ffi/cktap-uniffi-bindgen.rs b/cktap-ffi/cktap-uniffi-bindgen.rs new file mode 100644 index 0000000..227f443 --- /dev/null +++ b/cktap-ffi/cktap-uniffi-bindgen.rs @@ -0,0 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/cktap-ffi/src/bin/uniffi-bindgen.rs b/cktap-ffi/src/bin/uniffi-bindgen.rs deleted file mode 100644 index f6cff6c..0000000 --- a/cktap-ffi/src/bin/uniffi-bindgen.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - uniffi::uniffi_bindgen_main() -} diff --git a/cktap-ffi/src/error.rs b/cktap-ffi/src/error.rs new file mode 100644 index 0000000..69a7e17 --- /dev/null +++ b/cktap-ffi/src/error.rs @@ -0,0 +1,378 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::fmt::Debug; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum KeyError { + #[error("Secp256k1 error: {msg}")] + Secp256k1 { msg: String }, + #[error("Key from slice error: {msg}")] + KeyFromSlice { msg: String }, +} + +impl From for KeyError { + fn from(value: rust_cktap::SecpError) -> Self { + KeyError::Secp256k1 { + msg: value.to_string(), + } + } +} + +impl From for KeyError { + fn from(value: rust_cktap::FromSliceError) -> Self { + KeyError::KeyFromSlice { + msg: value.to_string(), + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum ChainCodeError { + #[error("Invalid length {len}, must be 32 bytes")] + InvalidLength { len: u64 }, +} + +impl From> for ChainCodeError { + fn from(value: Vec) -> Self { + ChainCodeError::InvalidLength { + len: value.len() as u64, + } + } +} + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum PsbtError { + #[error("Could not parse psbt: {msg}")] + Parse { msg: String }, +} + +impl From for PsbtError { + fn from(value: rust_cktap::PsbtParseError) -> Self { + PsbtError::Parse { + msg: value.to_string(), + } + } +} + +/// Errors returned by the CkTap card. +#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum CardError { + #[error("Rare or unlucky value used/occurred. Start again")] + UnluckyNumber, + #[error("Invalid/incorrect/incomplete arguments provided to command")] + BadArguments, + #[error("Authentication details (CVC/epubkey) are wrong")] + BadAuth, + #[error("Command requires auth, and none was provided")] + NeedsAuth, + #[error("The 'cmd' field is an unsupported command")] + UnknownCommand, + #[error("Command is not valid at this time, no point retrying")] + InvalidCommand, + #[error("You can't do that right now when card is in this state")] + InvalidState, + #[error("Nonce is not unique-looking enough")] + WeakNonce, + #[error("Unable to decode CBOR data stream")] + BadCBOR, + #[error("Can't change CVC without doing a backup first")] + BackupFirst, + #[error("Due to auth failures, delay required")] + RateLimited, +} + +impl From for CardError { + fn from(value: rust_cktap::CardError) -> Self { + match value { + rust_cktap::CardError::UnluckyNumber => CardError::UnluckyNumber, + rust_cktap::CardError::BadArguments => CardError::BadArguments, + rust_cktap::CardError::BadAuth => CardError::BadAuth, + rust_cktap::CardError::NeedsAuth => CardError::NeedsAuth, + rust_cktap::CardError::UnknownCommand => CardError::UnknownCommand, + rust_cktap::CardError::InvalidCommand => CardError::InvalidCommand, + rust_cktap::CardError::InvalidState => CardError::InvalidState, + rust_cktap::CardError::WeakNonce => CardError::WeakNonce, + rust_cktap::CardError::BadCBOR => CardError::BadCBOR, + rust_cktap::CardError::BackupFirst => CardError::BackupFirst, + rust_cktap::CardError::RateLimited => CardError::RateLimited, + } + } +} + +/// Errors returned by the card, CBOR deserialization or value encoding, or the APDU transport. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum CkTapError { + #[error(transparent)] + Card { err: CardError }, + #[error("CBOR deserialization error: {msg}")] + CborDe { msg: String }, + #[error("CBOR value error: {msg}")] + CborValue { msg: String }, + #[error("APDU transport error: {msg}")] + Transport { msg: String }, + #[error("Unknown card type")] + UnknownCardType, +} + +impl From for CkTapError { + fn from(value: rust_cktap::CkTapError) -> Self { + match value { + rust_cktap::CkTapError::Card(err) => CkTapError::Card { err: err.into() }, + rust_cktap::CkTapError::CborDe(msg) => CkTapError::CborDe { msg }, + rust_cktap::CkTapError::CborValue(msg) => CkTapError::CborValue { msg }, + rust_cktap::CkTapError::Transport(msg) => CkTapError::Transport { msg }, + rust_cktap::CkTapError::UnknownCardType => CkTapError::UnknownCardType, + } + } +} + +/// Errors returned by the `status` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum StatusError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, +} + +impl From for StatusError { + fn from(value: rust_cktap::StatusError) -> Self { + match value { + rust_cktap::StatusError::CkTap(err) => StatusError::CkTap { err: err.into() }, + rust_cktap::StatusError::KeyFromSlice(err) => StatusError::Key { err: err.into() }, + } + } +} + +/// Errors returned by the `read` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum ReadError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, +} + +impl From for ReadError { + fn from(value: rust_cktap::ReadError) -> Self { + match value { + rust_cktap::ReadError::CkTap(err) => ReadError::CkTap { err: err.into() }, + rust_cktap::ReadError::Secp256k1(err) => ReadError::Key { err: err.into() }, + rust_cktap::ReadError::KeyFromSlice(err) => ReadError::Key { err: err.into() }, + } + } +} + +/// Errors returned by the `certs` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum CertsError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, + #[error("Root cert is not from Coinkite. Card is counterfeit: {msg}")] + InvalidRootCert { msg: String }, +} + +impl From for CertsError { + fn from(value: rust_cktap::CertsError) -> Self { + match value { + rust_cktap::CertsError::CkTap(err) => CertsError::CkTap { err: err.into() }, + rust_cktap::CertsError::Secp256k1(err) => CertsError::Key { err: err.into() }, + rust_cktap::CertsError::KeyFromSlice(err) => CertsError::Key { err: err.into() }, + rust_cktap::CertsError::InvalidRootCert(msg) => CertsError::InvalidRootCert { msg }, + } + } +} + +/// Errors returned by the `derive` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum DeriveError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, + #[error("Invalid chain code: {msg}")] + InvalidChainCode { msg: String }, +} + +impl From for DeriveError { + fn from(value: rust_cktap::DeriveError) -> Self { + match value { + rust_cktap::DeriveError::CkTap(err) => DeriveError::CkTap { err: err.into() }, + rust_cktap::DeriveError::Secp256k1(err) => DeriveError::Key { err: err.into() }, + rust_cktap::DeriveError::KeyFromSlice(err) => DeriveError::Key { err: err.into() }, + rust_cktap::DeriveError::InvalidChainCode(msg) => DeriveError::InvalidChainCode { msg }, + } + } +} + +/// Errors returned by the `unseal` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum UnsealError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, +} + +impl From for UnsealError { + fn from(value: rust_cktap::UnsealError) -> Self { + match value { + rust_cktap::UnsealError::CkTap(err) => UnsealError::CkTap { err: err.into() }, + rust_cktap::UnsealError::Secp256k1(err) => UnsealError::Key { err: err.into() }, + rust_cktap::UnsealError::KeyFromSlice(err) => UnsealError::Key { err: err.into() }, + } + } +} + +/// Errors returned by the `dump` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum DumpError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error(transparent)] + Key { + #[from] + err: KeyError, + }, + #[error("Slot is sealed: {slot}")] + SlotSealed { slot: u8 }, + #[error("Slot is unused: {slot}")] + SlotUnused { slot: u8 }, + /// If the slot was unsealed due to confusion or uncertainty about its status. + /// In other words, if the card unsealed itself rather than via a + /// successful `unseal` command. + #[error("Slot was unsealed improperly: {slot}")] + SlotTampered { slot: u8 }, +} + +impl From for DumpError { + fn from(value: rust_cktap::DumpError) -> Self { + match value { + rust_cktap::DumpError::CkTap(err) => DumpError::CkTap { err: err.into() }, + rust_cktap::DumpError::Secp256k1(err) => DumpError::Key { err: err.into() }, + rust_cktap::DumpError::KeyFromSlice(err) => DumpError::Key { err: err.into() }, + rust_cktap::DumpError::SlotSealed(slot) => DumpError::SlotSealed { slot }, + rust_cktap::DumpError::SlotUnused(slot) => DumpError::SlotUnused { slot }, + rust_cktap::DumpError::SlotTampered(slot) => DumpError::SlotTampered { slot }, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum SignPsbtError { + #[error("Invalid path at index: {index}")] + InvalidPath { index: u64 }, + #[error("Invalid script at index: {index}")] + InvalidScript { index: u64 }, + #[error("Missing pubkey at index: {index}")] + MissingPubkey { index: u64 }, + #[error("Missing UTXO at index: {index}")] + MissingUtxo { index: u64 }, + #[error("Pubkey mismatch at index: {index}")] + PubkeyMismatch { index: u64 }, + #[error("Sighash error: {msg}")] + SighashError { msg: String }, + #[error("Signature error: {msg}")] + SignatureError { msg: String }, + #[error("Signing slot is not unsealed: {slot}")] + SlotNotUnsealed { slot: u8 }, + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error("Witness program error: {msg}")] + WitnessProgram { msg: String }, +} + +impl From for SignPsbtError { + fn from(value: rust_cktap::SignPsbtError) -> SignPsbtError { + match value { + rust_cktap::SignPsbtError::InvalidPath(index) => SignPsbtError::InvalidPath { + index: index as u64, + }, + rust_cktap::SignPsbtError::InvalidScript(index) => SignPsbtError::InvalidScript { + index: index as u64, + }, + rust_cktap::SignPsbtError::MissingPubkey(index) => SignPsbtError::MissingPubkey { + index: index as u64, + }, + rust_cktap::SignPsbtError::MissingUtxo(index) => SignPsbtError::MissingUtxo { + index: index as u64, + }, + rust_cktap::SignPsbtError::PubkeyMismatch(index) => SignPsbtError::PubkeyMismatch { + index: index as u64, + }, + rust_cktap::SignPsbtError::SighashError(msg) => SignPsbtError::SighashError { msg }, + rust_cktap::SignPsbtError::SignatureError(msg) => SignPsbtError::SignatureError { msg }, + rust_cktap::SignPsbtError::SlotNotUnsealed(slot) => { + SignPsbtError::SlotNotUnsealed { slot } + } + rust_cktap::SignPsbtError::CkTap(err) => SignPsbtError::CkTap { err: err.into() }, + rust_cktap::SignPsbtError::WitnessProgram(msg) => SignPsbtError::WitnessProgram { msg }, + } + } +} + +/// Errors returned by the `change` command. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum ChangeError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error("new cvc is too short, must be at least 6 bytes, was only {len} bytes")] + TooShort { len: u64 }, + #[error("new cvc is too long, must be at most 32 bytes, was {len} bytes")] + TooLong { len: u64 }, + #[error("new cvc is the same as the old one")] + SameAsOld, +} + +impl From for ChangeError { + fn from(value: rust_cktap::ChangeError) -> Self { + match value { + rust_cktap::ChangeError::CkTap(err) => ChangeError::CkTap { err: err.into() }, + rust_cktap::ChangeError::TooShort(len) => ChangeError::TooShort { len: len as u64 }, + rust_cktap::ChangeError::TooLong(len) => ChangeError::TooLong { len: len as u64 }, + rust_cktap::ChangeError::SameAsOld => ChangeError::SameAsOld, + } + } +} diff --git a/cktap-ffi/src/lib.rs b/cktap-ffi/src/lib.rs index 3c18eb3..e4d699a 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -1,92 +1,169 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +mod error; +mod sats_card; +mod sats_chip; +mod tap_signer; + uniffi::setup_scaffolding!(); -use rust_cktap::apdu::{AppletSelect, CommandApdu, ResponseApdu, StatusResponse}; -use rust_cktap::{Error as CoreError, rand_nonce as core_rand_nonce}; +use crate::error::{ + CertsError, ChainCodeError, CkTapError, KeyError, PsbtError, ReadError, StatusError, +}; +use crate::sats_card::SatsCard; +use crate::sats_chip::SatsChip; +use crate::tap_signer::TapSigner; +use futures::lock::Mutex; +use rust_cktap::Network; +use rust_cktap::shared::FactoryRootKey; +use rust_cktap::shared::{Certificate, Read}; use std::fmt::Debug; +use std::str::FromStr; +use std::sync::Arc; -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum Error { - #[error("Core Error: {msg}")] - Core { msg: String }, - #[error("Transport Error: {msg}")] - Transport { msg: String }, +#[uniffi::export(callback_interface)] +#[async_trait::async_trait] +pub trait CkTransport: Send + Sync { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, CkTapError>; } -impl From for Error { - fn from(e: CoreError) -> Self { - Error::Core { msg: e.to_string() } +pub struct CkTransportWrapper(Box); + +#[async_trait::async_trait] +impl rust_cktap::CkTransport for CkTransportWrapper { + async fn transmit_apdu( + &self, + command_apdu: Vec, + ) -> Result, rust_cktap::CkTapError> { + self.0 + .transmit_apdu(command_apdu) + .await + .map_err(|e| rust_cktap::CkTapError::Transport(e.to_string())) } } -#[derive(uniffi::Record)] -pub struct FfiStatusResponse { - pub proto: u64, - pub ver: String, - pub birth: u64, - // Flatten Option<(u8, u8)> to slot_0 and slot_1 - pub slot_0: Option, - pub slot_1: Option, - pub addr: Option, - pub tapsigner: Option, - pub satschip: Option, - pub path: Option>, - pub num_backups: Option, - pub pubkey: Vec, - pub card_nonce: Vec, // Use Vec for [u8; 16] - pub testnet: Option, - pub auth_delay: Option, +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct PrivateKey { + inner: rust_cktap::PrivateKey, } -impl From for FfiStatusResponse { - fn from(sr: StatusResponse) -> Self { - Self { - proto: sr.proto as u64, - ver: sr.ver, - birth: sr.birth as u64, - slot_0: sr.slots.map(|s| s.0), - slot_1: sr.slots.map(|s| s.1), - addr: sr.addr, - tapsigner: sr.tapsigner, - satschip: sr.satschip, - path: sr.path.map(|p| p.into_iter().map(|u| u as u64).collect()), - num_backups: sr.num_backups.map(|n| n as u64), - pubkey: sr.pubkey, - card_nonce: sr.card_nonce.to_vec(), - testnet: sr.testnet, - auth_delay: sr.auth_delay.map(|d| d as u64), - } +#[uniffi::export] +impl PrivateKey { + #[uniffi::constructor] + pub fn from(data: Vec) -> Result { + Ok(Self { + inner: rust_cktap::PrivateKey::from_slice(data.as_slice(), Network::Bitcoin) + .map_err(|e| KeyError::Secp256k1 { msg: e.to_string() })?, + }) + } + + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() } } -#[uniffi::export(callback_interface)] -pub trait CkTransportFfi: Send + Sync + Debug + 'static { - fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct PublicKey { + inner: rust_cktap::PublicKey, } #[uniffi::export] -pub async fn get_status(transport: Box) -> Result { - let cmd = AppletSelect::default(); - let command_apdu = cmd.apdu_bytes(); - let rapdu = transport - .transmit_apdu(command_apdu) - .map_err(|e| Error::Transport { msg: e.to_string() })?; - let response = StatusResponse::from_cbor(rapdu)?; - Ok(response.into()) +impl PublicKey { + #[uniffi::constructor] + pub fn from(data: Vec) -> Result { + Ok(Self { + inner: rust_cktap::PublicKey::from_slice(data.as_slice()) + .map_err(|e| KeyError::Secp256k1 { msg: e.to_string() })?, + }) + } + + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() + } } -#[derive(uniffi::Record)] -pub struct TestRecord { - pub message: String, - pub count: u32, +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct ChainCode { + inner: rust_cktap::ChainCode, } -// this is actually a class per Object not Record -#[derive(uniffi::Object)] -pub struct TestStruct { - pub value: u32, +#[uniffi::export] +impl ChainCode { + #[uniffi::constructor] + pub fn from_bytes(data: Vec) -> Result { + let data: [u8; 32] = data.try_into()?; + Ok(Self { + inner: rust_cktap::ChainCode::from(data), + }) + } + + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes().to_vec() + } +} + +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct Psbt { + inner: rust_cktap::Psbt, } #[uniffi::export] -pub fn rand_nonce() -> Vec { - core_rand_nonce().to_vec() +impl Psbt { + #[uniffi::constructor] + pub fn from_base64(data: String) -> Result { + Ok(Self { + inner: rust_cktap::Psbt::from_str(&data)?, + }) + } + + pub fn to_base64(&self) -> String { + self.inner.to_string() + } +} + +#[derive(uniffi::Enum)] +pub enum CkTapCard { + SatsCard(Arc), + TapSigner(Arc), + SatsChip(Arc), +} + +#[uniffi::export] +pub async fn to_cktap(transport: Box) -> Result { + let wrapper = CkTransportWrapper(transport); + let cktap: rust_cktap::CkTapCard = rust_cktap::shared::to_cktap(Arc::new(wrapper)).await?; + + match cktap { + rust_cktap::CkTapCard::SatsCard(sc) => { + Ok(CkTapCard::SatsCard(Arc::new(SatsCard(Mutex::new(sc))))) + } + rust_cktap::CkTapCard::TapSigner(ts) => { + Ok(CkTapCard::TapSigner(Arc::new(TapSigner(Mutex::new(ts))))) + } + rust_cktap::CkTapCard::SatsChip(sc) => { + Ok(CkTapCard::SatsChip(Arc::new(SatsChip(Mutex::new(sc))))) + } + } +} + +// command helpers + +async fn read( + card: &mut (impl Read + Send + Sync), + cvc: Option, +) -> Result, ReadError> { + card.read(cvc) + .await + .map(|pk| pk.to_bytes()) + .map_err(ReadError::from) +} + +async fn check_cert(card: &mut (impl Certificate + Send + Sync)) -> Result<(), CertsError> { + match card.check_certificate().await? { + FactoryRootKey::Pub(_) => Ok(()), + FactoryRootKey::Dev(_) => Err(CertsError::InvalidRootCert { + msg: "Developer Cert Found".to_string(), + }), + } } diff --git a/cktap-ffi/src/sats_card.rs b/cktap-ffi/src/sats_card.rs new file mode 100644 index 0000000..f1b9e25 --- /dev/null +++ b/cktap-ffi/src/sats_card.rs @@ -0,0 +1,130 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::error::{ + CertsError, CkTapError, DeriveError, DumpError, ReadError, SignPsbtError, UnsealError, +}; +use crate::{ChainCode, PrivateKey, Psbt, PublicKey, check_cert, read}; +use futures::lock::Mutex; +use rust_cktap::shared::{Authentication, Wait}; +use std::sync::Arc; + +#[derive(uniffi::Object)] +pub struct SatsCard(pub Mutex); + +#[derive(uniffi::Record, Debug, Clone)] +pub struct SatsCardStatus { + pub proto: u64, + pub ver: String, + pub birth: u64, + pub active_slot: u8, + pub num_slots: u8, + pub addr: Option, + pub pubkey: Vec, + pub auth_delay: Option, +} + +#[derive(uniffi::Record, Clone)] +pub struct UnsealedSlot { + slot: u8, + privkey: Option>, + pubkey: Arc, +} + +#[uniffi::export] +impl SatsCard { + pub async fn status(&self) -> SatsCardStatus { + let card = self.0.lock().await; + SatsCardStatus { + proto: card.proto as u64, + ver: card.ver().to_string(), + birth: card.birth as u64, + active_slot: card.slots.0, + num_slots: card.slots.1, + addr: card.addr.clone(), + pubkey: card.pubkey().to_bytes(), + auth_delay: card.auth_delay().map(|d| d as u8), + } + } + + pub async fn address(&self) -> Result { + let mut card = self.0.lock().await; + card.address().await.map_err(ReadError::from) + } + + pub async fn read(&self) -> Result, ReadError> { + let mut card = self.0.lock().await; + read(&mut *card, None).await + } + + pub async fn wait(&self) -> Result<(), CkTapError> { + let mut card = self.0.lock().await; + // if auth delay call wait + while card.auth_delay().is_some() { + card.wait(None).await?; + } + Ok(()) + } + + pub async fn check_cert(&self) -> Result<(), CertsError> { + let mut card = self.0.lock().await; + check_cert(&mut *card).await + } + + pub async fn new_slot( + &self, + slot: u8, + chain_code: Option>, + cvc: String, + ) -> Result { + let mut card = self.0.lock().await; + let chain_code = chain_code.map(|cc| cc.inner); + card.new_slot(slot, chain_code, &cvc) + .await + .map_err(CkTapError::from) + } + + pub async fn derive(&self) -> Result { + let mut card = self.0.lock().await; + let chain_code = card.derive().await.map(|cc| ChainCode { inner: cc })?; + Ok(chain_code) + } + + pub async fn unseal(&self, slot: u8, cvc: String) -> Result { + let mut card = self.0.lock().await; + let (privkey, pubkey) = card.unseal(slot, &cvc).await?; + let pubkey = Arc::new(PublicKey { inner: pubkey }); + let privkey = Some(Arc::new(PrivateKey { inner: privkey })); + Ok(UnsealedSlot { + slot, + pubkey, + privkey, + }) + } + + pub async fn dump(&self, slot: u8, cvc: Option) -> Result { + let mut card = self.0.lock().await; + let (privkey, pubkey) = card.dump(slot, cvc).await?; + let pubkey = Arc::new(PublicKey { inner: pubkey }); + let privkey = privkey.map(|sk| Arc::new(PrivateKey { inner: sk })); + Ok(UnsealedSlot { + slot, + pubkey, + privkey, + }) + } + + pub async fn sign_psbt( + &self, + slot: u8, + psbt: Arc, + cvc: String, + ) -> Result { + let mut card = self.0.lock().await; + let psbt = card + .sign_psbt(slot, (*psbt).clone().inner, &cvc) + .await + .map(|psbt| Psbt { inner: psbt })?; + Ok(psbt) + } +} diff --git a/cktap-ffi/src/sats_chip.rs b/cktap-ffi/src/sats_chip.rs new file mode 100644 index 0000000..ac107e6 --- /dev/null +++ b/cktap-ffi/src/sats_chip.rs @@ -0,0 +1,82 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::error::{CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignPsbtError}; +use crate::tap_signer::{change, derive, init, sign_psbt}; +use crate::{ChainCode, Psbt, PublicKey, check_cert, read}; +use futures::lock::Mutex; +use rust_cktap::shared::{Authentication, Wait}; +use std::sync::Arc; + +#[derive(uniffi::Object)] +pub struct SatsChip(pub Mutex); + +#[derive(uniffi::Record, Debug, Clone)] +pub struct SatsChipStatus { + pub proto: u64, + pub ver: String, + pub birth: u64, + pub path: Option>, + pub pubkey: Vec, + pub auth_delay: Option, +} + +#[uniffi::export] +impl SatsChip { + pub async fn status(&self) -> SatsChipStatus { + let card = self.0.lock().await; + SatsChipStatus { + proto: card.proto as u64, + ver: card.ver().to_string(), + birth: card.birth as u64, + path: card + .path + .clone() + .map(|p| p.iter().map(|&p| p as u64).collect()), + pubkey: card.pubkey().to_bytes(), + auth_delay: card.auth_delay().map(|d| d as u8), + } + } + + pub async fn read(&self) -> Result, ReadError> { + let mut card = self.0.lock().await; + read(&mut *card, None).await + } + + pub async fn wait(&self) -> Result<(), CkTapError> { + let mut card = self.0.lock().await; + // if auth delay call wait + while card.auth_delay().is_some() { + card.wait(None).await?; + } + Ok(()) + } + + pub async fn check_cert(&self) -> Result<(), CertsError> { + let mut card = self.0.lock().await; + check_cert(&mut *card).await + } + + pub async fn init(&self, chain_code: Arc, cvc: String) -> Result<(), CkTapError> { + let mut card = self.0.lock().await; + init(&mut *card, chain_code, cvc).await + } + + pub async fn sign_psbt(&self, psbt: Arc, cvc: String) -> Result { + let mut card = self.0.lock().await; + let psbt = sign_psbt(&mut *card, psbt, cvc).await?; + Ok(psbt) + } + + pub async fn derive(&self, path: Vec, cvc: String) -> Result { + let mut card = self.0.lock().await; + let pubkey = derive(&mut *card, path, cvc).await?; + Ok(pubkey) + } + + pub async fn change(&self, new_cvc: String, cvc: String) -> Result<(), ChangeError> { + let mut card = self.0.lock().await; + change(&mut *card, new_cvc, cvc).await?; + Ok(()) + } +} diff --git a/cktap-ffi/src/tap_signer.rs b/cktap-ffi/src/tap_signer.rs new file mode 100644 index 0000000..e46a4e3 --- /dev/null +++ b/cktap-ffi/src/tap_signer.rs @@ -0,0 +1,127 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::error::{CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignPsbtError}; +use crate::{ChainCode, Psbt, PublicKey, check_cert, read}; +use futures::lock::Mutex; +use rust_cktap::shared::{Authentication, Wait}; +use rust_cktap::tap_signer::TapSignerShared; +use std::sync::Arc; + +#[derive(uniffi::Object)] +pub struct TapSigner(pub Mutex); + +#[derive(uniffi::Record, Debug, Clone)] +pub struct TapSignerStatus { + pub proto: u64, + pub ver: String, + pub birth: u64, + pub path: Option>, + pub num_backups: u64, + pub pubkey: Vec, + pub auth_delay: Option, +} + +#[uniffi::export] +impl TapSigner { + pub async fn status(&self) -> TapSignerStatus { + let card = self.0.lock().await; + TapSignerStatus { + proto: card.proto as u64, + ver: card.ver().to_string(), + birth: card.birth as u64, + path: card + .path + .clone() + .map(|p| p.iter().map(|&p| p as u64).collect()), + num_backups: card.num_backups.unwrap_or_default() as u64, + pubkey: card.pubkey().to_bytes(), + auth_delay: card.auth_delay().map(|d| d as u8), + } + } + + pub async fn read(&self, cvc: String) -> Result, ReadError> { + let mut card = self.0.lock().await; + read(&mut *card, Some(cvc)).await + } + + pub async fn wait(&self) -> Result<(), CkTapError> { + let mut card = self.0.lock().await; + // if auth delay call wait + while card.auth_delay().is_some() { + card.wait(None).await?; + } + Ok(()) + } + + pub async fn check_cert(&self) -> Result<(), CertsError> { + let mut card = self.0.lock().await; + check_cert(&mut *card).await + } + + pub async fn init(&self, chain_code: Arc, cvc: String) -> Result<(), CkTapError> { + let mut card = self.0.lock().await; + init(&mut *card, chain_code, cvc).await + } + + pub async fn sign_psbt(&self, psbt: Arc, cvc: String) -> Result { + let mut card = self.0.lock().await; + let psbt = sign_psbt(&mut *card, psbt, cvc).await?; + Ok(psbt) + } + + pub async fn derive(&self, path: Vec, cvc: String) -> Result { + let mut card = self.0.lock().await; + let pubkey = derive(&mut *card, path, cvc).await?; + Ok(pubkey) + } + + pub async fn change(&self, new_cvc: String, cvc: String) -> Result<(), ChangeError> { + let mut card = self.0.lock().await; + change(&mut *card, new_cvc, cvc).await?; + Ok(()) + } +} + +pub async fn init( + card: &mut (impl TapSignerShared + Send + Sync), + chain_code: Arc, + cvc: String, +) -> Result<(), CkTapError> { + card.init(chain_code.inner, &cvc) + .await + .map_err(CkTapError::from) +} + +pub async fn sign_psbt( + card: &mut (impl TapSignerShared + Send + Sync), + psbt: Arc, + cvc: String, +) -> Result { + let psbt = card + .sign_psbt((*psbt).clone().inner, &cvc) + .await + .map(|psbt| Psbt { inner: psbt })?; + Ok(psbt) +} + +pub async fn derive( + card: &mut (impl TapSignerShared + Send + Sync), + path: Vec, + cvc: String, +) -> Result { + let pubkey = card + .derive(path, &cvc) + .await + .map(|pk| PublicKey { inner: pk })?; + Ok(pubkey) +} + +pub async fn change( + card: &mut (impl TapSignerShared + Send + Sync), + new_cvc: String, + cvc: String, +) -> Result<(), ChangeError> { + card.change(&new_cvc, &cvc).await?; + Ok(()) +} diff --git a/cktap-swift/Tests/CKTapTests/CKTapTests.swift b/cktap-swift/Tests/CKTapTests/CKTapTests.swift index bffb6d1..649a515 100644 --- a/cktap-swift/Tests/CKTapTests/CKTapTests.swift +++ b/cktap-swift/Tests/CKTapTests/CKTapTests.swift @@ -1,10 +1,32 @@ -import XCTest +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + import CKTap +import XCTest final class CKTapTests: XCTestCase { - func testHelloRandom() throws { - print("Hello!") - let nonce = randNonce() - print("Random: \(nonce)") + func testEmulatorTransport() async throws { + print("Test with card emulator transport") + let cardEmulator = CardEmulator() + + let card = try await toCktap(transport: cardEmulator) + + switch card { + case .satsCard(let satsCard): + print("Handling SatsCard with status: \(await satsCard.status())") + let address: String = try await satsCard.address() + print("SatsCard address: \(address)") + XCTAssertEqual(address, "bc1qdu05evh9kw0w482lfl2ktxm6ylp060km28z8fr") + case .tapSigner(let tapSigner): + let status = await tapSigner.status() + print("Handling TapSigner with status: \(status)") + let public_key = try await tapSigner.read(cvc: "123456") + print("TapSigner public key: \(Array(public_key))") + XCTAssertEqual(status.ver, "1.0.3") + case .satsChip(let satsChip): + let status = await satsChip.status() + print("Handling SatsChip with status: \(status)") + XCTAssertEqual(status.ver, "1.0.3") } + } } diff --git a/cktap-swift/Tests/CKTapTests/CardEmulator.swift b/cktap-swift/Tests/CKTapTests/CardEmulator.swift new file mode 100644 index 0000000..de4eed6 --- /dev/null +++ b/cktap-swift/Tests/CKTapTests/CardEmulator.swift @@ -0,0 +1,103 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +import CKTap +import Foundation + +// Example of a custom Swift implementation of the CkTransport callback interface +// start the emulator with the repo level command: just start +// stop the emulator with: just stop +// see other emulator options with: just help +final class CardEmulator: CkTransport { + let SELECT_CLA_INS_P1P2: [UInt8] = [0x00, 0xA4, 0x04, 0x00] + let APP_ID: [UInt8] = [0x0F, 0xF0] + Array("CoinkiteCARDv1".utf8) + // CBOR encoded status command, { cmd: "status" } + let STATUS_COMMAND: [UInt8] = [ + 0xA1, 0x63, 0x63, 0x6d, 0x64, 0x66, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + ] + + func transmitApdu(commandApdu: Data) async throws -> Data { + let socketPath = "/tmp/ecard-pipe" + + // Create a Unix domain socket + let socketFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFd != -1 else { + throw CkTapError.Transport( + msg: "Failed to create socket: \(String(cString: strerror(errno)))") + } + defer { + close(socketFd) + } + + // Set up the socket address structure + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + + // Copy the socket path to sun_path + let pathBytes = socketPath.utf8CString + guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { + throw CkTapError.Transport(msg: "Socket path too long") + } + + withUnsafeMutableBytes(of: &addr.sun_path) { ptr in + pathBytes.withUnsafeBufferPointer { pathPtr in + ptr.copyMemory(from: UnsafeRawBufferPointer(pathPtr)) + } + } + + // Connect to the socket + let connectResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + connect(socketFd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + + guard connectResult != -1 else { + throw CkTapError.Transport( + msg: "Failed to connect to socket: \(String(cString: strerror(errno)))") + } + + // Prepare the command data + let selectApdu: Data = Data(SELECT_CLA_INS_P1P2 + APP_ID) + let commandData: Data + if commandApdu == selectApdu { + commandData = Data(STATUS_COMMAND) + } else { + commandData = commandApdu[5...] // remove APDU header (first 5 bytes) + } + + // Write data to the socket + let writeResult = commandData.withUnsafeBytes { ptr in + send(socketFd, ptr.baseAddress!, commandData.count, 0) + } + + guard writeResult != -1 else { + throw CkTapError.Transport( + msg: "Failed to write to socket: \(String(cString: strerror(errno)))") + } + + guard writeResult == commandData.count else { + throw CkTapError.Transport( + msg: "Incomplete write to socket: wrote \(writeResult) of \(commandData.count) bytes") + } + + // Read response from the socket + let bufferSize = 4096 + var buffer = [UInt8](repeating: 0, count: bufferSize) + + let readResult = recv(socketFd, &buffer, bufferSize, 0) + guard readResult != -1 else { + throw CkTapError.Transport( + msg: "Failed to read from socket: \(String(cString: strerror(errno)))") + } + + guard readResult > 0 else { + throw CkTapError.Transport(msg: "Socket connection closed by peer") + } + + // Create Data from the received bytes + let responseData = Data(buffer.prefix(readResult)) + + return responseData + } +} diff --git a/cktap-swift/build-xcframework.sh b/cktap-swift/build-xcframework.sh index 6ff2543..531202c 100755 --- a/cktap-swift/build-xcframework.sh +++ b/cktap-swift/build-xcframework.sh @@ -1,5 +1,10 @@ #!/bin/bash +# +# Copyright (c) 2025 rust-cktap contributors +# SPDX-License-Identifier: MIT OR Apache-2.0 +# + # This script builds local cktap Swift language bindings and corresponding cktapFFI.xcframework. TARGETDIR="../target" @@ -17,7 +22,7 @@ GENERATED_MODULEMAP="${FFI_LIB_NAME}FFI.modulemap" NAME="cktapFFI" STATIC_LIB_FILENAME="lib${FFI_LIB_NAME}.a" -NEW_HEADER_DIR="../target/include" +NEW_HEADER_DIR="${TARGETDIR}/include" # set required rust version and install component and targets rustup default 1.85.0 @@ -30,11 +35,11 @@ rustup target add x86_64-apple-darwin # mac x86_64 # Create all required directories first mkdir -p Sources/CKTap -mkdir -p ../target/include -mkdir -p ../target/lipo-macos/release-smaller -mkdir -p ../target/lipo-ios-sim/release-smaller +mkdir -p ${TARGETDIR}/include +mkdir -p ${TARGETDIR}/lipo-macos/${RELDIR} +mkdir -p ${TARGETDIR}/lipo-ios-sim/${RELDIR} -cd ../ || exit +#cd ../ || exit # Target architectures # macOS Intel @@ -49,23 +54,23 @@ cargo build --package ${FFI_PKG_NAME} --profile ${RELDIR} --target aarch64-apple cargo build --package ${FFI_PKG_NAME} --profile ${RELDIR} --target aarch64-apple-ios # Then run uniffi-bindgen -cargo run --package ${FFI_PKG_NAME} --bin uniffi-bindgen generate \ - --library target/aarch64-apple-ios/${RELDIR}/${DYLIB_FILENAME} \ +cargo run --package ${FFI_PKG_NAME} --bin cktap-uniffi-bindgen generate \ + --library ${TARGETDIR}/aarch64-apple-ios/${RELDIR}/${DYLIB_FILENAME} \ --language swift \ - --out-dir cktap-swift/Sources/CKTap \ + --out-dir ./Sources/CKTap \ --no-format # Create universal library for simulator targets -lipo target/aarch64-apple-ios-sim/${RELDIR}/${STATIC_LIB_FILENAME} \ - target/x86_64-apple-ios/${RELDIR}/${STATIC_LIB_FILENAME} \ - -create -output target/lipo-ios-sim/${RELDIR}/${STATIC_LIB_FILENAME} +lipo ${TARGETDIR}/aarch64-apple-ios-sim/${RELDIR}/${STATIC_LIB_FILENAME} \ + ${TARGETDIR}/x86_64-apple-ios/${RELDIR}/${STATIC_LIB_FILENAME} \ + -create -output ${TARGETDIR}/lipo-ios-sim/${RELDIR}/${STATIC_LIB_FILENAME} # Create universal library for mac targets -lipo target/aarch64-apple-darwin/${RELDIR}/${STATIC_LIB_FILENAME} \ - target/x86_64-apple-darwin/${RELDIR}/${STATIC_LIB_FILENAME} \ - -create -output target/lipo-macos/${RELDIR}/${STATIC_LIB_FILENAME} +lipo ${TARGETDIR}/aarch64-apple-darwin/${RELDIR}/${STATIC_LIB_FILENAME} \ + ${TARGETDIR}/x86_64-apple-darwin/${RELDIR}/${STATIC_LIB_FILENAME} \ + -create -output ${TARGETDIR}/lipo-macos/${RELDIR}/${STATIC_LIB_FILENAME} -cd cktap-swift || exit +#cd cktap-swift || exit # Unique subdir to prevent collisions UNIQUE_HEADER_SUBDIR="${NEW_HEADER_DIR}/${HEADER_BASENAME}" diff --git a/cktap-swift/justfile b/cktap-swift/justfile index a16116e..88b8cc5 100644 --- a/cktap-swift/justfile +++ b/cktap-swift/justfile @@ -1,11 +1,17 @@ default: just --list +format: + swift-format format -i Tests/CKTapTests/*.swift + build: bash ./build-xcframework.sh clean: - rm -rf ../rust-cktap-ffi/target/ + cargo clean + rm -rf Sources + rm -rf .build + rm -rf *.xcframework -test: +test: format swift test diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 844d65d..14b96b8 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -14,6 +14,7 @@ pcsc = { version = "2" } clap = { version = "4.3.1", features = ["derive"] } rpassword = { version = "7.2" } tokio = { version = "1", features = ["full"] } +thiserror = "2.0.16" [features] emulator = ["rust-cktap/emulator"] diff --git a/cli/src/main.rs b/cli/src/main.rs index 3f5942b..a265649 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,19 +1,41 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + /// CLI for rust-cktap use clap::{Parser, Subcommand}; use rpassword::read_password; -use rust_cktap::commands::{Authentication, CkTransport, Read, Wait}; #[cfg(feature = "emulator")] use rust_cktap::emulator; +use rust_cktap::error::{DumpError, StatusError, UnsealError}; #[cfg(not(feature = "emulator"))] use rust_cktap::pcsc; -use rust_cktap::secp256k1::hashes::Hash as _; -use rust_cktap::secp256k1::rand; +use rust_cktap::shared::{Authentication, Read, Wait}; use rust_cktap::tap_signer::TapSignerShared; -use rust_cktap::{CkTapCard, apdu::Error, commands::Certificate, rand_chaincode}; +use rust_cktap::{ + CkTapCard, CkTapError, Psbt, PsbtParseError, SignPsbtError, rand_chaincode, shared::Certificate, +}; use std::io; use std::io::Write; #[cfg(feature = "emulator")] use std::path::Path; +use std::str::FromStr; + +/// Errors returned by the card, CBOR deserialization or value encoding, or the APDU transport. +#[derive(Debug, thiserror::Error)] +pub enum CliError { + #[error(transparent)] + PsbtParse(#[from] PsbtParseError), + #[error(transparent)] + Status(#[from] StatusError), + #[error(transparent)] + Unseal(#[from] UnsealError), + #[error(transparent)] + SignPsbt(#[from] SignPsbtError), + #[error(transparent)] + Dump(#[from] DumpError), + #[error(transparent)] + CkTap(#[from] CkTapError), +} /// SatsCard CLI #[derive(Parser)] @@ -28,7 +50,7 @@ struct SatsCardCli { #[derive(Subcommand)] enum SatsCardCommand { /// Show the card status - Debug, + Status, /// Show current deposit address Address, /// Check this card was made by Coinkite: Verifies a certificate chain up to root factory key. @@ -41,6 +63,15 @@ enum SatsCardCommand { Unseal, /// Get the payment address and verify it follows from the chain code and master public key Derive, + /// This reveals the details for any slot. The current slot is not affected. + Dump { slot: u8 }, + /// Sign a PSBT with the current slot (must be unsealed) + Sign { + /// Slot to sign with + slot: u8, + /// Unsigned PSBT + psbt: String, + }, /// Call wait command until no auth delay Wait, } @@ -58,7 +89,7 @@ struct TapSignerCli { #[derive(Subcommand)] enum TapSignerCommand { /// Show the card status - Debug, + Status, /// Check this card was made by Coinkite: Verifies a certificate chain up to root factory key. Certs, /// Read the pubkey (requires CVC) @@ -69,7 +100,7 @@ enum TapSignerCommand { Derive { /// path, eg. for 84'/0'/0'/* use 84,0,0 #[clap(short, long, value_delimiter = ',', num_args = 1..)] - path: Vec, + path: Option>, }, /// Get an encrypted backup of the card's private key Backup, @@ -94,7 +125,7 @@ struct SatsChipCli { #[derive(Subcommand)] enum SatsChipCommand { /// Show the card status - Debug, + Status, /// Check this card was made by Coinkite: Verifies a certificate chain up to root factory key. Certs, /// Read the pubkey (requires CVC) @@ -105,7 +136,7 @@ enum SatsChipCommand { Derive { /// path, eg. for 84'/0'/0'/* use 84,0,0 #[clap(short, long, value_delimiter = ',', num_args = 1..)] - path: Vec, + path: Option>, }, /// Change the PIN (CVC) used for card authentication to a new user provided one Change { new_cvc: String }, @@ -116,7 +147,7 @@ enum SatsChipCommand { } #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), CliError> { // figure out what type of card we have before parsing cli args #[cfg(not(feature = "emulator"))] let mut card = pcsc::find_first().await?; @@ -125,13 +156,11 @@ async fn main() -> Result<(), Error> { #[cfg(feature = "emulator")] let mut card = emulator::find_emulator(Path::new("/tmp/ecard-pipe")).await?; - let rng = &mut rand::thread_rng(); - match &mut card { CkTapCard::SatsCard(sc) => { let cli = SatsCardCli::parse(); match cli.command { - SatsCardCommand::Debug => { + SatsCardCommand::Status => { dbg!(&sc); } SatsCardCommand::Address => println!("Address: {}", sc.address().await.unwrap()), @@ -139,36 +168,49 @@ async fn main() -> Result<(), Error> { SatsCardCommand::Read => read(sc, None).await, SatsCardCommand::New => { let slot = sc.slot().expect("current slot number"); - let chain_code = Some(rand_chaincode(rng)); - let response = &sc.new_slot(slot, chain_code, &cvc()).await.unwrap(); + let chain_code = Some(rand_chaincode()); + let response = &sc.new_slot(slot, chain_code, &cvc()).await?; println!("{response}") } SatsCardCommand::Unseal => { let slot = sc.slot().expect("current slot number"); - let response = &sc.unseal(slot, &cvc()).await.unwrap(); - println!("{response}") + let (privkey, pubkey) = &sc.unseal(slot, &cvc()).await?; + println!("privkey: {}, pubkey: {pubkey}", privkey.to_wif()) + } + SatsCardCommand::Sign { slot, psbt } => { + let psbt = Psbt::from_str(&psbt)?; + let signed_psbt = sc.sign_psbt(slot, psbt, &cvc()).await?; + println!("signed_psbt: {signed_psbt}"); } SatsCardCommand::Derive => { dbg!(&sc.derive().await); } + SatsCardCommand::Dump { slot } => { + let cvc = cvc(); + let cvc = if cvc.is_empty() { None } else { Some(cvc) }; + let response = sc.dump(slot, cvc).await?; + dbg!(response); + } SatsCardCommand::Wait => wait(sc).await, } } CkTapCard::TapSigner(ts) => { let cli = TapSignerCli::parse(); match cli.command { - TapSignerCommand::Debug => { + TapSignerCommand::Status => { dbg!(&ts); } TapSignerCommand::Certs => check_cert(ts).await, TapSignerCommand::Read => read(ts, Some(cvc())).await, TapSignerCommand::Init => { - let chain_code = rand_chaincode(rng); + let chain_code = rand_chaincode(); let response = &ts.init(chain_code, &cvc()).await; dbg!(response); } TapSignerCommand::Derive { path } => { - dbg!(&ts.derive(&path, &cvc()).await); + // let test_path:Vec = ts.path.clone().unwrap().iter().map(|p| p ^ (1 << 31)).collect(); + // dbg!(test_path); + dbg!(&ts.derive(path.unwrap_or_default(), &cvc()).await); } TapSignerCommand::Backup => { @@ -182,8 +224,7 @@ async fn main() -> Result<(), Error> { } TapSignerCommand::Sign { to_sign } => { let digest: [u8; 32] = - rust_cktap::secp256k1::hashes::sha256::Hash::hash(to_sign.as_bytes()) - .to_byte_array(); + rust_cktap::Hash::hash(to_sign.as_bytes()).to_byte_array(); let response = &ts.sign(digest, vec![], &cvc()).await; println!("{response:?}"); @@ -194,18 +235,18 @@ async fn main() -> Result<(), Error> { CkTapCard::SatsChip(sc) => { let cli = SatsChipCli::parse(); match cli.command { - SatsChipCommand::Debug => { + SatsChipCommand::Status => { dbg!(&sc); } SatsChipCommand::Certs => check_cert(sc).await, SatsChipCommand::Read => read(sc, Some(cvc())).await, SatsChipCommand::Init => { - let chain_code = rand_chaincode(rng); + let chain_code = rand_chaincode(); let response = &sc.init(chain_code, &cvc()).await; dbg!(response); } SatsChipCommand::Derive { path } => { - dbg!(&sc.derive(&path, &cvc()).await); + dbg!(&sc.derive(path.unwrap_or_default(), &cvc()).await); } SatsChipCommand::Change { new_cvc } => { @@ -214,8 +255,7 @@ async fn main() -> Result<(), Error> { } SatsChipCommand::Sign { to_sign } => { let digest: [u8; 32] = - rust_cktap::secp256k1::hashes::sha256::Hash::hash(to_sign.as_bytes()) - .to_byte_array(); + rust_cktap::Hash::hash(to_sign.as_bytes()).to_byte_array(); let response = &sc.sign(digest, vec![], &cvc()).await; println!("{response:?}"); @@ -230,9 +270,9 @@ async fn main() -> Result<(), Error> { // handler functions for each command -async fn check_cert(card: &mut C) +async fn check_cert(card: &mut C) where - C: Certificate, + C: Certificate + Send, { if let Ok(k) = card.check_certificate().await { println!( @@ -244,9 +284,9 @@ where } } -async fn read(card: &mut C, cvc: Option) +async fn read(card: &mut C, cvc: Option) where - C: Read, + C: Read + Send, { match card.read(cvc).await { Ok(resp) => println!("{resp}"), @@ -264,22 +304,16 @@ fn cvc() -> String { cvc.trim().to_string() } -async fn wait(card: &mut C) +async fn wait(card: &mut C) where - C: Authentication + Wait, + C: Authentication + Wait + Send, { // if auth delay call wait if card.auth_delay().is_some() { - let mut entered_cvc = None; while card.auth_delay().is_some() { - if entered_cvc.is_none() { - entered_cvc = Some(cvc()); - print!("Auth delay:"); - io::stdout().flush().unwrap(); - } print!(" {}", card.auth_delay().unwrap()); io::stdout().flush().unwrap(); - let _result = card.wait(entered_cvc.clone()).await.expect("wait failed"); + card.wait(None).await.expect("wait failed"); } println!(); } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 197e4f6..aa99e4c 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -17,13 +17,15 @@ serde = "1" serde_bytes = "0.11" # async +async-trait = "0.1.81" tokio = { version = "1.44", features = ["macros"] } # error handling thiserror = "2.0" # bitcoin -bitcoin = { version = "0.32", features = ["rand-std"] } +bitcoin = { version = "0.32.7", features = ["rand-std", "base64"] } +bitcoin_hashes = "0.16.0" # logging log = "0.4" @@ -34,11 +36,8 @@ pcsc = { version = "2", optional = true } [dev-dependencies] tokio = { version = "1.44", features = ["rt"] } +bdk_wallet = { version = "2.1.0", features = ["all-keys","test-utils"] } [features] default = [] emulator = [] - -[[example]] -name = "pcsc" -required-features = ["pcsc"] diff --git a/lib/examples/pcsc.rs b/lib/examples/pcsc.rs deleted file mode 100644 index 0be40e5..0000000 --- a/lib/examples/pcsc.rs +++ /dev/null @@ -1,123 +0,0 @@ -extern crate core; - -use rust_cktap::apdu::Error; -use rust_cktap::commands::{Certificate, Wait}; -use rust_cktap::{CkTapCard, pcsc, rand_chaincode}; - -use bitcoin::secp256k1::rand; -use rust_cktap::tap_signer::TapSignerShared; -use std::io; -use std::io::Write; - -fn get_cvc() -> String { - print!("Enter cvc: "); - io::stdout().flush().unwrap(); - let mut cvc: String = String::new(); - let _btye_count = std::io::stdin().read_line(&mut cvc).unwrap(); - cvc.trim().to_string() -} - -// Example using pcsc crate -#[tokio::main] -async fn main() -> Result<(), Error> { - let card = pcsc::find_first().await?; - dbg!(&card); - - let rng = &mut rand::thread_rng(); - - match card { - CkTapCard::TapSigner(mut ts) => { - let cvc: String = get_cvc(); - - // if auth delay call wait - while ts.auth_delay.is_some() { - dbg!(ts.auth_delay.unwrap()); - ts.wait(None).await?; - } - - // only do this once per card! - if ts.path.is_none() { - let chain_code = rand_chaincode(rng); - let new_result = ts.init(chain_code, &cvc).await.unwrap(); - dbg!(new_result); - } - - // let read_result = ts.read(Some(cvc.clone())).await?; - // dbg!(read_result); - - dbg!(ts.check_certificate().await.unwrap().name()); - - //let dump_result = card.dump(); - - // let path = vec![2147483732, 2147483648, 2147483648]; - // let derive_result = ts.derive(path, cvc.clone()).await?; - // dbg!(&derive_result); - - // let nfc_result = card.nfc()?; - // dbg!(nfc_result); - } - CkTapCard::SatsChip(mut chip) => { - let _cvc: String = get_cvc(); - - // if auth delay call wait - while chip.auth_delay.is_some() { - dbg!(chip.auth_delay.unwrap()); - chip.wait(None).await?; - } - - // only do this once per card! - if chip.path.is_none() { - let chain_code = rand_chaincode(rng); - let new_result = chip.init(chain_code, &get_cvc()).await.unwrap(); - dbg!(new_result); - } - - // let read_result = chip.read(Some(cvc.clone())).await?; - // dbg!(read_result); - - // let nfc_result = card.nfc()?; - // dbg!(nfc_result); - } - CkTapCard::SatsCard(mut sc) => { - // if auth delay call wait - while sc.auth_delay.is_some() { - dbg!(sc.auth_delay.unwrap()); - let wait_response = sc.wait(None).await?; - dbg!(wait_response); - } - - // let read_result = sc.read(None).await?; - // dbg!(read_result); - - // let derive_result = sc.derive().await?; - // dbg!(&derive_result); - - dbg!(sc.check_certificate().await.unwrap().name()); - - // let nfc_result = card.nfc()?; - // dbg!(nfc_result); - - // if let Some(slot) = card.slots.first() { - // if slot == &0 { - // // TODO must unseal first - // let chain_code = rand_chaincode(rng).to_vec(); - // let new_result = sc.new_slot(0, chain_code, get_cvc()).await?; - // } - // } - - // let certs_result = card.certs()?; - // dbg!(certs_result); - - // let unseal_result = sc.unseal(0, get_cvc()).await?; - // dbg!(unseal_result); - - // let dump_result = sc.dump(0, None).await?; - // dbg!(dump_result); - - // let dump_result = sc.dump(0, Some(get_cvc())).await?; - // dbg!(dump_result); - } - } - - Ok(()) -} diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index 61f08d6..f743b69 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -1,141 +1,27 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + /// An Application Protocol Data Unit (APDU) is the unit of communication between a smart card /// reader and a smart card. This file defines the Coinkite APDU and set of command/responses. pub mod tap_signer; -use bitcoin::secp256k1::{ - self, PublicKey, SecretKey, XOnlyPublicKey, ecdh::SharedSecret, ecdsa::Signature, - hashes::hex::DisplayHex, -}; +use crate::error::{ErrorResponse, ReadError}; +use crate::{CardError, CkTapError}; +use bitcoin::bip32::ChainCode; +use bitcoin::secp256k1::{self, ecdh::SharedSecret, ecdsa::Signature}; +use bitcoin::{Network, PrivateKey, PublicKey}; +use bitcoin_hashes::hex::DisplayHex; use ciborium::de::from_reader; use ciborium::ser::into_writer; use ciborium::value::Value; -use serde; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Serialize, Serializer}; +use serde_bytes::ByteBuf; use std::fmt; use std::fmt::{Debug, Formatter}; -pub const APP_ID: [u8; 15] = *b"\xf0CoinkiteCARDv1"; -pub const SELECT_CLA_INS_P1P2: [u8; 4] = [0x00, 0xA4, 0x04, 0x00]; -pub const CBOR_CLA_INS_P1P2: [u8; 4] = [0x00, 0xCB, 0x00, 0x00]; - -// Errors -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum Error { - #[error("CiborDe: {0}")] - CiborDe(String), - #[error("CiborValue: {0}")] - CiborValue(String), - #[error("CkTap: {0:?}")] - CkTap(CkTapError), - #[error("IncorrectSignature: {0}")] - IncorrectSignature(String), - #[error("Root cert is not from Coinkite. Card is counterfeit: {0}")] - InvalidRootCert(String), - #[error("UnknownCardType: {0}")] - UnknownCardType(String), - - #[cfg(feature = "pcsc")] - #[error("PcSc: {0}")] - PcSc(String), - - #[cfg(feature = "emulator")] - #[error("Emulator: {0}")] - Emulator(String), -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] -pub enum CkTapError { - #[error("Rare or unlucky value used/occurred. Start again")] - UnluckyNumber, - #[error("Invalid/incorrect/incomplete arguments provided to command")] - BadArguments, - #[error("Authentication details (CVC/epubkey) are wrong")] - BadAuth, - #[error("Command requires auth, and none was provided")] - NeedsAuth, - #[error("The 'cmd' field is an unsupported command")] - UnknownCommand, - #[error("Command is not valid at this time, no point retrying")] - InvalidCommand, - #[error("You can't do that right now when card is in this state")] - InvalidState, - #[error("Nonce is not unique-looking enough")] - WeakNonce, - #[error("Unable to decode CBOR data stream")] - BadCBOR, - #[error("Can't change CVC without doing a backup first")] - BackupFirst, - #[error("Due to auth failures, delay required")] - RateLimited, -} - -impl CkTapError { - pub fn error_from_code(code: u16) -> Option { - match code { - 205 => Some(CkTapError::UnluckyNumber), - 400 => Some(CkTapError::BadArguments), - 401 => Some(CkTapError::BadAuth), - 403 => Some(CkTapError::NeedsAuth), - 404 => Some(CkTapError::UnknownCommand), - 405 => Some(CkTapError::InvalidCommand), - 406 => Some(CkTapError::InvalidState), - 417 => Some(CkTapError::WeakNonce), - 422 => Some(CkTapError::BadCBOR), - 425 => Some(CkTapError::BackupFirst), - 429 => Some(CkTapError::RateLimited), - _ => None, - } - } - - pub fn error_code(&self) -> u16 { - match self { - CkTapError::UnluckyNumber => 205, - CkTapError::BadArguments => 400, - CkTapError::BadAuth => 401, - CkTapError::NeedsAuth => 403, - CkTapError::UnknownCommand => 404, - CkTapError::InvalidCommand => 405, - CkTapError::InvalidState => 406, - CkTapError::WeakNonce => 417, - CkTapError::BadCBOR => 422, - CkTapError::BackupFirst => 425, - CkTapError::RateLimited => 429, - } - } -} - -impl From> for Error -where - T: Debug, -{ - fn from(e: ciborium::de::Error) -> Self { - Error::CiborDe(e.to_string()) - } -} -impl From for Error { - fn from(e: ciborium::value::Error) -> Self { - Error::CiborValue(e.to_string()) - } -} - -impl From for Error { - fn from(e: secp256k1::Error) -> Self { - Error::IncorrectSignature(e.to_string()) - } -} - -#[cfg(feature = "pcsc")] -impl From for Error { - fn from(e: pcsc::Error) -> Self { - Error::PcSc(e.to_string()) - } -} - -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct ErrorResponse { - pub error: String, - pub code: u16, -} +const APP_ID: [u8; 15] = *b"\xf0CoinkiteCARDv1"; +const SELECT_CLA_INS_P1P2: [u8; 4] = [0x00, 0xA4, 0x04, 0x00]; +const CBOR_CLA_INS_P1P2: [u8; 4] = [0x00, 0xCB, 0x00, 0x00]; // Apdu Traits pub trait CommandApdu { @@ -151,7 +37,7 @@ pub trait CommandApdu { } pub trait ResponseApdu { - fn from_cbor<'a>(cbor: Vec) -> Result + fn from_cbor<'a>(cbor: Vec) -> Result where Self: Deserialize<'a> + Debug, { @@ -159,8 +45,8 @@ pub trait ResponseApdu { let cbor_struct: Result = cbor_value.deserialized(); if let Ok(error_resp) = cbor_struct { - let error = CkTapError::error_from_code(error_resp.code).unwrap_or(CkTapError::BadCBOR); - return Err(Error::CkTap(error)); + let error = CardError::error_from_code(error_resp.code).unwrap_or(CardError::BadCBOR); + return Err(CkTapError::Card(error)); } let cbor_struct: Self = cbor_value.deserialized()?; @@ -174,7 +60,7 @@ fn build_apdu(header: &[u8], command: &[u8]) -> Vec { [header, &[command_len as u8], command].concat() } -/// Applet Select +/// Applet Select. #[derive(Default, Serialize, Clone, Debug, PartialEq, Eq)] pub struct AppletSelect {} @@ -188,7 +74,7 @@ impl CommandApdu for AppletSelect { } } -/// Status Command +/// Status Command. #[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct StatusCommand { /// 'status' command @@ -207,29 +93,51 @@ impl CommandApdu for StatusCommand { } } +/// Response value from the [`StatusCommand`]. #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct StatusResponse { + /// Version of CBOR protocol in use. pub proto: usize, + /// Firmware version of card itself. pub ver: String, + /// Card birth block height (int) (fixed after production). pub birth: usize, + /// SATSCARD Only: Tuple of (active_slot, num_slots). pub slots: Option<(u8, u8)>, + /// SATSCARD Only: Payment address, middle chars blanked out with 3 underscores. pub addr: Option, + /// TAPSIGNER only: will be Some(true). pub tapsigner: Option, + /// SATSCHIP only: will be Some(true). pub satschip: Option, + /// TAPSIGNER or SATSCHIP only: a short array of integers, the subkey derivation currently in + /// effect. It encodes a BIP-32 derivation path, like m/84h/0h/0h, which is a typical value for + /// segwit usage, although the value is controlled by the wallet application. The field is only + /// present if a master key has been picked (i.e., setup is complete). pub path: Option>, + /// Counts up, when backup command is used. pub num_backups: Option, + /// Public key unique to this card (fixed for card life) aka: card_pubkey. #[serde(with = "serde_bytes")] pub pubkey: Vec, + /// Random bytes, changed each time we reply to a valid command. #[serde(with = "serde_bytes")] pub card_nonce: [u8; 16], + /// A development card will also have a testnet=True field; if false, the field is not provided. + /// Testnet cannot be enabled after leaving the factory, and those cards are only used by + /// CoinKite for internal testing. pub testnet: Option, + /// Shows the number of seconds required between attempts. Use the wait command to pass the + /// time. Another attempt is allowed after the delay passes. If the CVC value is correct, + /// normal operation begins. If the CVC value is incorrect, the 15-second delay between + /// attempts continues. #[serde(default)] pub auth_delay: Option, } impl ResponseApdu for StatusResponse {} -/// Read Command +/// Read Command. /// /// Apps need to write a CBOR message to read a SATSCARD's current payment address, or a /// TAPSIGNER's derived public key. @@ -249,7 +157,7 @@ pub struct ReadCommand { } impl ReadCommand { - pub fn authenticated(nonce: [u8; 16], epubkey: PublicKey, xcvc: Vec) -> Self { + pub fn authenticated(nonce: [u8; 16], epubkey: secp256k1::PublicKey, xcvc: Vec) -> Self { ReadCommand { cmd: Self::name(), nonce, @@ -286,31 +194,30 @@ impl CommandApdu for ReadCommand { pub struct ReadResponse { /// signature over a bunch of fields using private key of slot, 64 bytes #[serde(with = "serde_bytes")] - pub sig: Vec, + sig: Vec, /// public key for this slot/derivation, 33 bytes #[serde(with = "serde_bytes")] - pub pubkey: Vec, + pubkey: Vec, /// new nonce value, for NEXT command (not this one), 16 bytes #[serde(with = "serde_bytes")] - pub card_nonce: [u8; 16], + pub(crate) card_nonce: [u8; 16], } impl ResponseApdu for ReadResponse {} impl ReadResponse { - pub fn signature(&self) -> Result { - Signature::from_compact(self.sig.as_slice()).map_err(|e| Error::CiborValue(e.to_string())) + pub fn signature(&self) -> Result { + Signature::from_compact(self.sig.as_slice()).map_err(ReadError::from) } - pub fn pubkey(&self, session_key: Option) -> Result { + pub fn pubkey(&self, session_key: Option) -> Result { if let Some(sk) = session_key { let pubkey_bytes = unzip(&self.pubkey, sk); - return PublicKey::from_slice(pubkey_bytes.as_slice()) - .map_err(|e| Error::CiborValue(e.to_string())); + return PublicKey::from_slice(pubkey_bytes.as_slice()).map_err(ReadError::from); }; let pubkey_bytes = self.pubkey.as_slice(); - PublicKey::from_slice(pubkey_bytes).map_err(|e| Error::CiborValue(e.to_string())) + PublicKey::from_slice(pubkey_bytes).map_err(ReadError::from) } } @@ -349,7 +256,11 @@ pub struct DeriveCommand { /// provided by app, cannot be all same byte (& should be random), 16 bytes #[serde(with = "serde_bytes")] nonce: [u8; 16], - path: Vec, // tapsigner: empty list for `m` case (a no-op) + #[serde( + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_some" + )] + path: Option>, // tapsigner: empty list for `m` case (a no-op) /// app's ephemeral public key, 33 bytes #[serde(with = "serde_bytes")] epubkey: Option<[u8; 33]>, @@ -369,7 +280,7 @@ impl DeriveCommand { DeriveCommand { cmd: Self::name(), nonce, - path: vec![], + path: None, epubkey: None, xcvc: None, } @@ -378,13 +289,13 @@ impl DeriveCommand { pub fn for_tapsigner( nonce: [u8; 16], path: Vec, - epubkey: PublicKey, + epubkey: secp256k1::PublicKey, xcvc: Vec, ) -> Self { DeriveCommand { cmd: Self::name(), nonce, - path, + path: Some(path), epubkey: Some(epubkey.serialize()), xcvc: Some(xcvc), } @@ -453,8 +364,7 @@ impl Default for CertsCommand { #[derive(Deserialize, Clone)] pub struct CertsResponse { /// list of certificates, from 'batch' to 'root' - // TODO create custom deserializer like "serde_bytes" but for Vec> - cert_chain: Vec, + cert_chain: Vec, } impl ResponseApdu for CertsResponse {} @@ -464,10 +374,7 @@ impl CertsResponse { self.clone() .cert_chain .into_iter() - .filter_map(|v| match v { - Value::Bytes(bv) => Some(bv), - _ => None, - }) + .map(|bb| bb.to_vec()) .collect() } } @@ -514,15 +421,15 @@ impl CheckCommand { } /// Check Certs Response -/// ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#certs +/// ref: [certs](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#certs) #[derive(Deserialize, Clone)] pub struct CheckResponse { /// signature using card_pubkey, 64 bytes #[serde(with = "serde_bytes")] - pub auth_sig: Vec, + pub(crate) auth_sig: Vec, /// new nonce value, for NEXT command (not this one), 16 bytes #[serde(with = "serde_bytes")] - pub card_nonce: [u8; 16], + pub(crate) card_nonce: [u8; 16], } impl ResponseApdu for CheckResponse {} @@ -537,6 +444,7 @@ impl Debug for CheckResponse { } /// nfc command to return dynamic url for NFC-enabled smart phone +#[allow(unused)] #[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct NfcCommand { cmd: &'static str, @@ -556,63 +464,77 @@ impl CommandApdu for NfcCommand { /// nfc Response /// -/// URL for smart phone to navigate to +/// URL for smartphone to navigate to #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct NfcResponse { /// command result - pub url: String, + url: String, } impl ResponseApdu for NfcResponse {} -/// Sign Command -// { -// 'cmd': 'sign', # command -// 'slot': 0, # (optional) which slot's to key to use, must be unsealed. -// 'subpath': [0, 0], # (TAPSIGNER only) additional derivation keypath to be used -// 'digest': (32 bytes), # message digest to be signed -// 'epubkey': (33 bytes), # app's ephemeral public key -// 'xcvc': (6 bytes) # encrypted CVC value -// } +// This function is needed because the subpath field can only be included (and not as an Option) +// when signing with a TAPSIGNER and NOT with a SATSCARD. +pub(crate) fn serialize_some(opt: &Option, serializer: S) -> Result +where + T: Serialize, + S: Serializer, +{ + opt.as_ref().unwrap().serialize(serializer) +} + +/// Sign Command. #[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct SignCommand { + /// Command, must be 'sign'. cmd: &'static str, + /// (SATSCARD only) Which slot's to key to use, must be unsealed. slot: Option, - // 0,1 or 2 length - #[serde(rename = "subpath")] - sub_path: Vec, - // additional keypath for TapSigner only + /// (TAPSIGNER only) Additional derivation key path to be used; must be 0, 1 or 2 length. + #[serde( + rename = "subpath", + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_some" + )] + sub_path: Option>, + /// Message digest to be signed. #[serde(with = "serde_bytes")] digest: [u8; 32], - // message digest to be signed + /// App's ephemeral public key. #[serde(with = "serde_bytes")] epubkey: [u8; 33], + /// Encrypted CVC value. #[serde(with = "serde_bytes")] xcvc: Vec, } impl SignCommand { - // pub fn for_satscard(slot: Option, digest: Vec, epubkey: Vec, xcvc: Vec) -> Self { - // Self { - // cmd: "sign".to_string(), - // slot, - // digest, - // subpath: None, - // epubkey, - // xcvc, - // } - // } + pub fn for_satscard( + slot: u8, + digest: [u8; 32], + epubkey: secp256k1::PublicKey, + xcvc: Vec, + ) -> Self { + SignCommand { + cmd: Self::name(), + slot: Some(slot), + sub_path: None, // field will not be included in serialization + digest, + epubkey: epubkey.serialize(), + xcvc, + } + } pub fn for_tapsigner( sub_path: Vec, digest: [u8; 32], - epubkey: PublicKey, + epubkey: secp256k1::PublicKey, xcvc: Vec, ) -> Self { SignCommand { cmd: Self::name(), slot: Some(0), - sub_path, + sub_path: Some(sub_path), // field and value will be serialized but without the Option digest, epubkey: epubkey.serialize(), xcvc, @@ -626,10 +548,14 @@ impl CommandApdu for SignCommand { } } -/// Sign Response -// SATSCARD: Arbitrary signatures can be created for unsealed slots. The app could perform this, since the private key is known, but it's best if the app isn't contaminated with private key information. This could be used for both spending and multisig wallet operations. -// -// TAPSIGNER: This is its core feature — signing an arbitrary message digest with a tap. Once the card is set up (the key is picked), the command will always be valid. +/// Sign Response. +/// +/// SATSCARD: Arbitrary signatures can be created for unsealed slots. The app could perform this, +/// since the private key is known, but it's best if the app isn't contaminated with private key +/// information. This could be used for both spending and multisig wallet operations. +/// +/// TAPSIGNER: This is its core feature — signing an arbitrary message digest with a tap. Once the +/// card is set up (the key is picked), the command will always be valid. #[derive(Deserialize, Clone, PartialEq, Eq)] pub struct SignResponse { /// command result @@ -678,10 +604,10 @@ pub struct WaitCommand { } impl WaitCommand { - pub fn new(epubkey: Option<[u8; 33]>, xcvc: Option>) -> Self { + pub fn new(epubkey: Option, xcvc: Option>) -> Self { WaitCommand { cmd: Self::name(), - epubkey, + epubkey: epubkey.map(|pk| pk.serialize()), xcvc, } } @@ -699,10 +625,10 @@ impl CommandApdu for WaitCommand { #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct WaitResponse { /// command result - pub success: bool, + success: bool, /// how much more delay is now required #[serde(default)] - pub auth_delay: usize, + pub(crate) auth_delay: usize, } impl ResponseApdu for WaitResponse {} @@ -734,11 +660,12 @@ pub struct NewCommand { impl NewCommand { pub fn new( slot: Option, - chain_code: Option<[u8; 32]>, - epubkey: PublicKey, + chain_code: Option, + epubkey: secp256k1::PublicKey, xcvc: Vec, ) -> Self { let slot = slot.unwrap_or_default(); + let chain_code = chain_code.map(|cc| cc.to_bytes()); NewCommand { cmd: Self::name(), slot, @@ -770,10 +697,10 @@ impl CommandApdu for NewCommand { #[derive(Deserialize, Clone, Debug)] pub struct NewResponse { /// slot just made - pub slot: u8, + pub(crate) slot: u8, /// new nonce value, for NEXT command (not this one) #[serde(with = "serde_bytes")] - pub card_nonce: [u8; 16], // 16 bytes + pub(crate) card_nonce: [u8; 16], // 16 bytes } impl ResponseApdu for NewResponse {} @@ -803,7 +730,7 @@ pub struct UnsealCommand { } impl UnsealCommand { - pub fn new(slot: u8, epubkey: PublicKey, xcvc: Vec) -> Self { + pub fn new(slot: u8, epubkey: secp256k1::PublicKey, xcvc: Vec) -> Self { UnsealCommand { cmd: Self::name(), slot, @@ -835,8 +762,9 @@ pub struct UnsealResponse { #[serde(with = "serde_bytes")] pub master_pk: Vec, /// nonce provided by customer + #[allow(unused)] #[serde(with = "serde_bytes")] - pub chain_code: Vec, + pub chain_code: Vec, // TODO verify this is same as selected by app /// new nonce value, for NEXT command (not this one), 16 bytes #[serde(with = "serde_bytes")] pub card_nonce: [u8; 16], @@ -846,13 +774,13 @@ impl ResponseApdu for UnsealResponse {} impl fmt::Display for UnsealResponse { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let master = XOnlyPublicKey::from_slice(self.master_pk.as_slice()).unwrap(); + let master = PublicKey::from_slice(self.master_pk.as_slice()).unwrap(); let pubkey = PublicKey::from_slice(self.pubkey.as_slice()).unwrap(); - let privkey = SecretKey::from_slice(self.privkey.as_slice()).unwrap(); + let privkey = PrivateKey::from_slice(self.privkey.as_slice(), Network::Bitcoin).unwrap(); writeln!(f, "slot: {}", self.slot)?; writeln!(f, "master_pk: {master}")?; writeln!(f, "pubkey: {pubkey}")?; - writeln!(f, "privkey: {}", privkey.display_secret()) + writeln!(f, "privkey: {}", privkey.to_wif()) } } @@ -872,7 +800,7 @@ pub struct DumpCommand { /// 'dump' command cmd: &'static str, /// which slot to dump, must be unsealed. - slot: usize, + slot: u8, /// app's ephemeral public key (optional), 33 bytes #[serde(with = "serde_bytes")] #[serde(skip_serializing_if = "Option::is_none")] @@ -884,7 +812,7 @@ pub struct DumpCommand { } impl DumpCommand { - pub fn new(slot: usize, epubkey: Option, xcvc: Option>) -> Self { + pub fn new(slot: u8, epubkey: Option, xcvc: Option>) -> Self { DumpCommand { cmd: Self::name(), slot, @@ -907,6 +835,7 @@ impl CommandApdu for DumpCommand { #[derive(Deserialize, Clone, Debug)] pub struct DumpResponse { /// slot just made + #[allow(unused)] // TODO verify this is correct slot pub slot: usize, /// private key for spending (for addr), 32 bytes /// The private keys are encrypted, XORed with the session key @@ -916,12 +845,14 @@ pub struct DumpResponse { /// public key, 33 bytes #[serde(with = "serde_bytes")] #[serde(default)] - pub pubkey: Vec, + pub pubkey: Option>, /// nonce provided by customer originally + #[allow(unused)] // TODO verify this is correct chain_code #[serde(with = "serde_bytes")] #[serde(default)] pub chain_code: Option>, /// master private key for this slot (was picked by card), 32 bytes + #[allow(unused)] // TODO verify this is correct pubkey #[serde(with = "serde_bytes")] #[serde(default)] pub master_pk: Option>, @@ -934,6 +865,7 @@ pub struct DumpResponse { /// if no xcvc provided, slot sealed status pub sealed: Option, /// if no xcvc provided, full payment address (not censored) + #[allow(unused)] // TODO verify this is correct address #[serde(default)] pub addr: Option, /// new nonce value, for NEXT command (not this one), 16 bytes diff --git a/lib/src/apdu/tap_signer.rs b/lib/src/apdu/tap_signer.rs index 7b84f82..aecbd05 100644 --- a/lib/src/apdu/tap_signer.rs +++ b/lib/src/apdu/tap_signer.rs @@ -1,12 +1,16 @@ -use core::fmt::{self, Formatter}; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 use super::{CommandApdu, ResponseApdu}; +use core::fmt::{self, Formatter}; -use bitcoin::secp256k1::{PublicKey, hashes::hex::DisplayHex as _}; +use bitcoin::secp256k1; +use bitcoin_hashes::hex::DisplayHex as _; use serde::{Deserialize, Serialize}; // MARK: - XpubCommand -/// TAPSIGNER only - Provides the current XPUB (BIP-32 serialized), either at the top level (master) or the derived key in use (see 'path' value in status response) +/// TAPSIGNER only - Provides the current XPUB (BIP-32 serialized), either at the top level (master) +/// or the derived key in use (see 'path' value in status response) #[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct XpubCommand { cmd: &'static str, // always "xpub" @@ -24,7 +28,8 @@ impl CommandApdu for XpubCommand { } impl XpubCommand { - pub fn new(master: bool, epubkey: PublicKey, xcvc: Vec) -> Self { + #[allow(unused)] // TODO this needs to be used + pub fn new(master: bool, epubkey: secp256k1::PublicKey, xcvc: Vec) -> Self { Self { cmd: Self::name(), master, @@ -37,9 +42,9 @@ impl XpubCommand { #[derive(Deserialize, Clone)] pub struct XpubResponse { #[serde(with = "serde_bytes")] - pub xpub: Vec, + xpub: Vec, #[serde(with = "serde_bytes")] - pub card_nonce: [u8; 16], + card_nonce: [u8; 16], } impl ResponseApdu for XpubResponse {} @@ -80,7 +85,7 @@ impl CommandApdu for ChangeCommand { } impl ChangeCommand { - pub fn new(data: Vec, epubkey: PublicKey, xcvc: Vec) -> Self { + pub fn new(data: Vec, epubkey: secp256k1::PublicKey, xcvc: Vec) -> Self { Self { cmd: Self::name(), data, @@ -123,7 +128,7 @@ impl CommandApdu for BackupCommand { } impl BackupCommand { - pub fn new(epubkey: PublicKey, xcvc: Vec) -> Self { + pub fn new(epubkey: secp256k1::PublicKey, xcvc: Vec) -> Self { Self { cmd: Self::name(), epubkey: epubkey.serialize(), diff --git a/lib/src/commands.rs b/lib/src/commands.rs deleted file mode 100644 index b50af24..0000000 --- a/lib/src/commands.rs +++ /dev/null @@ -1,367 +0,0 @@ -use crate::factory_root_key::FactoryRootKey; -use crate::{CkTapCard, SatsCard, TapSigner}; -use crate::{apdu::*, rand_nonce}; - -use bitcoin::key::rand; -use bitcoin::secp256k1::ecdh::SharedSecret; -use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; -use bitcoin::secp256k1::hashes::{Hash, sha256}; -use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, SecretKey}; - -use std::convert::TryFrom; - -use crate::sats_chip::SatsChip; -use std::fmt::Debug; -use std::future::Future; - -// Helper functions for authenticated commands. -pub trait Authentication { - fn secp(&self) -> &Secp256k1; - fn ver(&self) -> &str; - fn pubkey(&self) -> &PublicKey; - fn card_nonce(&self) -> &[u8; 16]; - fn set_card_nonce(&mut self, new_nonce: [u8; 16]); - fn auth_delay(&self) -> &Option; - fn set_auth_delay(&mut self, auth_delay: Option); - - fn transport(&self) -> &T; - - fn calc_ekeys_xcvc(&self, cvc: &str, command: &str) -> (SecretKey, PublicKey, Vec) { - let secp = Self::secp(self); - let pubkey = Self::pubkey(self); - let nonce = Self::card_nonce(self); - let cvc_bytes = cvc.as_bytes(); - let card_nonce_command = [nonce, command.as_bytes()].concat(); - let (ephemeral_private_key, ephemeral_public_key) = - secp.generate_keypair(&mut rand::thread_rng()); - - let session_key = SharedSecret::new(pubkey, &ephemeral_private_key); - let md = sha256::Hash::hash(card_nonce_command.as_slice()); - let md: &[u8; 32] = md.as_ref(); - - let mask: Vec = session_key - .as_ref() - .iter() - .zip(md) - .map(|(x, y)| x ^ y) - .take(cvc_bytes.len()) - .collect(); - - let xcvc = cvc_bytes.iter().zip(mask).map(|(x, y)| x ^ y).collect(); - (ephemeral_private_key, ephemeral_public_key, xcvc) - } -} - -pub trait CkTransport: Sized { - fn transmit(&self, command: &C) -> impl Future> - where - C: CommandApdu + serde::Serialize + Debug, - R: ResponseApdu + serde::de::DeserializeOwned + Debug, - { - async move { - let command_apdu = command.apdu_bytes(); - let rapdu = self.transmit_apdu(command_apdu).await?; - let response = R::from_cbor(rapdu.to_vec())?; - Ok(response) - } - } - fn transmit_apdu(&self, command_apdu: Vec) -> impl Future, Error>>; - - fn to_cktap(self) -> impl Future, Error>> { - async { - // Get status from card - let cmd = AppletSelect::default(); - let status_response: StatusResponse = self.transmit(&cmd).await?; - - // Return correct card variant using status - match (status_response.tapsigner, status_response.satschip) { - (Some(true), None) => { - let tap_signer = TapSigner::try_from_status(self, status_response)?; - Ok(CkTapCard::TapSigner(tap_signer)) - } - (Some(true), Some(true)) => { - let sats_chip = SatsChip::try_from_status(self, status_response)?; - Ok(CkTapCard::SatsChip(sats_chip)) - } - (None, None) => { - let sats_card = SatsCard::from_status(self, status_response)?; - Ok(CkTapCard::SatsCard(sats_card)) - } - (_, _) => Err(Error::UnknownCardType("Card not recognized.".to_string())), - } - } - } -} - -// card traits -pub trait Read: Authentication -where - T: CkTransport, -{ - fn requires_auth(&self) -> bool; - fn slot(&self) -> Option; - - fn read(&mut self, cvc: Option) -> impl Future> { - async move { - let card_nonce = *self.card_nonce(); - let app_nonce = rand_nonce(); - - let (cmd, session_key) = if self.requires_auth() { - let (eprivkey, epubkey, xcvc) = self - .calc_ekeys_xcvc(cvc.as_ref().expect("cvc is required"), ReadCommand::name()); - ( - ReadCommand::authenticated(app_nonce, epubkey, xcvc), - Some(SharedSecret::new(self.pubkey(), &eprivkey)), - ) - } else { - (ReadCommand::unauthenticated(app_nonce), None) - }; - - let read_response: ReadResponse = self.transport().transmit(&cmd).await?; - - self.secp().verify_ecdsa( - &self.message_digest(card_nonce, app_nonce.to_vec()), - &read_response.signature()?, // or add 'from' trait: Signature::from(response.sig: ) - &read_response.pubkey(session_key)?, - )?; - - self.set_card_nonce(read_response.card_nonce); - - Ok(read_response) - } - } - - fn message_digest(&self, card_nonce: [u8; 16], app_nonce: Vec) -> Message { - let mut message_bytes: Vec = Vec::new(); - message_bytes.extend("OPENDIME".as_bytes()); - message_bytes.extend(card_nonce); - message_bytes.extend(app_nonce); - - if let Some(slot) = self.slot() { - message_bytes.push(slot); - } else { - message_bytes.push(0); - } - - let hash = sha256::Hash::hash(message_bytes.as_slice()); - Message::from_digest(hash.to_byte_array()) - } -} - -pub trait Wait: Authentication -where - T: CkTransport, -{ - fn wait(&mut self, cvc: Option) -> impl Future> { - async move { - let epubkey_xcvc = cvc.map(|cvc| { - let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, WaitCommand::name()); - (epubkey, xcvc) - }); - - let (epubkey, xcvc) = epubkey_xcvc - .map(|(epubkey, xcvc)| (Some(epubkey.serialize()), Some(xcvc))) - .unwrap_or((None, None)); - - let wait_command = WaitCommand::new(epubkey, xcvc); - - let wait_response: WaitResponse = self.transport().transmit(&wait_command).await?; - if wait_response.auth_delay > 0 { - self.set_auth_delay(Some(wait_response.auth_delay)); - } else { - self.set_auth_delay(None); - } - - Ok(wait_response) - } - } -} - -pub trait Certificate: Read -where - T: CkTransport, -{ - fn message_digest_with_slot_pubkey( - &self, - card_nonce: [u8; 16], - app_nonce: [u8; 16], - slot_pubkey: Option, - ) -> Message { - let mut message_bytes: Vec = Vec::new(); - message_bytes.extend("OPENDIME".as_bytes()); - message_bytes.extend(card_nonce); - message_bytes.extend(app_nonce); - - if let Some(pubkey) = slot_pubkey { - if self.ver() != "0.9.0" { - let slot_pubkey_bytes = pubkey.serialize(); - message_bytes.extend(slot_pubkey_bytes); - } - } - - let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice()); - Message::from_digest(message_bytes_hash.to_byte_array()) - } - - fn check_certificate(&mut self) -> impl Future> { - async { - let nonce = rand_nonce(); - let card_nonce = *self.card_nonce(); - - let certs_cmd = CertsCommand::default(); - let certs_response: CertsResponse = self.transport().transmit(&certs_cmd).await?; - - let check_cmd = CheckCommand::new(nonce); - let check_response: CheckResponse = self.transport().transmit(&check_cmd).await?; - self.set_card_nonce(check_response.card_nonce); - - let slot_pubkey = self.slot_pubkey().await?; - self.verify_card_signature(check_response.auth_sig, card_nonce, nonce, slot_pubkey)?; - - let mut pubkey = *self.pubkey(); - for sig in &certs_response.cert_chain() { - // BIP-137: https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki - let subtract_by = match sig[0] { - 27..=30 => 27, // P2PKH uncompressed - 31..=34 => 31, // P2PKH compressed - 35..=38 => 35, // Segwit P2SH - 39..=42 => 39, // Segwit Bech32 - _ => panic!("Unrecognized BIP-137 address"), - }; - let rec_id = RecoveryId::from_i32((sig[0] as i32) - subtract_by)?; - let (_, sig) = sig.split_at(1); - let result = RecoverableSignature::from_compact(sig, rec_id); - let rec_sig = result?; - let pubkey_hash = sha256::Hash::hash(&pubkey.serialize()); - let md = Message::from_digest(pubkey_hash.to_byte_array()); - let result = self.secp().recover_ecdsa(&md, &rec_sig); - pubkey = result?; - } - - FactoryRootKey::try_from(pubkey) - } - } - - fn verify_card_signature( - &mut self, - signature: Vec, - card_nonce: [u8; 16], - app_nonce: [u8; 16], - slot_pubkey: Option, - ) -> Result<(), secp256k1::Error> { - let message = self.message_digest_with_slot_pubkey(card_nonce, app_nonce, slot_pubkey); - let signature = Signature::from_compact(signature.as_slice()) - .expect("Failed to construct ECDSA signature from check response"); - self.secp() - .verify_ecdsa(&message, &signature, self.pubkey()) - } - - fn slot_pubkey(&mut self) -> impl Future, Error>>; -} - -#[cfg(feature = "emulator")] -#[cfg(test)] -mod tests { - use super::*; - use std::path::Path; - - use crate::emulator::CVC; - use crate::emulator::find_emulator; - use crate::emulator::test::{CardTypeOption, EcardSubprocess}; - use crate::rand_chaincode; - use crate::tap_signer::TapSignerShared; - - #[tokio::test] - async fn test_new_command() { - let rng = &mut rand::thread_rng(); - let chain_code = rand_chaincode(rng); - for card_type in CardTypeOption::values() { - let pipe_path = format!("/tmp/test-new-command-pipe{card_type}"); - let pipe_path = Path::new(&pipe_path); - let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); - let emulator = find_emulator(pipe_path).await.unwrap(); - match emulator { - CkTapCard::SatsCard(mut sc) => { - assert_eq!(card_type, CardTypeOption::SatsCard); - let current_slot = sc.slots.0; - let response = sc.unseal(current_slot, CVC).await; - assert!(response.is_ok()); - let response = sc.new_slot(current_slot + 1, Some(chain_code), CVC).await; - assert!(response.is_ok()); - assert_eq!(sc.slots.0, current_slot + 1); - // test with no new chain_code - let current_slot = sc.slots.0; - let response = sc.unseal(current_slot, CVC).await; - assert!(response.is_ok()); - let response = sc.new_slot(current_slot + 1, None, CVC).await; - assert!(response.is_ok()); - assert_eq!(sc.slots.0, current_slot + 1); - } - CkTapCard::TapSigner(mut ts) => { - assert_eq!(card_type, CardTypeOption::TapSigner); - let response = ts.init(chain_code, CVC).await; - dbg!(&response); - assert!(response.is_ok()) - } - CkTapCard::SatsChip(mut sc) => { - assert_eq!(card_type, CardTypeOption::SatsChip); - let response = sc.init(chain_code, CVC).await; - assert!(response.is_ok()) - } - }; - drop(python); - } - } - - #[tokio::test] - async fn test_cert_command() { - for card_type in CardTypeOption::values() { - let emulator_root_pubkey = - "0312d005ca1501b1603c3b00412eefe27c6b20a74c29377263b357b3aff12de6fa".to_string(); - let pipe_path = format!("/tmp/test-cert-command-pipe{card_type}"); - let pipe_path = Path::new(&pipe_path); - let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); - let emulator = find_emulator(pipe_path).await.unwrap(); - match emulator { - CkTapCard::SatsCard(mut sc) => { - assert_eq!(card_type, CardTypeOption::SatsCard); - let response = sc.check_certificate().await; - assert!(response.is_err()); - matches!(response, Err(Error::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); - } - CkTapCard::TapSigner(mut ts) => { - assert_eq!(card_type, CardTypeOption::TapSigner); - let response = ts.check_certificate().await; - assert!(response.is_err()); - matches!(response, Err(Error::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); - } - CkTapCard::SatsChip(mut sc) => { - assert_eq!(card_type, CardTypeOption::SatsChip); - let response = sc.check_certificate().await; - assert!(response.is_err()); - matches!(response, Err(Error::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); - } - }; - drop(python); - } - } - - // #[test] - // fn test_tapsigner_signature() { - // let card_pubkey = PublicKey::from_slice( - // &from_hex("0335170d9b853440080b0e5d6129f985ebeb919e7a90f28a5fa15c7987ec986a6b") - // .as_slice(), - // ) - // .map_err(|e| Error::CiborValue(e.to_string())) - // .unwrap(); - // let signature: Vec = from_hex("44721225a42eb3496cc38858adf8fafde9a752776d36c719aaa4f255ab121a0864be7d21eb47a5db88e3879b53ea74794d3e9503cc9b56b8bf9f948324198c30"); - // let card_nonce: Vec = from_hex("fd4c5d2c9d9c5a647cbc0b2b79ffef91"); - // let app_nonce: Vec = from_hex("273faf8a0b270f697bcb6c90dc8cd4ba"); - // let secp = Secp256k1::new(); - // - // assert!( - // verify_tapsigner_signature(&card_pubkey, signature, card_nonce, app_nonce, &secp) - // .is_ok() - // ); - // } -} diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 2a40417..fd70dec 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -1,20 +1,28 @@ -use crate::CkTapCard; -use crate::apdu::{AppletSelect, CommandApdu, Error, StatusCommand}; -use crate::commands::CkTransport; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::apdu::{AppletSelect, CommandApdu, StatusCommand}; +use crate::error::StatusError; +use crate::shared::{CkTransport, to_cktap}; +use crate::{CkTapCard, CkTapError}; +use async_trait::async_trait; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; use std::path::Path; use std::string::ToString; +use std::sync::Arc; pub const CVC: &str = "123456"; -pub async fn find_emulator(pipe_path: &Path) -> Result, Error> { +pub async fn find_emulator(pipe_path: &Path) -> Result { if !pipe_path.exists() { - return Err(Error::Emulator("Emulator pipe doesn't exist.".to_string())); + return Err(StatusError::CkTap(CkTapError::Transport( + "Emulator pipe doesn't exist.".to_string(), + ))); } let stream = UnixStream::connect(pipe_path).expect("unix stream"); let card_emulator = CardEmulator { stream }; - card_emulator.to_cktap().await + to_cktap(Arc::new(card_emulator)).await } #[derive(Debug)] @@ -22,8 +30,9 @@ pub struct CardEmulator { stream: UnixStream, } +#[async_trait] impl CkTransport for CardEmulator { - async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error> { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, CkTapError> { // convert select_apdu into StatusCommand apdu bytes let select_apdu: Vec = AppletSelect::default().apdu_bytes(); let command_apdu = if command_apdu.eq(&select_apdu) { @@ -37,12 +46,12 @@ impl CkTransport for CardEmulator { // trim first 5 bytes from command apdu bytes to get the cbor data stream .write_all(&command_apdu.as_slice()[5..]) - .map_err(|e| Error::Emulator(e.to_string()))?; + .map_err(|e| CkTapError::Transport(e.to_string()))?; let mut buffer = [0; 4096]; // read up to 4096 bytes stream .read(&mut buffer[..]) - .map_err(|e| Error::Emulator(e.to_string()))?; + .map_err(|e| CkTapError::Transport(e.to_string()))?; Ok(buffer.to_vec()) } } @@ -134,7 +143,7 @@ pub mod test { pub async fn test_transmit() { let pipe_path = Path::new("/tmp/test-transmit-pipe"); let _python = EcardSubprocess::new(pipe_path, &CardTypeOption::SatsCard).unwrap(); - let emulator = find_emulator(pipe_path).await.unwrap(); - dbg!(emulator); + let emulator = find_emulator(pipe_path).await; + assert!(emulator.is_ok()); } } diff --git a/lib/src/error.rs b/lib/src/error.rs new file mode 100644 index 0000000..0ef405e --- /dev/null +++ b/lib/src/error.rs @@ -0,0 +1,241 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use serde::Deserialize; +use std::fmt::Debug; + +/// Errors returned by the card, CBOR deserialization or value encoding, or the APDU transport. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CkTapError { + #[error(transparent)] + Card(#[from] CardError), + #[error("CBOR deserialization error: {0}")] + CborDe(String), + #[error("CBOR value error: {0}")] + CborValue(String), + #[error("APDU transport error: {0}")] + Transport(String), + #[error("Unknown card type")] + UnknownCardType, +} + +/// Errors returned by the CkTap card. +#[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CardError { + #[error("Rare or unlucky value used/occurred. Start again")] + UnluckyNumber, + #[error("Invalid/incorrect/incomplete arguments provided to command")] + BadArguments, + #[error("Authentication details (CVC/epubkey) are wrong")] + BadAuth, + #[error("Command requires auth, and none was provided")] + NeedsAuth, + #[error("The 'cmd' field is an unsupported command")] + UnknownCommand, + #[error("Command is not valid at this time, no point retrying")] + InvalidCommand, + #[error("You can't do that right now when card is in this state")] + InvalidState, + #[error("Nonce is not unique-looking enough")] + WeakNonce, + #[error("Unable to decode CBOR data stream")] + BadCBOR, + #[error("Can't change CVC without doing a backup first")] + BackupFirst, + #[error("Due to auth failures, delay required")] + RateLimited, +} + +impl CardError { + pub fn error_from_code(code: u16) -> Option { + match code { + 205 => Some(CardError::UnluckyNumber), + 400 => Some(CardError::BadArguments), + 401 => Some(CardError::BadAuth), + 403 => Some(CardError::NeedsAuth), + 404 => Some(CardError::UnknownCommand), + 405 => Some(CardError::InvalidCommand), + 406 => Some(CardError::InvalidState), + 417 => Some(CardError::WeakNonce), + 422 => Some(CardError::BadCBOR), + 425 => Some(CardError::BackupFirst), + 429 => Some(CardError::RateLimited), + _ => None, + } + } + + pub fn error_code(&self) -> u16 { + match self { + CardError::UnluckyNumber => 205, + CardError::BadArguments => 400, + CardError::BadAuth => 401, + CardError::NeedsAuth => 403, + CardError::UnknownCommand => 404, + CardError::InvalidCommand => 405, + CardError::InvalidState => 406, + CardError::WeakNonce => 417, + CardError::BadCBOR => 422, + CardError::BackupFirst => 425, + CardError::RateLimited => 429, + } + } +} + +impl From> for CkTapError +where + T: Debug, +{ + fn from(e: ciborium::de::Error) -> Self { + CkTapError::CborDe(e.to_string()) + } +} + +impl From for CkTapError { + fn from(e: ciborium::value::Error) -> Self { + CkTapError::CborValue(e.to_string()) + } +} + +#[cfg(feature = "pcsc")] +impl From for CkTapError { + fn from(e: pcsc::Error) -> Self { + CkTapError::Transport(e.to_string()) + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ErrorResponse { + pub error: String, + pub code: u16, +} + +/// Errors returned by the `status` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum StatusError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), +} + +#[cfg(feature = "pcsc")] +impl From for StatusError { + fn from(e: pcsc::Error) -> Self { + StatusError::CkTap(CkTapError::Transport(e.to_string())) + } +} + +/// Errors returned by the `change` command. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum ChangeError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error("new cvc is too short, must be at least 6 bytes, was only {0} bytes")] + TooShort(usize), + #[error("new cvc is too long, must be at most 32 bytes, was {0} bytes")] + TooLong(usize), + #[error("new cvc is the same as the old one")] + SameAsOld, +} + +/// Errors returned by the `read` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ReadError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + Secp256k1(#[from] bitcoin::secp256k1::Error), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), +} + +impl From for CertsError { + fn from(e: ReadError) -> Self { + match e { + ReadError::CkTap(e) => CertsError::CkTap(e), + ReadError::Secp256k1(e) => CertsError::Secp256k1(e), + ReadError::KeyFromSlice(e) => CertsError::KeyFromSlice(e), + } + } +} + +/// Errors returned by the `certs` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum CertsError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + Secp256k1(#[from] bitcoin::secp256k1::Error), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), + #[error("Root cert is not from Coinkite. Card is counterfeit: {0}")] + InvalidRootCert(String), +} + +/// Errors returned by the `derive` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DeriveError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + Secp256k1(#[from] bitcoin::secp256k1::Error), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), + #[error("Invalid chain code: {0}")] + InvalidChainCode(String), +} + +/// Errors returned by the `unseal` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum UnsealError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + Secp256k1(#[from] bitcoin::secp256k1::Error), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), +} + +/// Errors returned by the `dump` command. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DumpError { + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error(transparent)] + Secp256k1(#[from] bitcoin::secp256k1::Error), + #[error(transparent)] + KeyFromSlice(#[from] bitcoin::key::FromSliceError), + #[error("Slot is sealed: {0}")] + SlotSealed(u8), + #[error("Slot is unused: {0}")] + SlotUnused(u8), + /// If the slot was unsealed due to confusion or uncertainty about its status. + /// In other words, if the card unsealed itself rather than via a + /// successful `unseal` command. + #[error("Slot was unsealed improperly: {0}")] + SlotTampered(u8), +} + +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum SignPsbtError { + #[error("Invalid path at index: {0}")] + InvalidPath(usize), + #[error("Invalid script at index: {0}")] + InvalidScript(usize), + #[error("Missing pubkey at index: {0}")] + MissingPubkey(usize), + #[error("Missing UTXO at index: {0}")] + MissingUtxo(usize), + #[error("Pubkey mismatch at index: {0}")] + PubkeyMismatch(usize), + #[error("Sighash error: {0}")] + SighashError(String), + #[error("Signature error: {0}")] + SignatureError(String), + #[error("Signing slot is not unsealed: {0}")] + SlotNotUnsealed(u8), + #[error(transparent)] + CkTap(#[from] CkTapError), + #[error("Witness program error: {0}")] + WitnessProgram(String), +} diff --git a/lib/src/factory_root_key.rs b/lib/src/factory_root_key.rs deleted file mode 100644 index 287c835..0000000 --- a/lib/src/factory_root_key.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::apdu::Error; -use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::hashes::hex::DisplayHex; -use std::convert::TryFrom; -use std::fmt; -use std::fmt::Debug; - -/// Published Coinkite factory root keys. -const PUB_FACTORY_ROOT_KEY: &str = - "03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1"; -/// Obsolete dev value, but keeping for a little while longer. -const DEV_FACTORY_ROOT_KEY: &str = - "027722ef208e681bac05f1b4b3cc478d6bf353ac9a09ff0c843430138f65c27bab"; - -pub enum FactoryRootKey { - Pub(PublicKey), - Dev(PublicKey), -} - -impl TryFrom for FactoryRootKey { - type Error = Error; - - fn try_from(pubkey: PublicKey) -> Result { - match pubkey.serialize().to_lower_hex_string().as_str() { - PUB_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Pub(pubkey)), - DEV_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Dev(pubkey)), - _ => Err(Error::InvalidRootCert( - pubkey.serialize().to_lower_hex_string(), - )), - } - } -} - -impl FactoryRootKey { - pub fn name(&self) -> String { - match &self { - FactoryRootKey::Pub(_) => "Root Factory Certificate".to_string(), - FactoryRootKey::Dev(_) => "Root Factory Certificate (TESTING ONLY)".to_string(), - } - } -} - -impl Debug for FactoryRootKey { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match &self { - FactoryRootKey::Pub(pk) => { - write!(f, "FactoryRootKey::Pub({pk:?})") - } - FactoryRootKey::Dev(pk) => { - write!(f, "FactoryRootKey::Dev({pk:?})") - } - } - } -} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 29e7f79..a340db9 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,52 +1,60 @@ -extern crate core; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 -use bitcoin::key::rand::Rng as _; -use commands::CkTransport; +extern crate core; -pub mod apdu; -pub mod commands; -pub mod factory_root_key; +pub use bitcoin::bip32::ChainCode; +pub use bitcoin::key::FromSliceError; +pub use bitcoin::psbt::{Psbt, PsbtParseError}; +pub use bitcoin::secp256k1::{Error as SecpError, rand}; +pub use bitcoin::{Network, PrivateKey, PublicKey}; +pub use bitcoin_hashes::sha256::Hash; -pub use bitcoin::{ - Address, Network, - key::CompressedPublicKey, - key::UntweakedPublicKey, - secp256k1::{self, rand}, +pub use error::{ + CardError, CertsError, ChangeError, CkTapError, DeriveError, DumpError, ReadError, + SignPsbtError, StatusError, UnsealError, }; +pub use shared::CkTransport; + +use bitcoin::key::rand::Rng as _; + +pub(crate) mod apdu; +pub mod error; +pub mod sats_card; +pub mod sats_chip; +pub mod shared; +pub mod tap_signer; #[cfg(feature = "emulator")] pub mod emulator; #[cfg(feature = "pcsc")] pub mod pcsc; -pub mod sats_card; -pub mod sats_chip; -pub mod tap_signer; -pub type TapSigner = tap_signer::TapSigner; -pub type SatsCard = sats_card::SatsCard; +pub type SatsCard = sats_card::SatsCard; +pub type TapSigner = tap_signer::TapSigner; +pub type SatsChip = sats_chip::SatsChip; -pub enum CkTapCard { - SatsCard(SatsCard), - TapSigner(TapSigner), - SatsChip(SatsChip), -} +// BIP 32 hardened derivation bitmask, 1 << 31 +const BIP32_HARDENED_MASK: u32 = 1 << 31; -// re-export -use crate::sats_chip::SatsChip; -pub use apdu::Error; +pub enum CkTapCard { + SatsCard(SatsCard), + TapSigner(TapSigner), + SatsChip(SatsChip), +} -impl core::fmt::Debug for CkTapCard { +impl core::fmt::Debug for CkTapCard { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match &self { - CkTapCard::TapSigner(t) => { - write!(f, "CkTap::TapSigner({t:?})") + CkTapCard::TapSigner(ts) => { + write!(f, "CkTap::TapSigner({ts:?})") } - CkTapCard::SatsChip(t) => { - write!(f, "CkTap::SatsChip({t:?})") + CkTapCard::SatsChip(sc) => { + write!(f, "CkTap::SatsChip({sc:?})") } - CkTapCard::SatsCard(s) => { - write!(f, "CkTap::SatsCard({s:?})") + CkTapCard::SatsCard(sc) => { + write!(f, "CkTap::SatsCard({sc:?})") } } } @@ -54,10 +62,11 @@ impl core::fmt::Debug for CkTapCard { // utility functions -pub fn rand_chaincode(rng: &mut rand::rngs::ThreadRng) -> [u8; 32] { +pub fn rand_chaincode() -> ChainCode { + let rng = &mut rand::thread_rng(); let mut chain_code = [0u8; 32]; rng.fill(&mut chain_code); - chain_code + ChainCode::from(chain_code) } pub fn rand_nonce() -> [u8; 16] { @@ -66,20 +75,3 @@ pub fn rand_nonce() -> [u8; 16] { rng.fill(&mut nonce); nonce } - -// Errors -// #[derive(Debug)] -// pub enum Error { - -// // #[cfg(feature = "pcsc")] -// // PcSc(String), -// } - -// impl From> for Error -// where -// T: Debug, -// { -// fn from(e: ciborium::de::Error) -> Self { -// Error::CiborDe(e.to_string()) -// } -// } diff --git a/lib/src/pcsc.rs b/lib/src/pcsc.rs index 858c830..876d872 100644 --- a/lib/src/pcsc.rs +++ b/lib/src/pcsc.rs @@ -1,10 +1,17 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + extern crate core; -use crate::Error; +use crate::CkTapError; +use crate::error::StatusError; +use crate::shared::to_cktap; use crate::{CkTapCard, CkTransport}; +use async_trait::async_trait; use pcsc::{Card, Context, MAX_BUFFER_SIZE, Protocols, Scope, ShareMode}; +use std::sync::Arc; -pub async fn find_first() -> Result, Error> { +pub async fn find_first() -> Result { // Establish a PC/SC context. let ctx = Context::establish(Scope::User)?; @@ -17,19 +24,21 @@ pub async fn find_first() -> Result, Error> { Some(reader) => Ok(reader), None => { //println!("No readers are connected."); - Err(Error::PcSc("No readers are connected.".to_string())) + Err(CkTapError::Transport( + "No readers are connected.".to_string(), + )) } }?; - ctx.connect(reader, ShareMode::Shared, Protocols::ANY)? - .to_cktap() - .await + let card = ctx.connect(reader, ShareMode::Shared, Protocols::ANY)?; + to_cktap(Arc::new(card)).await } +#[async_trait] impl CkTransport for Card { - async fn transmit_apdu(&self, apdu: Vec) -> Result, Error> { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, CkTapError> { let mut receive_buffer = vec![0; MAX_BUFFER_SIZE]; - let rapdu = self.transmit(apdu.as_slice(), &mut receive_buffer)?; + let rapdu = self.transmit(command_apdu.as_slice(), &mut receive_buffer)?; Ok(rapdu.to_vec()) } } diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index fd4ed00..c9318c5 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,17 +1,26 @@ -use bitcoin::hashes::{Hash as _, sha256}; -use bitcoin::key::CompressedPublicKey as BitcoinPublicKey; -use bitcoin::secp256k1::{All, Message, PublicKey, Secp256k1, ecdsa::Signature}; -use bitcoin::{Address, Network}; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 +use crate::CkTapError; use crate::apdu::{ - CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, NewCommand, - NewResponse, StatusResponse, UnsealCommand, UnsealResponse, + CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, NewCommand, + NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, UnsealResponse, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; -use crate::secp256k1::hashes::hex::DisplayHex; - -pub struct SatsCard { - pub transport: T, +use crate::error::{CardError, DeriveError, DumpError, ReadError, UnsealError}; +use crate::error::{SignPsbtError, StatusError}; +use crate::shared::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; +use async_trait::async_trait; +use bitcoin::bip32::{ChainCode, DerivationPath, Fingerprint, Xpub}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::{All, Message, Secp256k1, ecdsa::Signature}; +use bitcoin::{Address, CompressedPublicKey, Network, NetworkKind, PrivateKey, PublicKey}; +use bitcoin_hashes::hex::DisplayHex; +use bitcoin_hashes::sha256; +use std::str::FromStr; +use std::sync::Arc; + +pub struct SatsCard { + pub transport: Arc, pub secp: Secp256k1, pub proto: usize, pub ver: String, @@ -23,7 +32,7 @@ pub struct SatsCard { pub auth_delay: Option, } -impl Authentication for SatsCard { +impl Authentication for SatsCard { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -52,19 +61,22 @@ impl Authentication for SatsCard { self.auth_delay = auth_delay; } - fn transport(&self) -> &T { - &self.transport + fn transport(&self) -> Arc { + self.transport.clone() } } -impl SatsCard { - pub fn from_status(transport: T, status_response: StatusResponse) -> Result { +impl SatsCard { + pub fn from_status( + transport: Arc, + status_response: StatusResponse, + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(|e| Error::CiborValue(e.to_string()))?; + let pubkey = PublicKey::from_slice(pubkey)?; let slots = status_response .slots - .ok_or_else(|| Error::CiborValue("Missing slots".to_string()))?; + .ok_or_else(|| CkTapError::CborValue("Missing slots".to_string()))?; Ok(Self { transport, @@ -83,79 +95,317 @@ impl SatsCard { pub async fn new_slot( &mut self, slot: u8, - chain_code: Option<[u8; 32]>, + chain_code: Option, cvc: &str, - ) -> Result { + ) -> Result { let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, NewCommand::name()); let new_command = NewCommand::new(Some(slot), chain_code, epubkey, xcvc); - let new_response: NewResponse = self.transport.transmit(&new_command).await?; + let new_response: NewResponse = transmit(self.transport(), &new_command).await?; self.card_nonce = new_response.card_nonce; self.slots.0 = new_response.slot; - Ok(new_response) + Ok(new_response.slot) } - pub async fn derive(&mut self) -> Result { - let nonce = crate::rand_nonce(); + /// Verify the master public key and chain code used to derive the payment public key. + /// + /// The derivation is fixed as m/0, meaning the first non-hardened derived key. SATSCARD + /// always uses that derived key as the payment address. + /// + /// The user should verify the returned card chain code matches the chain code they provided + /// when they created the current slot, see: [`Self::new_slot`]. + /// + /// Ref: + pub async fn derive(&mut self) -> Result { + let app_nonce = crate::rand_nonce(); let card_nonce = *self.card_nonce(); - let cmd = DeriveCommand::for_satscard(nonce); - let resp: DeriveResponse = self.transport().transmit(&cmd).await?; - self.set_card_nonce(resp.card_nonce); + let cmd = DeriveCommand::for_satscard(app_nonce); + let derive_response: DeriveResponse = transmit(self.transport(), &cmd).await?; + self.set_card_nonce(derive_response.card_nonce); // Verify signature let mut message_bytes: Vec = Vec::new(); message_bytes.extend("OPENDIME".as_bytes()); message_bytes.extend(card_nonce); - message_bytes.extend(nonce); - message_bytes.extend(resp.chain_code); + message_bytes.extend(app_nonce); + message_bytes.extend(derive_response.chain_code); let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice()); let message = Message::from_digest(message_bytes_hash.to_byte_array()); - let signature = Signature::from_compact(&resp.sig)?; - - let pubkey = PublicKey::from_slice(&resp.master_pubkey)?; - self.secp().verify_ecdsa(&message, &signature, &pubkey)?; - - Ok(resp) + let signature = Signature::from_compact(&derive_response.sig)?; + + let master_pubkey = PublicKey::from_slice(&derive_response.master_pubkey)?; + self.secp() + .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; + + // response chain code, user should verify it matches the chain code they provided + let response_chaincode = &derive_response.chain_code; + + // SATSCARD returns the card's chain code and master public key + // With the master pubkey and the chain code, reconstructs a BIP-32 XPUB (extended public key) + // and verify it matches the payment address the card shares (i.e., the slot's pubkey) + // it must equal the BIP-32 derived key (m/0) constructed from that XPUB. + let card_chaincode = ChainCode::from(*response_chaincode); + let xpub = Xpub { + network: NetworkKind::Main, + depth: 0, + parent_fingerprint: Fingerprint::default(), // No parent for master key + child_number: bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(), + public_key: master_pubkey.inner, + chain_code: card_chaincode, + }; + + // Derive the child key at path m/0 + let derivation_path = DerivationPath::from_str("m/0").unwrap(); + let derived_xpub = xpub.derive_pub(self.secp(), &derivation_path).unwrap(); + + // Create a compressed public key for address generation + let bitcoin_pubkey = CompressedPublicKey(derived_xpub.public_key); + + // P2WPKH (Native SegWit address starting with 'bc1') + let p2wpkh_address = Address::p2wpkh(&bitcoin_pubkey, Network::Bitcoin).to_string(); + let self_addr = self.addr.clone().unwrap(); + + // if derived address and card address don't match then chaincode in response was not used + if !(p2wpkh_address[..12] == self_addr[..12] + && p2wpkh_address[p2wpkh_address.len().saturating_sub(12)..] + == self_addr[self_addr.len().saturating_sub(12)..]) + { + return Err(DeriveError::InvalidChainCode( + "Unable to derive card address".to_string(), + )); + } + + Ok(card_chaincode) } - pub async fn unseal(&mut self, slot: u8, cvc: &str) -> Result { - let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, UnsealCommand::name()); + /// Unseal a slot. + /// + /// Returns the private and corresponding public keys for that slot. + pub async fn unseal( + &mut self, + slot: u8, + cvc: &str, + ) -> Result<(PrivateKey, PublicKey), UnsealError> { + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, UnsealCommand::name()); let unseal_command = UnsealCommand::new(slot, epubkey, xcvc); - let unseal_response: UnsealResponse = self.transport.transmit(&unseal_command).await?; + let unseal_response: UnsealResponse = transmit(self.transport(), &unseal_command).await?; self.set_card_nonce(unseal_response.card_nonce); - Ok(unseal_response) + // private key for spending (for addr), 32 bytes + // the private key is encrypted, XORed with the session key, need to decrypt + let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey); + // decrypt the private key by XORing with the session key + let privkey: Vec = session_key + .as_ref() + .iter() + .zip(&unseal_response.privkey) + .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte) + .collect(); + let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?; + let pubkey = PublicKey::from_slice(&unseal_response.pubkey)?; + // TODO should verify user provided chain code was used similar to above `derive`. + Ok((privkey, pubkey)) } - pub async fn dump(&self, slot: usize, cvc: Option) -> Result { - let epubkey_xcvc = cvc.map(|cvc| { - let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, DumpCommand::name()); - (epubkey, xcvc) + /// Dumps the slot key(s) for an unsealed slot. + /// + /// With the CVC the private and public slot address keys are returned. + /// Without the CVC, the private key is not included. + /// If a sealed or unused slot number is given an error is returned. + pub async fn dump( + &mut self, + slot: u8, + cvc: Option, + ) -> Result<(Option, PublicKey), DumpError> { + let epubkey_eprivkey_xcvc = cvc.map(|cvc| { + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, DumpCommand::name()); + (epubkey, eprivkey, xcvc) }); - let (epubkey, xcvc) = epubkey_xcvc - .map(|(epubkey, xcvc)| (Some(epubkey), Some(xcvc))) - .unwrap_or((None, None)); - - let dump_command = DumpCommand::new(slot, epubkey, xcvc); - self.transport.transmit(&dump_command).await + let (epubkey, eprivkey, xcvc) = epubkey_eprivkey_xcvc + .map(|(epubkey, eprivkey, xcvc)| (Some(epubkey), Some(eprivkey), Some(xcvc))) + .unwrap_or((None, None, None)); + + let dump_command = DumpCommand::new(slot, epubkey, xcvc.clone()); + let dump_response: DumpResponse = transmit(self.transport(), &dump_command).await?; + self.set_card_nonce(dump_response.card_nonce); + + // throw errors + if let Some(tampered) = dump_response.tampered { + if tampered { + return Err(DumpError::SlotTampered(slot)); + } + } else if let Some(sealed) = dump_response.sealed { + if sealed { + return Err(DumpError::SlotSealed(slot)); + } + } else if let Some(used) = dump_response.used { + if !used { + return Err(DumpError::SlotUnused(slot)); + } + } + + // at this point pubkey must be available + let pubkey_bytes = dump_response.pubkey.unwrap(); + let pubkey = PublicKey::from_slice(&pubkey_bytes)?; + + // TODO use chaincode and master public key to verify pubkey or return error + + let seckey = if let (Some(privkey), Some(eprivkey)) = (dump_response.privkey, eprivkey) { + // the private key is encrypted, XORed with the session key, need to decrypt + let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey); + // decrypt the private key by XORing with the session key + let privkey: Vec = session_key + .as_ref() + .iter() + .zip(&privkey) + .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte) + .collect(); + let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?; + Some(privkey) + } else { + None + }; + + Ok((seckey, pubkey)) } - pub async fn address(&mut self) -> Result { + pub async fn address(&mut self) -> Result { let network = Network::Bitcoin; - let slot_pubkey = self.read(None).await?.pubkey; - let pk = BitcoinPublicKey::from_slice(&slot_pubkey)?; - let address = Address::p2wpkh(&pk, network); + let slot_pubkey = self.read(None).await?; + let slot_pubkey = CompressedPublicKey(slot_pubkey.inner); + let address = Address::p2wpkh(&slot_pubkey, network); Ok(address.to_string()) } + + /// Sign a message digest with the SATSCARD. + pub async fn sign( + &mut self, + digest: [u8; 32], + slot: u8, + cvc: &str, + ) -> Result { + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name()); + + // Use the same session key to encrypt the new CVC + let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey); + + // encrypt the digest by XORing with the session key + let xdigest_vec: Vec = session_key + .as_ref() + .iter() + .zip(digest) + .map(|(session_key_byte, digest_byte)| session_key_byte ^ digest_byte) + .collect(); + + let xdigest: [u8; 32] = xdigest_vec.try_into().expect("input is also 32 bytes"); + + let sign_command = SignCommand::for_satscard(slot, xdigest, epubkey, xcvc.clone()); + + let mut sign_response: Result = + transmit(self.transport(), &sign_command).await; + + let mut unlucky_number_retries = 0; + while let Err(CkTapError::Card(CardError::UnluckyNumber)) = sign_response { + let sign_command = SignCommand::for_satscard(slot, xdigest, epubkey, xcvc.clone()); + + sign_response = transmit(self.transport(), &sign_command).await; + unlucky_number_retries += 1; + + if unlucky_number_retries > 3 { + // TODO shouldn't this return an Err(UnluckyNumber) ? + break; + } + } + + let sign_response = sign_response?; + self.set_card_nonce(sign_response.card_nonce); + Ok(sign_response) + } + + /// Sign P2WPKH inputs for a PSBT with unsealed slot private key m/0 + /// This function will return a signed but not finalized PSBT. You will need to finalize the + /// PSBT yourself before it can be broadcast. + pub async fn sign_psbt( + &mut self, + slot: u8, + mut psbt: bitcoin::Psbt, + cvc: &str, + ) -> Result { + use bitcoin::{ + secp256k1::ecdsa, + sighash::{EcdsaSighashType, SighashCache}, + }; + + type Error = SignPsbtError; + + let unsigned_tx = psbt.unsigned_tx.clone(); + let mut sighash_cache = SighashCache::new(&unsigned_tx); + + for (input_index, input) in psbt.inputs.iter_mut().enumerate() { + // extract previous output data from the PSBT + let witness_utxo = input + .witness_utxo + .as_ref() + .ok_or(Error::MissingUtxo(input_index))?; + + let amount = witness_utxo.value; + + // extract the P2WPKH script from PSBT + let script_pubkey = &witness_utxo.script_pubkey; + if !script_pubkey.is_p2wpkh() { + return Err(Error::InvalidScript(input_index)); + } + + // get the public key from the PSBT + let key_pairs = &input.bip32_derivation; + let (psbt_pubkey, (_fingerprint, path)) = key_pairs + .iter() + .next() + .ok_or(Error::MissingPubkey(input_index))?; + + if !path.is_empty() { + return Err(Error::InvalidPath(input_index)); + } + + // calculate sighash + let script = script_pubkey.as_script(); + let sighash = sighash_cache + .p2wpkh_signature_hash(input_index, script, amount, EcdsaSighashType::All) + .map_err(|e| Error::SighashError(e.to_string()))?; + + // the digest is the sighash + let digest: &[u8; 32] = sighash.as_ref(); + + // send digest to SATSCARD for signing + let sign_response = self.sign(*digest, slot, cvc).await?; + let signature_raw = sign_response.sig; + + // verify that SATSCARD used the same public key as the PSBT + if sign_response.pubkey != psbt_pubkey.serialize() { + return Err(Error::PubkeyMismatch(input_index)); + } + + // update the PSBT input with the signature + let ecdsa_sig = ecdsa::Signature::from_compact(&signature_raw) + .map_err(|e| Error::SignatureError(e.to_string()))?; + + let final_sig = bitcoin::ecdsa::Signature::sighash_all(ecdsa_sig); + input.partial_sigs.insert((*psbt_pubkey).into(), final_sig); + } + + Ok(psbt) + } } -impl Wait for SatsCard {} +#[async_trait] +impl Wait for SatsCard {} -impl Read for SatsCard { +#[async_trait] +impl Read for SatsCard { fn requires_auth(&self) -> bool { false } @@ -164,14 +414,15 @@ impl Read for SatsCard { } } -impl Certificate for SatsCard { - async fn slot_pubkey(&mut self) -> Result, Error> { - let pubkey = self.read(None).await?.pubkey(None)?; +#[async_trait] +impl Certificate for SatsCard { + async fn slot_pubkey(&mut self) -> Result, ReadError> { + let pubkey = self.read(None).await?; Ok(Some(pubkey)) } } -impl core::fmt::Debug for SatsCard { +impl core::fmt::Debug for SatsCard { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("SatsCard") .field("proto", &self.proto) @@ -185,3 +436,142 @@ impl core::fmt::Debug for SatsCard { .finish() } } + +#[cfg(feature = "emulator")] +#[cfg(test)] +mod test { + use crate::CkTapCard; + use crate::emulator::find_emulator; + use crate::emulator::test::{CardTypeOption, EcardSubprocess}; + use crate::error::DumpError; + use crate::shared::Certificate; + use bdk_wallet::chain::{BlockId, ConfirmationBlockTime}; + use bdk_wallet::template::P2Wpkh; + use bdk_wallet::test_utils::{insert_anchor, insert_checkpoint, insert_tx, new_tx}; + use bdk_wallet::{KeychainKind, SignOptions, Wallet}; + use bitcoin::Network::Testnet; + use bitcoin::hashes::Hash; + use bitcoin::{Address, Amount, BlockHash, FeeRate, Network, Transaction, TxOut}; + use std::path::Path; + use std::str::FromStr; + + // verify a signed psbt can be finalized + #[tokio::test] + async fn test_satscard_sign_psbt() { + let card_type = CardTypeOption::SatsCard; + let pipe_path = "/tmp/test-satscard-sign-psbt-pipe"; + let pipe_path = Path::new(&pipe_path); + let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); + let emulator = find_emulator(pipe_path).await.unwrap(); + if let CkTapCard::SatsCard(mut sc) = emulator { + let slot_pubkey = sc.slot_pubkey().await.unwrap().unwrap(); + let card_address = sc.address().await.unwrap(); + let (_seckey, pubkey) = sc.unseal(0, "123456").await.unwrap(); + assert_eq!(pubkey, slot_pubkey); + + let descriptor = P2Wpkh(pubkey); + let mut wallet = Wallet::create_single(descriptor) + .network(Network::Bitcoin) + .create_wallet_no_persist() + .unwrap(); + let wallet_address = wallet.reveal_next_address(KeychainKind::External).address; + assert_eq!(&wallet_address.to_string(), &card_address); + + let tx0 = Transaction { + output: vec![TxOut { + value: Amount::from_sat(76_000), + script_pubkey: wallet_address.script_pubkey(), + }], + ..new_tx(0) + }; + + insert_checkpoint( + &mut wallet, + BlockId { + height: 1_000, + hash: BlockHash::all_zeros(), + }, + ); + insert_checkpoint( + &mut wallet, + BlockId { + height: 2_000, + hash: BlockHash::all_zeros(), + }, + ); + insert_tx(&mut wallet, tx0.clone()); + insert_anchor( + &mut wallet, + tx0.compute_txid(), + ConfirmationBlockTime { + block_id: BlockId { + height: 1_000, + hash: BlockHash::all_zeros(), + }, + confirmation_time: 100, + }, + ); + let balance = wallet.balance(); + assert_eq!(balance.confirmed, Amount::from_sat(76_000)); + let mut builder = wallet.build_tx(); + builder + .add_recipient( + Address::from_str("tb1qlvuuza7al8k6shl67qv00g8va3eepy70a42nxd") + .unwrap() + .require_network(Testnet) + .unwrap(), + Amount::from_sat(1000), + ) + .fee_rate(FeeRate::from_sat_per_vb(2).unwrap()); + let psbt = builder.finish().unwrap(); + let mut signed_psbt = sc.sign_psbt(0, psbt, "123456").await.unwrap(); + let finalized = wallet + .finalize_psbt(&mut signed_psbt, SignOptions::default()) + .unwrap(); + assert!(finalized); + } else { + panic!("Expected SatsCard"); + } + + drop(python); + } + + // test dumping a sealed, unsealed, and unsealed slot + #[tokio::test] + async fn test_satscard_dump() { + let card_type = CardTypeOption::SatsCard; + let pipe_path = "/tmp/test-satscard-dump-pipe"; + let pipe_path = Path::new(&pipe_path); + let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); + let emulator = find_emulator(pipe_path).await.unwrap(); + if let CkTapCard::SatsCard(mut sc) = emulator { + // slot 0 is sealed, with cvc return sealed error + let slot_keys = sc.dump(0, Some("123456".to_string())).await; + assert!(matches!(slot_keys, Err(DumpError::SlotSealed(slot)) if slot == 0)); + // slot 0 is sealed, with no cvc return sealed error + let slot_keys = sc.dump(0, None).await; + assert!(matches!(slot_keys, Err(DumpError::SlotSealed(slot)) if slot == 0)); + // unseal slot 0 + sc.unseal(0, "123456").await.unwrap(); + // slot 0 is unsealed, with cvc return privkey + let slot_keys = sc.dump(0, Some("123456".to_string())).await; + assert!(slot_keys.is_ok()); + assert!(matches!(slot_keys, Ok((Some(_), _)))); + let slot_keys = slot_keys.unwrap(); + // verify pubkey matches pubkey derived from decrypted private key + let pubkey_from_privkey = slot_keys.0.unwrap().public_key(&sc.secp); + assert_eq!(pubkey_from_privkey, slot_keys.1); + // slot 0 is unsealed, with no cvc don't return privkey + let slot_keys = sc.dump(0, None).await; + assert!(slot_keys.is_ok()); + assert!(matches!(slot_keys, Ok((None, _)))); + // slot 1 is unused, with cvc return unused error + let dump_response = sc.dump(1, Some("123456".to_string())).await; + assert!(matches!(dump_response, Err(DumpError::SlotUnused(slot)) if slot == 1)); + // slot 1 is unused, with no cvc also return unused error + let dump_response = sc.dump(1, None).await; + assert!(matches!(dump_response, Err(DumpError::SlotUnused(slot)) if slot == 1)); + } + drop(python); + } +} diff --git a/lib/src/sats_chip.rs b/lib/src/sats_chip.rs index 8e9dfb2..8f40f87 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -1,15 +1,22 @@ -use bitcoin::secp256k1::{All, PublicKey, Secp256k1}; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 -use crate::apdu::{Error, StatusResponse}; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; +use async_trait::async_trait; +use bitcoin::PublicKey; +use bitcoin::secp256k1::{All, Secp256k1}; +use std::sync::Arc; + +use crate::apdu::StatusResponse; +use crate::error::{ReadError, StatusError}; +use crate::shared::{Authentication, Certificate, CkTransport, Read, Wait}; use crate::tap_signer::TapSignerShared; /// - SATSCHIP model: this product variant is a TAPSIGNER in all respects, /// except, as of v1.0.0: `num_backups` in status field is omitted, and /// a flag `satschip=True` will be present instead. The "backup" command /// is not supported and will fail with 404 error. -pub struct SatsChip { - pub transport: T, +pub struct SatsChip { + pub transport: Arc, pub secp: Secp256k1, pub proto: usize, pub ver: String, @@ -22,7 +29,7 @@ pub struct SatsChip { pub auth_delay: Option, } -impl Authentication for SatsChip { +impl Authentication for SatsChip { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -51,19 +58,23 @@ impl Authentication for SatsChip { self.auth_delay = auth_delay; } - fn transport(&self) -> &T { - &self.transport + fn transport(&self) -> Arc { + self.transport.clone() } } -impl TapSignerShared for SatsChip {} +#[async_trait] +impl TapSignerShared for SatsChip {} -impl SatsChip { - pub fn try_from_status(transport: T, status_response: StatusResponse) -> Result { +impl SatsChip { + pub fn try_from_status( + transport: Arc, + status_response: StatusResponse, + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(|e| Error::CiborValue(e.to_string()))?; + let pubkey = PublicKey::from_slice(pubkey).map_err(StatusError::from)?; - Ok(Self { + Ok(SatsChip { transport, secp: Secp256k1::new(), proto: status_response.proto, @@ -78,9 +89,11 @@ impl SatsChip { } } -impl Wait for SatsChip {} +#[async_trait] +impl Wait for SatsChip {} -impl Read for SatsChip { +#[async_trait] +impl Read for SatsChip { fn requires_auth(&self) -> bool { true } @@ -90,13 +103,14 @@ impl Read for SatsChip { } } -impl Certificate for SatsChip { - async fn slot_pubkey(&mut self) -> Result, Error> { +#[async_trait] +impl Certificate for SatsChip { + async fn slot_pubkey(&mut self) -> Result, ReadError> { Ok(None) } } -impl core::fmt::Debug for SatsChip { +impl core::fmt::Debug for SatsChip { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("SatsChip") .field("proto", &self.proto) diff --git a/lib/src/shared.rs b/lib/src/shared.rs new file mode 100644 index 0000000..8e3cd64 --- /dev/null +++ b/lib/src/shared.rs @@ -0,0 +1,386 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::{CardError, CkTapCard, CkTapError, SatsCard, TapSigner}; +use crate::{apdu::*, rand_nonce}; + +use bitcoin::key::{PublicKey, rand}; +use bitcoin::secp256k1; +use bitcoin::secp256k1::ecdh::SharedSecret; +use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; +use bitcoin::secp256k1::{All, Message, Secp256k1}; +use bitcoin_hashes::sha256; + +use crate::error::{CertsError, ReadError, StatusError}; +use crate::sats_chip::SatsChip; +use async_trait::async_trait; +use bitcoin_hashes::hex::DisplayHex; +use std::convert::TryFrom; +use std::fmt; +use std::fmt::Debug; +use std::sync::Arc; + +/// Published Coinkite factory root keys. +const PUB_FACTORY_ROOT_KEY: &str = + "03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1"; +/// Obsolete dev value, but keeping for a little while longer. +const DEV_FACTORY_ROOT_KEY: &str = + "027722ef208e681bac05f1b4b3cc478d6bf353ac9a09ff0c843430138f65c27bab"; + +pub enum FactoryRootKey { + Pub(secp256k1::PublicKey), + Dev(secp256k1::PublicKey), +} + +impl TryFrom for FactoryRootKey { + type Error = CertsError; + + fn try_from(pubkey: secp256k1::PublicKey) -> Result { + match pubkey.serialize().to_lower_hex_string().as_str() { + PUB_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Pub(pubkey)), + DEV_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Dev(pubkey)), + _ => Err(CertsError::InvalidRootCert( + pubkey.serialize().to_lower_hex_string(), + )), + } + } +} + +impl FactoryRootKey { + pub fn name(&self) -> String { + match &self { + FactoryRootKey::Pub(_) => "Root Factory Certificate".to_string(), + FactoryRootKey::Dev(_) => "Root Factory Certificate (TESTING ONLY)".to_string(), + } + } +} + +impl Debug for FactoryRootKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self { + FactoryRootKey::Pub(pk) => { + write!(f, "FactoryRootKey::Pub({pk:?})") + } + FactoryRootKey::Dev(pk) => { + write!(f, "FactoryRootKey::Dev({pk:?})") + } + } + } +} + +/// Helper functions for authenticated commands. +pub trait Authentication { + fn secp(&self) -> &Secp256k1; + fn ver(&self) -> &str; + fn pubkey(&self) -> &PublicKey; + fn card_nonce(&self) -> &[u8; 16]; + fn set_card_nonce(&mut self, new_nonce: [u8; 16]); + fn auth_delay(&self) -> &Option; + fn set_auth_delay(&mut self, auth_delay: Option); + fn transport(&self) -> Arc; + + /// Calculate ephemeral key pair and XOR'd CVC. + /// ref: ["Authenticating Commands with CVC"](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#authenticating-commands-with-cvc) + fn calc_ekeys_xcvc( + &self, + cvc: &str, + command: &str, + ) -> (secp256k1::SecretKey, secp256k1::PublicKey, Vec) { + let secp = Self::secp(self); + let pubkey = Self::pubkey(self); + let nonce = Self::card_nonce(self); + let cvc_bytes = cvc.as_bytes(); + let card_nonce_command = [nonce, command.as_bytes()].concat(); + let (ephemeral_private_key, ephemeral_public_key) = + secp.generate_keypair(&mut rand::thread_rng()); + + let session_key = SharedSecret::new(&pubkey.inner, &ephemeral_private_key); + let md = sha256::Hash::hash(card_nonce_command.as_slice()); + let md: &[u8; 32] = md.as_ref(); + + let mask: Vec = session_key + .as_ref() + .iter() + .zip(md) + .map(|(x, y)| x ^ y) + .take(cvc_bytes.len()) + .collect(); + + let xcvc = cvc_bytes.iter().zip(mask).map(|(x, y)| x ^ y).collect(); + (ephemeral_private_key, ephemeral_public_key, xcvc) + } +} + +/// Trait for exchanging APDU data with cktap cards. +#[async_trait] +pub trait CkTransport: Sync + Send { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, CkTapError>; +} + +/// Helper function for serialize APDU commands, transmit them and deserialize responses. +pub(crate) async fn transmit( + transport: Arc, + command: &C, +) -> Result +where + C: CommandApdu + serde::Serialize + Debug + Send + Sync, + R: ResponseApdu + serde::de::DeserializeOwned + Debug + Send, +{ + let command_apdu = command.apdu_bytes(); + let rapdu = transport.transmit_apdu(command_apdu).await?; + let response = R::from_cbor(rapdu.to_vec())?; + Ok(response) +} + +pub async fn to_cktap(transport: Arc) -> Result { + // Get status from card + let cmd = AppletSelect::default(); + let status_response: StatusResponse = transmit(transport.clone(), &cmd).await?; + + // Return correct card variant using status + match (status_response.tapsigner, status_response.satschip) { + (Some(true), None) => { + let tap_signer = TapSigner::try_from_status(transport, status_response)?; + Ok(CkTapCard::TapSigner(tap_signer)) + } + (Some(true), Some(true)) => { + let sats_chip = SatsChip::try_from_status(transport, status_response)?; + Ok(CkTapCard::SatsChip(sats_chip)) + } + (None, None) => { + let sats_card = SatsCard::from_status(transport, status_response)?; + Ok(CkTapCard::SatsCard(sats_card)) + } + (_, _) => Err(StatusError::CkTap(CkTapError::UnknownCardType)), + } +} + +/// Command to read and authenticate the cktap card's public key. +/// SatsCard does not require a CVC but TapSigner and SatsChip do. +/// ref: [read](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#read) +#[async_trait] +pub trait Read: Authentication { + fn requires_auth(&self) -> bool; + + fn slot(&self) -> Option; + + async fn read(&mut self, cvc: Option) -> Result { + let card_nonce = *self.card_nonce(); + let app_nonce = rand_nonce(); + + // create the message digest + let mut message_bytes: Vec = Vec::new(); + message_bytes.extend("OPENDIME".as_bytes()); + message_bytes.extend(card_nonce); + message_bytes.extend(app_nonce); + if let Some(slot) = self.slot() { + message_bytes.push(slot); + } else { + message_bytes.push(0); + } + let hash = sha256::Hash::hash(message_bytes.as_slice()); + let message_digest = Message::from_digest(hash.to_byte_array()); + + // create the read command + let (cmd, session_key) = if self.requires_auth() { + let cvc = cvc.ok_or(CkTapError::Card(CardError::NeedsAuth))?; + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, ReadCommand::name()); + ( + ReadCommand::authenticated(app_nonce, epubkey, xcvc), + Some(SharedSecret::new(&self.pubkey().inner, &eprivkey)), + ) + } else { + (ReadCommand::unauthenticated(app_nonce), None) + }; + + // send the read command to the card + let read_response: ReadResponse = transmit(self.transport(), &cmd).await?; + + // convert the response to a PublicKey + let pubkey = read_response.pubkey(session_key)?; + self.secp().verify_ecdsa( + &message_digest, + &read_response.signature()?, // or add 'from' trait: Signature::from(response.sig: ) + &pubkey.inner, + )?; + + // update the card nonce + self.set_card_nonce(read_response.card_nonce); + + Ok(pubkey) + } +} + +#[async_trait] +pub trait Wait: Authentication { + async fn wait(&mut self, cvc: Option) -> Result, CkTapError> { + let epubkey_xcvc = cvc.map(|cvc| { + let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, WaitCommand::name()); + (epubkey, xcvc) + }); + + let (epubkey, xcvc) = epubkey_xcvc + .map(|(epubkey, xcvc)| (Some(epubkey), Some(xcvc))) + .unwrap_or((None, None)); + + let wait_command = WaitCommand::new(epubkey, xcvc); + + let wait_response: WaitResponse = transmit(self.transport(), &wait_command).await?; + // TODO throw error if success == false + if wait_response.auth_delay > 0 { + let auth_delay = Some(wait_response.auth_delay); + self.set_auth_delay(auth_delay); + Ok(auth_delay) + } else { + self.set_auth_delay(None); + Ok(None) + } + } +} + +#[async_trait] +pub trait Certificate: Read { + async fn check_certificate(&mut self) -> Result { + let app_nonce = rand_nonce(); + let card_nonce = *self.card_nonce(); + + let certs_cmd = CertsCommand::default(); + let certs_response: CertsResponse = transmit(self.transport(), &certs_cmd).await?; + + let check_cmd = CheckCommand::new(app_nonce); + let check_response: CheckResponse = transmit(self.transport(), &check_cmd).await?; + self.set_card_nonce(check_response.card_nonce); + + let slot_pubkey = self.slot_pubkey().await?; + + // create message digest with slot pubkey + let mut message_bytes: Vec = Vec::new(); + message_bytes.extend("OPENDIME".as_bytes()); + message_bytes.extend(card_nonce); + message_bytes.extend(app_nonce); + if let Some(pubkey) = slot_pubkey { + if self.ver() != "0.9.0" { + let slot_pubkey_bytes = pubkey.inner.serialize(); + message_bytes.extend(slot_pubkey_bytes); + } + } + let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice()); + let message = Message::from_digest(message_bytes_hash.to_byte_array()); + + // verify the signature with the message and pubkey + let signature = Signature::from_compact(check_response.auth_sig.as_slice()) + .expect("Failed to construct ECDSA signature from check response"); + self.secp() + .verify_ecdsa(&message, &signature, &self.pubkey().inner)?; + + let mut pubkey = *self.pubkey(); + for sig in &certs_response.cert_chain() { + // BIP-137: https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki + let subtract_by = match sig[0] { + 27..=30 => 27, // P2PKH uncompressed + 31..=34 => 31, // P2PKH compressed + 35..=38 => 35, // Segwit P2SH + 39..=42 => 39, // Segwit Bech32 + _ => panic!("Unrecognized BIP-137 address"), + }; + let rec_id = RecoveryId::from_i32((sig[0] as i32) - subtract_by)?; + let (_, sig) = sig.split_at(1); + let result = RecoverableSignature::from_compact(sig, rec_id); + let rec_sig = result?; + let pubkey_hash = sha256::Hash::hash(&pubkey.inner.serialize()); + let md = Message::from_digest(pubkey_hash.to_byte_array()); + let result = self.secp().recover_ecdsa(&md, &rec_sig); + pubkey = PublicKey::new(result?); + } + + FactoryRootKey::try_from(pubkey.inner) + } + + async fn slot_pubkey(&mut self) -> Result, ReadError>; +} + +#[cfg(feature = "emulator")] +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + use crate::emulator::CVC; + use crate::emulator::find_emulator; + use crate::emulator::test::{CardTypeOption, EcardSubprocess}; + use crate::rand_chaincode; + use crate::tap_signer::TapSignerShared; + + #[tokio::test] + async fn test_new_command() { + let chain_code = rand_chaincode(); + for card_type in CardTypeOption::values() { + let pipe_path = format!("/tmp/test-new-command-pipe{card_type}"); + let pipe_path = Path::new(&pipe_path); + let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); + let emulator = find_emulator(pipe_path).await.unwrap(); + match emulator { + CkTapCard::SatsCard(mut sc) => { + assert_eq!(card_type, CardTypeOption::SatsCard); + let current_slot = sc.slots.0; + let response = sc.unseal(current_slot, CVC).await; + assert!(response.is_ok()); + let response = sc.new_slot(current_slot + 1, Some(chain_code), CVC).await; + assert!(response.is_ok()); + assert_eq!(sc.slots.0, current_slot + 1); + // test with no new chain_code + let current_slot = sc.slots.0; + let response = sc.unseal(current_slot, CVC).await; + assert!(response.is_ok()); + let response = sc.new_slot(current_slot + 1, None, CVC).await; + assert!(response.is_ok()); + assert_eq!(sc.slots.0, current_slot + 1); + } + CkTapCard::TapSigner(mut ts) => { + assert_eq!(card_type, CardTypeOption::TapSigner); + let response = ts.init(chain_code, CVC).await; + assert!(response.is_ok()) + } + CkTapCard::SatsChip(mut sc) => { + assert_eq!(card_type, CardTypeOption::SatsChip); + let response = sc.init(chain_code, CVC).await; + assert!(response.is_ok()) + } + }; + drop(python); + } + } + + #[tokio::test] + async fn test_cert_command() { + for card_type in CardTypeOption::values() { + let emulator_root_pubkey = + "0312d005ca1501b1603c3b00412eefe27c6b20a74c29377263b357b3aff12de6fa".to_string(); + let pipe_path = format!("/tmp/test-cert-command-pipe{card_type}"); + let pipe_path = Path::new(&pipe_path); + let python = EcardSubprocess::new(pipe_path, &card_type).unwrap(); + let emulator = find_emulator(pipe_path).await.unwrap(); + match emulator { + CkTapCard::SatsCard(mut sc) => { + assert_eq!(card_type, CardTypeOption::SatsCard); + let response = sc.check_certificate().await; + assert!(response.is_err()); + matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); + } + CkTapCard::TapSigner(mut ts) => { + assert_eq!(card_type, CardTypeOption::TapSigner); + let response = ts.check_certificate().await; + assert!(response.is_err()); + matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); + } + CkTapCard::SatsChip(mut sc) => { + assert_eq!(card_type, CardTypeOption::SatsChip); + let response = sc.check_certificate().await; + assert!(response.is_err()); + matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); + } + }; + drop(python); + } + } +} diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 468ae59..e153101 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,21 +1,31 @@ -use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::{ - self, All, Message, PublicKey, Secp256k1, - ecdsa::Signature, - hashes::{Hash as _, sha256}, -}; -use log::error; -use std::future::Future; +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 use crate::apdu::{ - CommandApdu as _, DeriveCommand, DeriveResponse, Error, NewCommand, NewResponse, SignCommand, + CommandApdu as _, DeriveCommand, DeriveResponse, NewCommand, NewResponse, SignCommand, SignResponse, StatusCommand, StatusResponse, tap_signer::{BackupCommand, BackupResponse, ChangeCommand, ChangeResponse}, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; +use crate::error::{ChangeError, DeriveError, ReadError, SignPsbtError, StatusError}; +use crate::shared::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; +use crate::{BIP32_HARDENED_MASK, CkTapError}; +use async_trait::async_trait; +use bitcoin::PublicKey; +use bitcoin::bip32::ChainCode; +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::{self, All, Message, Secp256k1, ecdsa::Signature}; +use bitcoin_hashes::sha256; +use std::sync::Arc; + +const BIP84_PATH_LEN: usize = 5; + +// BIP84 derivation path structure, m / 84' / 0' / account' / change / address_index -pub struct TapSigner { - pub transport: T, +// Derivation sub-path indexes that must be hardened +const BIP84_HARDENED_SUBPATH: [usize; 3] = [0, 1, 2]; + +pub struct TapSigner { + pub transport: Arc, pub secp: Secp256k1, pub proto: usize, pub ver: String, @@ -28,58 +38,7 @@ pub struct TapSigner { pub auth_delay: Option, } -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum TapSignerError { - #[error(transparent)] - ApduError(#[from] Error), - - #[error(transparent)] - CvcChangeError(#[from] CvcChangeError), -} - -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum CvcChangeError { - #[error("new cvc is too short, must be at least 6 bytes, was only {0} bytes")] - TooShort(usize), - - #[error("new cvc is too long, must be at most 32 bytes, was {0} bytes")] - TooLong(usize), - - #[error("new cvc is the same as the old one")] - SameAsOld, -} - -#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] -pub enum PsbtSignError { - #[error("Missing UTXO")] - MissingUtxo(usize), - - #[error("Missing pubkey")] - MissingPubkey(usize), - - #[error("Signature error: {0}")] - SignatureError(String), - - #[error("Witness program error: {0}")] - WitnessProgramError(String), - - #[error("Sighash error: {0}")] - SighashError(String), - - #[error("Invalid script: index: {0}")] - InvalidScript(usize), - - #[error(transparent)] - TapSignerError(#[from] Error), - - #[error("pubkey mismatch: index: {0}")] - PubkeyMismatch(usize), - - #[error("Invalid path at index: {0}")] - InvalidPath(usize), -} - -impl Authentication for TapSigner { +impl Authentication for TapSigner { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -108,204 +67,199 @@ impl Authentication for TapSigner { self.auth_delay = auth_delay; } - fn transport(&self) -> &T { - &self.transport + fn transport(&self) -> Arc { + self.transport.clone() } } /// Function shared between TapSigner and SatsChip -pub trait TapSignerShared: Authentication -where - T: CkTransport, -{ +#[async_trait] +pub trait TapSignerShared: Authentication { /// Initialize the tap signer or sats chip, can only be done once - fn init( - &mut self, - chain_code: [u8; 32], - cvc: &str, - ) -> impl Future> { - async move { - let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, NewCommand::name()); - - let new_command = NewCommand::new(Some(0), Some(chain_code), epubkey, xcvc); - let new_response: NewResponse = self.transport().transmit(&new_command).await?; - - self.set_card_nonce(new_response.card_nonce); - Ok(new_response) - } + async fn init(&mut self, chain_code: ChainCode, cvc: &str) -> Result<(), CkTapError> { + let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, NewCommand::name()); + let new_command = NewCommand::new(Some(0), Some(chain_code), epubkey, xcvc); + let new_response: NewResponse = transmit(self.transport(), &new_command).await?; + self.set_card_nonce(new_response.card_nonce); + Ok(()) } /// Get the status of the tap signer or sats chip, including the current card nonce - fn status(&mut self) -> impl Future> { - async { - let cmd = StatusCommand::default(); - let status_response: StatusResponse = self.transport().transmit(&cmd).await?; - self.set_card_nonce(status_response.card_nonce); - Ok(status_response) - } + async fn status(&mut self) -> Result { + let cmd = StatusCommand::default(); + let status_response: StatusResponse = transmit(self.transport(), &cmd).await?; + self.set_card_nonce(status_response.card_nonce); + Ok(status_response) } /// Sign a message digest with the tap signer - fn sign( + async fn sign( &mut self, digest: [u8; 32], sub_path: Vec, cvc: &str, - ) -> impl Future> { - async move { - let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name()); + ) -> Result { + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name()); - // Use the same session key to encrypt the new CVC - let session_key = secp256k1::ecdh::SharedSecret::new(self.pubkey(), &eprivkey); + // Use the same session key to encrypt the new CVC + let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey); - // encrypt the new cvc by XORing with the session key - let xdigest_vec: Vec = session_key - .as_ref() - .iter() - .zip(digest) - .map(|(session_key_byte, digest_byte)| session_key_byte ^ digest_byte) - .collect(); + // encrypt the new cvc by XORing with the session key + let xdigest_vec: Vec = session_key + .as_ref() + .iter() + .zip(digest) + .map(|(session_key_byte, digest_byte)| session_key_byte ^ digest_byte) + .collect(); - let xdigest: [u8; 32] = xdigest_vec.try_into().expect("input is also 32 bytes"); + let xdigest: [u8; 32] = xdigest_vec.try_into().expect("input is also 32 bytes"); - let sign_command = - SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); + let sign_command = + SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); - let mut sign_response: Result = - self.transport().transmit(&sign_command).await; + let mut sign_response: Result = + transmit(self.transport(), &sign_command).await; - let mut unlucky_number_retries = 0; - while let Err(Error::CkTap(crate::apdu::CkTapError::UnluckyNumber)) = sign_response { - let sign_command = - SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); + let mut unlucky_number_retries = 0; + while let Err(CkTapError::Card(crate::CardError::UnluckyNumber)) = sign_response { + let sign_command = + SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); - sign_response = self.transport().transmit(&sign_command).await; - unlucky_number_retries += 1; + sign_response = transmit(self.transport(), &sign_command).await; + unlucky_number_retries += 1; - if unlucky_number_retries > 3 { - break; - } + if unlucky_number_retries > 3 { + break; } - - let sign_response = sign_response?; - self.set_card_nonce(sign_response.card_nonce); - Ok(sign_response) } + + let sign_response = sign_response?; + self.set_card_nonce(sign_response.card_nonce); + Ok(sign_response) } /// Sign a BIP84, currently only P2WPKH (BIP84) (Native SegWit) PSBT /// This function will return a signed but not finalized PSBT. You will need to finalize the - /// PSBT yourself before it can be broadcasted. - fn sign_psbt( + /// PSBT yourself before it can be broadcast. + async fn sign_psbt( &mut self, mut psbt: bitcoin::Psbt, cvc: &str, - ) -> impl Future> { + ) -> Result { use bitcoin::{ secp256k1::ecdsa, sighash::{EcdsaSighashType, SighashCache}, }; - type Error = PsbtSignError; + let unsigned_tx = psbt.unsigned_tx.clone(); + let mut sighash_cache = SighashCache::new(&unsigned_tx); - async { - let unsigned_tx = psbt.unsigned_tx.clone(); - let mut sighash_cache = SighashCache::new(&unsigned_tx); + for (input_index, input) in psbt.inputs.iter_mut().enumerate() { + // extract previous output data from the PSBT + let witness_utxo = input + .witness_utxo + .as_ref() + .ok_or(SignPsbtError::MissingUtxo(input_index))?; - for (input_index, input) in psbt.inputs.iter_mut().enumerate() { - // extract previous output data from the PSBT - let witness_utxo = input - .witness_utxo - .as_ref() - .ok_or(Error::MissingUtxo(input_index))?; + let amount = witness_utxo.value; - let amount = witness_utxo.value; + // extract the P2WPKH script from PSBT + let script_pubkey = &witness_utxo.script_pubkey; + if !script_pubkey.is_p2wpkh() { + return Err(SignPsbtError::InvalidScript(input_index)); + } - // extract the P2WPKH script from PSBT - let script_pubkey = &witness_utxo.script_pubkey; - if !script_pubkey.is_p2wpkh() { - return Err(Error::InvalidScript(input_index)); - } + // get the public key from the PSBT + let key_pairs = &input.bip32_derivation; + let (psbt_pubkey, (_fingerprint, path)) = key_pairs + .iter() + .next() + .ok_or(SignPsbtError::MissingPubkey(input_index))?; - // get the public key from the PSBT - let key_pairs = &input.bip32_derivation; - let (psbt_pubkey, (_fingerprint, path)) = key_pairs - .iter() - .next() - .ok_or(Error::MissingPubkey(input_index))?; + let path = path.to_u32_vec(); - // 1 << 31 - const HARDENED: u32 = 0x80000000; - let path = path.to_u32_vec(); + if path.len() != BIP84_PATH_LEN { + return Err(SignPsbtError::InvalidPath(input_index)); + } - if path.len() != 5 { - return Err(Error::InvalidPath(input_index)); - } + let sub_path = BIP84_HARDENED_SUBPATH.map(|i| path[i]); + if sub_path.iter().any(|p| *p > BIP32_HARDENED_MASK) { + return Err(SignPsbtError::InvalidPath(input_index)); + } - let sub_path = vec![path[3], path[4]]; - if sub_path.iter().any(|p| *p > HARDENED) { - return Err(Error::InvalidPath(input_index)); + // calculate sighash + let script = script_pubkey.as_script(); + let sighash = sighash_cache + .p2wpkh_signature_hash(input_index, script, amount, EcdsaSighashType::All) + .map_err(|e| SignPsbtError::SighashError(e.to_string()))?; + + // the digest is the sighash + let digest: &[u8; 32] = sighash.as_ref(); + + // send digest to TAPSIGNER for signing + let mut sign_response = self.sign(*digest, sub_path.to_vec(), cvc).await?; + let mut signature_raw = sign_response.sig; + + // verify that TAPSIGNER used the same public key as the PSBT + if sign_response.pubkey != psbt_pubkey.serialize() { + // try deriving the TAPSIGNER and try again + // take the hardened path and remove the hardened bit, because `sign` hardens it + let path: Vec = path + .into_iter() + .map(|p| p ^ BIP32_HARDENED_MASK) + .take(BIP84_HARDENED_SUBPATH.len()) + .collect(); + let derive_response = self.derive(path, cvc).await; + if derive_response.is_err() { + return Err(SignPsbtError::PubkeyMismatch(input_index)); } - // calculate sighash - let script = script_pubkey.as_script(); - let sighash = sighash_cache - .p2wpkh_signature_hash(input_index, script, amount, EcdsaSighashType::All) - .map_err(|e| Error::SighashError(e.to_string()))?; - - // the digest is the sighash - let digest: &[u8; 32] = sighash.as_ref(); - - // send digest to TAPSIGNER for signing - let mut sign_response = self.sign(*digest, sub_path.clone(), cvc).await?; - let mut signature_raw = sign_response.sig; + // update signature to the new one we just derived + sign_response = self.sign(*digest, sub_path.to_vec(), cvc).await?; + signature_raw = sign_response.sig; - // verify that TAPSIGNER used the same public key as the PSBT + // if still not matching, return error if sign_response.pubkey != psbt_pubkey.serialize() { - // try deriving the TAPSIGNER and try again - // take the hardened path and remove the the hardened bit, because `sign` hardens it - let path: Vec = path.into_iter().map(|p| p ^ (1 << 31)).take(3).collect(); - let derive_response = self.derive(&path, cvc).await; - if derive_response.is_err() { - return Err(Error::PubkeyMismatch(input_index)); - } - - // update signature to the new one we just derived - sign_response = self.sign(*digest, sub_path, cvc).await?; - signature_raw = sign_response.sig; - - // if still not matching, return error - if sign_response.pubkey != psbt_pubkey.serialize() { - return Err(Error::PubkeyMismatch(input_index)); - } + return Err(SignPsbtError::PubkeyMismatch(input_index)); } - - // update the PSBT input with the signature - let ecdsa_sig = ecdsa::Signature::from_compact(&signature_raw) - .map_err(|e| Error::SignatureError(e.to_string()))?; - - let final_sig = bitcoin::ecdsa::Signature::sighash_all(ecdsa_sig); - input.partial_sigs.insert((*psbt_pubkey).into(), final_sig); } - Ok(psbt) + // update the PSBT input with the signature + let ecdsa_sig = ecdsa::Signature::from_compact(&signature_raw) + .map_err(|e| SignPsbtError::SignatureError(e.to_string()))?; + + let final_sig = bitcoin::ecdsa::Signature::sighash_all(ecdsa_sig); + input.partial_sigs.insert((*psbt_pubkey).into(), final_sig); } + + Ok(psbt) } - /// Derive a public key at the given hardened path - fn derive( - &mut self, - path: &[u32], - cvc: &str, - ) -> impl Future> { - async { - // set most significant bit to 1 to represent hardened path steps - let path = path.iter().map(|p| p ^ (1 << 31)).collect(); - let app_nonce = crate::rand_nonce(); - let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, DeriveCommand::name()); - let cmd = DeriveCommand::for_tapsigner(app_nonce, path, epubkey, xcvc); - let derive_response: DeriveResponse = self.transport().transmit(&cmd).await?; + /// Derive a public key at the given hardened path. + /// + /// The derive command on the TAPSIGNER is used to perform hardened BIP-32 key derivation. + /// Wallets are expected to use it for deriving the BIP-44/48/84 prefix of the path; the value + /// is captured and stored long term. This is effectively calculating the XPUB to be used on the + /// mobile wallet. + /// + /// Ref: + async fn derive(&mut self, path: Vec, cvc: &str) -> Result { + // set most significant bit to 1 to represent hardened path steps + let path = path.iter().map(|p| p ^ (1 << 31)).collect::>(); + let app_nonce = crate::rand_nonce(); + let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, DeriveCommand::name()); + let cmd = DeriveCommand::for_tapsigner(app_nonce, path, epubkey, xcvc); + let derive_response: DeriveResponse = transmit(self.transport(), &cmd).await?; + self.set_card_nonce(derive_response.card_nonce); + + let master_pubkey = PublicKey::from_slice(&derive_response.master_pubkey)?; + let pubkey = match &derive_response.pubkey { + Some(pubkey) => PublicKey::from_slice(pubkey)?, + None => master_pubkey, + }; + // TODO FIX currently signature validation only works if no derivation path is used + if pubkey == master_pubkey { let card_nonce = self.card_nonce(); let sig = &derive_response.sig; @@ -318,80 +272,61 @@ where let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice()); let message = Message::from_digest(message_bytes_hash.to_byte_array()); - let signature = Signature::from_compact(sig).map_err(Error::from)?; - let pubkey = match &derive_response.pubkey { - Some(pubkey) => PublicKey::from_slice(pubkey).map_err(Error::from)?, - None => { - PublicKey::from_slice(&derive_response.master_pubkey).map_err(Error::from)? - } - }; - - // TODO: actually return as error when we can figure out why its not working on the card - if self - .secp() - .verify_ecdsa(&message, &signature, &pubkey) - .map_err(Error::from) - .is_err() - { - error!("verify derive command ecdsa signature failed"); - }; - - self.set_card_nonce(derive_response.card_nonce); - Ok(derive_response) + let signature = Signature::from_compact(sig)?; + + self.secp() + .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; } + Ok(pubkey) } /// Change the CVC used for card authentication to a new user provided one - fn change( - &mut self, - new_cvc: &str, - cvc: &str, - ) -> impl Future> { - async move { - if new_cvc.len() < 6 { - return Err(CvcChangeError::TooShort(new_cvc.len()).into()); - } + async fn change(&mut self, new_cvc: &str, cvc: &str) -> Result<(), ChangeError> { + if new_cvc.len() < 6 { + return Err(ChangeError::TooShort(new_cvc.len())); + } - if new_cvc.len() > 32 { - return Err(CvcChangeError::TooLong(new_cvc.len()).into()); - } + if new_cvc.len() > 32 { + return Err(ChangeError::TooLong(new_cvc.len())); + } - if new_cvc == cvc { - return Err(CvcChangeError::SameAsOld.into()); - } + if new_cvc == cvc { + return Err(ChangeError::SameAsOld); + } - // Create session key and encrypt current CVC - let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, ChangeCommand::name()); + // Create session key and encrypt current CVC + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, ChangeCommand::name()); - // Use the same session key to encrypt the new CVC - let session_key = secp256k1::ecdh::SharedSecret::new(self.pubkey(), &eprivkey); + // Use the same session key to encrypt the new CVC + let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey); - // encrypt the new cvc by XORing with the session key - let xnew_cvc: Vec = session_key - .as_ref() - .iter() - .zip(new_cvc.as_bytes().iter()) - .map(|(session_key_byte, cvc_byte)| session_key_byte ^ cvc_byte) - .collect(); + // encrypt the new cvc by XORing with the session key + let xnew_cvc: Vec = session_key + .as_ref() + .iter() + .zip(new_cvc.as_bytes().iter()) + .map(|(session_key_byte, cvc_byte)| session_key_byte ^ cvc_byte) + .collect(); - let change_command = ChangeCommand::new(xnew_cvc, epubkey, xcvc); - let change_response: ChangeResponse = - self.transport().transmit(&change_command).await?; + let change_command = ChangeCommand::new(xnew_cvc, epubkey, xcvc); + let change_response: ChangeResponse = transmit(self.transport(), &change_command).await?; - self.set_card_nonce(change_response.card_nonce); - Ok(change_response) - } + self.set_card_nonce(change_response.card_nonce); + Ok(()) } } -impl TapSignerShared for TapSigner {} +impl TapSignerShared for TapSigner {} -impl TapSigner { - pub fn try_from_status(transport: T, status_response: StatusResponse) -> Result { +impl TapSigner { + pub fn try_from_status( + transport: Arc, + status_response: StatusResponse, + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(|e| Error::CiborValue(e.to_string()))?; + let pubkey = PublicKey::from_slice(pubkey)?; - Ok(Self { + Ok(TapSigner { transport, secp: Secp256k1::new(), proto: status_response.proto, @@ -406,20 +341,22 @@ impl TapSigner { } /// Backup the current card, the backup is encrypted with the "Backup Password" on the back of the card - pub async fn backup(&mut self, cvc: &str) -> Result { + pub async fn backup(&mut self, cvc: &str) -> Result, ChangeError> { let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, "backup"); let backup_command = BackupCommand::new(epubkey, xcvc); - let backup_response: BackupResponse = self.transport.transmit(&backup_command).await?; + let backup_response: BackupResponse = transmit(self.transport(), &backup_command).await?; self.card_nonce = backup_response.card_nonce; - Ok(backup_response) + Ok(backup_response.data) } } -impl Wait for TapSigner {} +#[async_trait] +impl Wait for TapSigner {} -impl Read for TapSigner { +#[async_trait] +impl Read for TapSigner { fn requires_auth(&self) -> bool { true } @@ -429,13 +366,14 @@ impl Read for TapSigner { } } -impl Certificate for TapSigner { - async fn slot_pubkey(&mut self) -> Result, Error> { +#[async_trait] +impl Certificate for TapSigner { + async fn slot_pubkey(&mut self) -> Result, ReadError> { Ok(None) } } -impl core::fmt::Debug for TapSigner { +impl core::fmt::Debug for TapSigner { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { f.debug_struct("TapSigner") .field("proto", &self.proto)