From c3ad948f0f053d340f938d146cc8e170d6398bb7 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 13 Aug 2025 21:13:48 -0500 Subject: [PATCH 01/15] docs: fix duplicate uniffi-bindgen warning --- cktap-ffi/Cargo.toml | 9 +++++++-- .../bin/uniffi-bindgen.rs => cktap-uniffi-bindgen.rs} | 0 cktap-swift/build-xcframework.sh | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) rename cktap-ffi/{src/bin/uniffi-bindgen.rs => cktap-uniffi-bindgen.rs} (100%) diff --git a/cktap-ffi/Cargo.toml b/cktap-ffi/Cargo.toml index e96aabb..51f3580 100644 --- a/cktap-ffi/Cargo.toml +++ b/cktap-ffi/Cargo.toml @@ -8,11 +8,16 @@ 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"] } + +uniffi = { version = "=0.29.4", features = ["cli"] } thiserror = "1.0" [features] diff --git a/cktap-ffi/src/bin/uniffi-bindgen.rs b/cktap-ffi/cktap-uniffi-bindgen.rs similarity index 100% rename from cktap-ffi/src/bin/uniffi-bindgen.rs rename to cktap-ffi/cktap-uniffi-bindgen.rs diff --git a/cktap-swift/build-xcframework.sh b/cktap-swift/build-xcframework.sh index 6ff2543..c895d55 100755 --- a/cktap-swift/build-xcframework.sh +++ b/cktap-swift/build-xcframework.sh @@ -49,7 +49,7 @@ 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 \ +cargo run --package ${FFI_PKG_NAME} --bin cktap-uniffi-bindgen generate \ --library target/aarch64-apple-ios/${RELDIR}/${DYLIB_FILENAME} \ --language swift \ --out-dir cktap-swift/Sources/CKTap \ From 16d730404030a2f799253381f860cd19663d9d29 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 13 Aug 2025 21:18:06 -0500 Subject: [PATCH 02/15] deps: fix deprecated bitcoin::hashes and secp256k1::hashes warning use recommended bitcoin_hashes --- cli/src/main.rs | 5 ++--- lib/Cargo.toml | 1 + lib/src/apdu.rs | 4 ++-- lib/src/apdu/tap_signer.rs | 3 ++- lib/src/commands.rs | 15 ++++++++++----- lib/src/factory_root_key.rs | 2 +- lib/src/lib.rs | 1 + lib/src/sats_card.rs | 4 ++-- lib/src/tap_signer.rs | 7 ++----- 9 files changed, 23 insertions(+), 19 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 3f5942b..a13364c 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,7 +6,6 @@ use rust_cktap::commands::{Authentication, CkTransport, Read, Wait}; use rust_cktap::emulator; #[cfg(not(feature = "emulator"))] use rust_cktap::pcsc; -use rust_cktap::secp256k1::hashes::Hash as _; use rust_cktap::secp256k1::rand; use rust_cktap::tap_signer::TapSignerShared; use rust_cktap::{CkTapCard, apdu::Error, commands::Certificate, rand_chaincode}; @@ -182,7 +181,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()) + rust_cktap::bitcoin_hashes::sha256::Hash::hash(to_sign.as_bytes()) .to_byte_array(); let response = &ts.sign(digest, vec![], &cvc()).await; @@ -214,7 +213,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()) + rust_cktap::bitcoin_hashes::sha256::Hash::hash(to_sign.as_bytes()) .to_byte_array(); let response = &sc.sign(digest, vec![], &cvc()).await; diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 197e4f6..4f9e50d 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -24,6 +24,7 @@ thiserror = "2.0" # bitcoin bitcoin = { version = "0.32", features = ["rand-std"] } +bitcoin_hashes = "0.16.0" # logging log = "0.4" diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index 61f08d6..d0c90f6 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -4,8 +4,8 @@ pub mod tap_signer; use bitcoin::secp256k1::{ self, PublicKey, SecretKey, XOnlyPublicKey, ecdh::SharedSecret, ecdsa::Signature, - hashes::hex::DisplayHex, }; +use bitcoin_hashes::hex::DisplayHex; use ciborium::de::from_reader; use ciborium::ser::into_writer; use ciborium::value::Value; @@ -514,7 +514,7 @@ 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 diff --git a/lib/src/apdu/tap_signer.rs b/lib/src/apdu/tap_signer.rs index 7b84f82..0e5e808 100644 --- a/lib/src/apdu/tap_signer.rs +++ b/lib/src/apdu/tap_signer.rs @@ -2,7 +2,8 @@ use core::fmt::{self, Formatter}; use super::{CommandApdu, ResponseApdu}; -use bitcoin::secp256k1::{PublicKey, hashes::hex::DisplayHex as _}; +use bitcoin::secp256k1::PublicKey; +use bitcoin_hashes::hex::DisplayHex as _; use serde::{Deserialize, Serialize}; // MARK: - XpubCommand diff --git a/lib/src/commands.rs b/lib/src/commands.rs index b50af24..230136f 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -5,8 +5,8 @@ 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 bitcoin_hashes::sha256; use std::convert::TryFrom; @@ -14,7 +14,7 @@ use crate::sats_chip::SatsChip; use std::fmt::Debug; use std::future::Future; -// Helper functions for authenticated commands. +/// Helper functions for authenticated commands. pub trait Authentication { fn secp(&self) -> &Secp256k1; fn ver(&self) -> &str; @@ -26,6 +26,8 @@ pub trait Authentication { fn transport(&self) -> &T; + /// 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) -> (SecretKey, PublicKey, Vec) { let secp = Self::secp(self); let pubkey = Self::pubkey(self); @@ -52,6 +54,7 @@ pub trait Authentication { } } +/// Helper functions for communicating with cktap cards. pub trait CkTransport: Sized { fn transmit(&self, command: &C) -> impl Future> where @@ -93,7 +96,9 @@ pub trait CkTransport: Sized { } } -// card traits +/// 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) pub trait Read: Authentication where T: CkTransport, @@ -107,8 +112,8 @@ where 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()); + let cvc = cvc.ok_or(Error::CkTap(CkTapError::NeedsAuth))?; + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, ReadCommand::name()); ( ReadCommand::authenticated(app_nonce, epubkey, xcvc), Some(SharedSecret::new(self.pubkey(), &eprivkey)), diff --git a/lib/src/factory_root_key.rs b/lib/src/factory_root_key.rs index 287c835..a3698fd 100644 --- a/lib/src/factory_root_key.rs +++ b/lib/src/factory_root_key.rs @@ -1,6 +1,6 @@ use crate::apdu::Error; use bitcoin::secp256k1::PublicKey; -use bitcoin::secp256k1::hashes::hex::DisplayHex; +use bitcoin_hashes::hex::DisplayHex; use std::convert::TryFrom; use std::fmt; use std::fmt::Debug; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 29e7f79..18eb132 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -13,6 +13,7 @@ pub use bitcoin::{ key::UntweakedPublicKey, secp256k1::{self, rand}, }; +pub use bitcoin_hashes; #[cfg(feature = "emulator")] pub mod emulator; diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index fd4ed00..c2ebe2b 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,14 +1,14 @@ -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}; +use bitcoin_hashes::sha256; use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, NewCommand, NewResponse, StatusResponse, UnsealCommand, UnsealResponse, }; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; -use crate::secp256k1::hashes::hex::DisplayHex; +use bitcoin_hashes::hex::DisplayHex; pub struct SatsCard { pub transport: T, diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 468ae59..76923dc 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,9 +1,6 @@ use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::{ - self, All, Message, PublicKey, Secp256k1, - ecdsa::Signature, - hashes::{Hash as _, sha256}, -}; +use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, ecdsa::Signature}; +use bitcoin_hashes::sha256; use log::error; use std::future::Future; From 5a921da9aa8f749c67de42933e80ecbeab73f263 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 17 Aug 2025 21:28:08 -0500 Subject: [PATCH 03/15] refactor!: cleanup API and FFI bindings Reduce returned command data to just what is needed instead of entire response message. --- Justfile | 4 + cktap-ffi/Cargo.toml | 4 +- cktap-ffi/src/lib.rs | 170 ++++--- cktap-swift/Tests/CKTapTests/CKTapTests.swift | 31 +- .../CKTapTests/SmartCardTest.entitlements | 10 + cktap-swift/build-xcframework.sh | 28 +- cktap-swift/justfile | 5 +- cli/src/main.rs | 32 +- lib/Cargo.toml | 1 + lib/examples/pcsc.rs | 6 +- lib/src/apdu.rs | 59 ++- lib/src/apdu/tap_signer.rs | 3 +- lib/src/commands.rs | 313 ++++++------- lib/src/emulator.rs | 15 +- lib/src/lib.rs | 48 +- lib/src/pcsc.rs | 17 +- lib/src/sats_card.rs | 116 +++-- lib/src/sats_chip.rs | 35 +- lib/src/tap_signer.rs | 434 +++++++++--------- 19 files changed, 729 insertions(+), 602 deletions(-) create mode 100644 cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements diff --git a/Justfile b/Justfile index 72460ae..b11a64c 100644 --- a/Justfile +++ b/Justfile @@ -25,3 +25,7 @@ test: fmt # 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}} \ No newline at end of file diff --git a/cktap-ffi/Cargo.toml b/cktap-ffi/Cargo.toml index 51f3580..52f4fd0 100644 --- a/cktap-ffi/Cargo.toml +++ b/cktap-ffi/Cargo.toml @@ -17,8 +17,10 @@ path = "cktap-uniffi-bindgen.rs" [dependencies] rust-cktap = { path = "../lib" } -uniffi = { version = "=0.29.4", 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/src/lib.rs b/cktap-ffi/src/lib.rs index 3c18eb3..c959720 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -1,8 +1,9 @@ uniffi::setup_scaffolding!(); -use rust_cktap::apdu::{AppletSelect, CommandApdu, ResponseApdu, StatusResponse}; -use rust_cktap::{Error as CoreError, rand_nonce as core_rand_nonce}; +use futures::lock::Mutex; +use rust_cktap::commands::{Authentication, Read}; use std::fmt::Debug; +use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] pub enum Error { @@ -12,81 +13,132 @@ pub enum Error { Transport { msg: String }, } -impl From for Error { - fn from(e: CoreError) -> Self { +impl From for Error { + fn from(e: rust_cktap::Error) -> Self { Error::Core { msg: 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, +#[uniffi::export(callback_interface)] +#[async_trait::async_trait] +pub trait CkTransport: Send + Sync { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; } -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), - } +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::Error> { + self.0 + .transmit_apdu(command_apdu) + .await + .map_err(|e| rust_cktap::Error::Transport(e.to_string())) } } -#[uniffi::export(callback_interface)] -pub trait CkTransportFfi: Send + Sync + Debug + 'static { - fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; -} +// TODO de-duplicate code between SatsCard, TapSigner and SatsChip + +#[derive(uniffi::Object)] +pub struct SatsCard(Mutex); #[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 SatsCard { + pub async fn ver(&self) -> String { + self.0.lock().await.ver().to_string() + } + + pub async fn address(&self) -> Result { + self.0 + .lock() + .await + .address() + .await + .map_err(|e| Error::Core { msg: e.to_string() }) + } + + pub async fn read(&self) -> Result, Error> { + self.0 + .lock() + .await + .read(None) + .await + .map(|pk| pk.serialize().to_vec()) + .map_err(|e| Error::Core { msg: e.to_string() }) + } + // TODO implement the rest of the commands } -#[derive(uniffi::Record)] -pub struct TestRecord { - pub message: String, - pub count: u32, +#[derive(uniffi::Object)] +pub struct TapSigner(Mutex); + +#[uniffi::export] +impl TapSigner { + pub async fn ver(&self) -> String { + self.0.lock().await.ver().to_string() + } + + pub async fn read(&self) -> Result, Error> { + self.0 + .lock() + .await + .read(None) + .await + .map(|pk| pk.serialize().to_vec()) + .map_err(|e| Error::Core { msg: e.to_string() }) + } + // TODO implement the rest of the commands } -// this is actually a class per Object not Record #[derive(uniffi::Object)] -pub struct TestStruct { - pub value: u32, +pub struct SatsChip(Mutex); + +#[uniffi::export] +impl SatsChip { + pub async fn ver(&self) -> String { + self.0.lock().await.ver().to_string() + } + + pub async fn read(&self) -> Result, Error> { + self.0 + .lock() + .await + .read(None) + .await + .map(|pk| pk.serialize().to_vec()) + .map_err(|e| Error::Core { msg: e.to_string() }) + } + // TODO implement the rest of the commands +} + +#[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::commands::to_cktap(Arc::new(wrapper)) + .await + .map_err(Into::::into)?; + + 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))))) + } + } } #[uniffi::export] pub fn rand_nonce() -> Vec { - core_rand_nonce().to_vec() + rust_cktap::rand_nonce().to_vec() } diff --git a/cktap-swift/Tests/CKTapTests/CKTapTests.swift b/cktap-swift/Tests/CKTapTests/CKTapTests.swift index bffb6d1..1733bc0 100644 --- a/cktap-swift/Tests/CKTapTests/CKTapTests.swift +++ b/cktap-swift/Tests/CKTapTests/CKTapTests.swift @@ -1,10 +1,39 @@ import XCTest import CKTap +import CryptoTokenKit final class CKTapTests: XCTestCase { func testHelloRandom() throws { - print("Hello!") let nonce = randNonce() print("Random: \(nonce)") } + + func testAddress() throws { + print("Test getting an address") + let manager = TKSmartCardSlotManager() + let slots = manager.slotNames + + if slots.isEmpty { + print("No smart card slots available.") + return + } else { + if let firstSlotName = slots.first { + print("Connecting to slot: \(firstSlotName)") + manager.getSlot(withName:firstSlotName) { slot in + guard let smartCardSlot = slot?.makeSmartCard() else { + print("Failed to create smart card from slot.") + return + } + + smartCardSlot.beginSession(reply:) { success, error in + if success { + print("Session started successfully.") + } else { + print("Failed to start session: \(String(describing: error))") + } + } + } + } + } + } } diff --git a/cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements b/cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements new file mode 100644 index 0000000..b6329d8 --- /dev/null +++ b/cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.smartcard + + + \ No newline at end of file diff --git a/cktap-swift/build-xcframework.sh b/cktap-swift/build-xcframework.sh index c895d55..ab1259d 100755 --- a/cktap-swift/build-xcframework.sh +++ b/cktap-swift/build-xcframework.sh @@ -17,7 +17,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 +30,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 @@ -50,22 +50,22 @@ cargo build --package ${FFI_PKG_NAME} --profile ${RELDIR} --target aarch64-apple # Then run uniffi-bindgen cargo run --package ${FFI_PKG_NAME} --bin cktap-uniffi-bindgen generate \ - --library target/aarch64-apple-ios/${RELDIR}/${DYLIB_FILENAME} \ + --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..9095c97 100644 --- a/cktap-swift/justfile +++ b/cktap-swift/justfile @@ -5,7 +5,10 @@ build: bash ./build-xcframework.sh clean: - rm -rf ../rust-cktap-ffi/target/ + cargo clean + rm -rf Sources + rm -rf .build + rm -rf *.xcframework test: swift test diff --git a/cli/src/main.rs b/cli/src/main.rs index a13364c..bc99715 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,7 +1,7 @@ /// CLI for rust-cktap use clap::{Parser, Subcommand}; use rpassword::read_password; -use rust_cktap::commands::{Authentication, CkTransport, Read, Wait}; +use rust_cktap::commands::{Authentication, Read, Wait}; #[cfg(feature = "emulator")] use rust_cktap::emulator; #[cfg(not(feature = "emulator"))] @@ -27,7 +27,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. @@ -57,7 +57,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) @@ -93,7 +93,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) @@ -130,7 +130,7 @@ async fn main() -> Result<(), Error> { 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,13 +139,13 @@ async fn main() -> Result<(), Error> { 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 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.display_secret()) } SatsCardCommand::Derive => { dbg!(&sc.derive().await); @@ -156,7 +156,7 @@ async fn main() -> Result<(), Error> { CkTapCard::TapSigner(ts) => { let cli = TapSignerCli::parse(); match cli.command { - TapSignerCommand::Debug => { + TapSignerCommand::Status => { dbg!(&ts); } TapSignerCommand::Certs => check_cert(ts).await, @@ -193,7 +193,7 @@ 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, @@ -229,9 +229,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!( @@ -243,9 +243,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}"), @@ -263,9 +263,9 @@ 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() { diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 4f9e50d..39748d1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -17,6 +17,7 @@ serde = "1" serde_bytes = "0.11" # async +async-trait = "0.1.81" tokio = { version = "1.44", features = ["macros"] } # error handling diff --git a/lib/examples/pcsc.rs b/lib/examples/pcsc.rs index 0be40e5..5e304fd 100644 --- a/lib/examples/pcsc.rs +++ b/lib/examples/pcsc.rs @@ -38,8 +38,7 @@ async fn main() -> Result<(), Error> { // 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); + ts.init(chain_code, &cvc).await.unwrap(); } // let read_result = ts.read(Some(cvc.clone())).await?; @@ -68,8 +67,7 @@ async fn main() -> Result<(), Error> { // 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); + chip.init(chain_code, &get_cvc()).await.unwrap(); } // let read_result = chip.read(Some(cvc.clone())).await?; diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index d0c90f6..407080f 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -13,9 +13,9 @@ use serde; use serde::{Deserialize, Serialize}; 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]; +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]; // Errors #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] @@ -30,16 +30,13 @@ pub enum Error { IncorrectSignature(String), #[error("Root cert is not from Coinkite. Card is counterfeit: {0}")] InvalidRootCert(String), + #[error("Card chain code doesn't match user provided chain code")] + InvalidChaincode, #[error("UnknownCardType: {0}")] UnknownCardType(String), - #[cfg(feature = "pcsc")] - #[error("PcSc: {0}")] - PcSc(String), - - #[cfg(feature = "emulator")] - #[error("Emulator: {0}")] - Emulator(String), + #[error("Transport: {0}")] + Transport(String), } #[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] @@ -127,7 +124,7 @@ impl From for Error { #[cfg(feature = "pcsc")] impl From for Error { fn from(e: pcsc::Error) -> Self { - Error::PcSc(e.to_string()) + Error::Transport(e.to_string()) } } @@ -174,7 +171,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 +185,7 @@ impl CommandApdu for AppletSelect { } } -/// Status Command +/// Status Command. #[derive(Serialize, Clone, Debug, PartialEq, Eq)] pub struct StatusCommand { /// 'status' command @@ -207,29 +204,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. @@ -626,10 +645,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 diff --git a/lib/src/apdu/tap_signer.rs b/lib/src/apdu/tap_signer.rs index 0e5e808..a15117d 100644 --- a/lib/src/apdu/tap_signer.rs +++ b/lib/src/apdu/tap_signer.rs @@ -7,7 +7,8 @@ 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" diff --git a/lib/src/commands.rs b/lib/src/commands.rs index 230136f..c3c4494 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -5,17 +5,18 @@ use crate::{apdu::*, rand_nonce}; use bitcoin::key::rand; use bitcoin::secp256k1::ecdh::SharedSecret; use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; -use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, SecretKey}; +use bitcoin::secp256k1::{All, Message, PublicKey, Secp256k1, SecretKey}; use bitcoin_hashes::sha256; use std::convert::TryFrom; use crate::sats_chip::SatsChip; +use async_trait::async_trait; use std::fmt::Debug; -use std::future::Future; +use std::sync::Arc; /// Helper functions for authenticated commands. -pub trait Authentication { +pub trait Authentication { fn secp(&self) -> &Secp256k1; fn ver(&self) -> &str; fn pubkey(&self) -> &PublicKey; @@ -23,10 +24,9 @@ pub trait Authentication { 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; - fn transport(&self) -> &T; - - /// Calculate ephemeral key pair and XOR'd CVC + /// 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) -> (SecretKey, PublicKey, Vec) { let secp = Self::secp(self); @@ -54,214 +54,189 @@ pub trait Authentication { } } -/// Helper functions for communicating with cktap cards. -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) +/// Trait for exchanging APDU data with cktap cards. +#[async_trait] +pub trait CkTransport: Sync + Send { + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; +} + +/// 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)) } - } - 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())), - } + (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(Error::UnknownCardType("Card not recognized.".to_string())), } } /// 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) -pub trait Read: Authentication -where - T: CkTransport, -{ +#[async_trait] +pub trait Read: Authentication { 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 cvc = cvc.ok_or(Error::CkTap(CkTapError::NeedsAuth))?; - let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, 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); + fn slot(&self) -> Option; - Ok(read_response) - } - } + async fn read(&mut self, cvc: Option) -> Result { + let card_nonce = *self.card_nonce(); + let app_nonce = rand_nonce(); - fn message_digest(&self, card_nonce: [u8; 16], app_nonce: Vec) -> Message { + // 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()); - Message::from_digest(hash.to_byte_array()) + 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(Error::CkTap(CkTapError::NeedsAuth))?; + let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, ReadCommand::name()); + ( + ReadCommand::authenticated(app_nonce, epubkey, xcvc), + Some(SharedSecret::new(self.pubkey(), &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, + )?; + + // update the card nonce + self.set_card_nonce(read_response.card_nonce); + + Ok(pubkey) } } -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) +#[async_trait] +pub trait Wait: Authentication { + async fn wait(&mut self, cvc: Option) -> Result, Error> { + 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 = 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) } } } -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 { +#[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.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?; - } + let message = Message::from_digest(message_bytes_hash.to_byte_array()); - 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()) + // 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()) + .verify_ecdsa(&message, &signature, self.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 slot_pubkey(&mut self) -> impl Future, Error>>; + async fn slot_pubkey(&mut self) -> Result, Error>; } #[cfg(feature = "emulator")] diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 2a40417..72e2795 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -1,20 +1,22 @@ use crate::CkTapCard; use crate::apdu::{AppletSelect, CommandApdu, Error, StatusCommand}; -use crate::commands::CkTransport; +use crate::commands::{CkTransport, to_cktap}; +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(Error::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,6 +24,7 @@ pub struct CardEmulator { stream: UnixStream, } +#[async_trait] impl CkTransport for CardEmulator { async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error> { // convert select_apdu into StatusCommand apdu bytes @@ -37,12 +40,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| Error::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| Error::Transport(e.to_string()))?; Ok(buffer.to_vec()) } } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 18eb132..6a25e83 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,7 +1,7 @@ extern crate core; use bitcoin::key::rand::Rng as _; -use commands::CkTransport; +pub use commands::CkTransport; pub mod apdu; pub mod commands; @@ -20,34 +20,35 @@ 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), +pub enum CkTapCard { + SatsCard(SatsCard), + TapSigner(TapSigner), + SatsChip(SatsChip), } // re-export -use crate::sats_chip::SatsChip; pub use apdu::Error; -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:?})") } } } @@ -67,20 +68,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..d70bdaf 100644 --- a/lib/src/pcsc.rs +++ b/lib/src/pcsc.rs @@ -1,10 +1,13 @@ extern crate core; use crate::Error; +use crate::commands::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 +20,19 @@ 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(Error::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, Error> { 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 c2ebe2b..921aa52 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,17 +1,20 @@ +use async_trait::async_trait; use bitcoin::key::CompressedPublicKey as BitcoinPublicKey; -use bitcoin::secp256k1::{All, Message, PublicKey, Secp256k1, ecdsa::Signature}; +use bitcoin::secp256k1::{All, Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature}; use bitcoin::{Address, Network}; use bitcoin_hashes::sha256; +use std::sync::Arc; use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, NewCommand, NewResponse, StatusResponse, UnsealCommand, UnsealResponse, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; +use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; +use crate::secp256k1; use bitcoin_hashes::hex::DisplayHex; -pub struct SatsCard { - pub transport: T, +pub struct SatsCard { + pub transport: Arc, pub secp: Secp256k1, pub proto: usize, pub ver: String, @@ -23,7 +26,7 @@ pub struct SatsCard { pub auth_delay: Option, } -impl Authentication for SatsCard { +impl Authentication for SatsCard { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -52,13 +55,16 @@ 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()))?; @@ -85,22 +91,31 @@ impl SatsCard { slot: u8, chain_code: Option<[u8; 32]>, 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 { + /// 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: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive + pub async fn derive(&mut self) -> Result<[u8; 32], Error> { let nonce = crate::rand_nonce(); let card_nonce = *self.card_nonce(); let cmd = DeriveCommand::for_satscard(nonce); - let resp: DeriveResponse = self.transport().transmit(&cmd).await?; + let resp: DeriveResponse = transmit(self.transport(), &cmd).await?; self.set_card_nonce(resp.card_nonce); // Verify signature @@ -115,22 +130,52 @@ impl SatsCard { let signature = Signature::from_compact(&resp.sig)?; - let pubkey = PublicKey::from_slice(&resp.master_pubkey)?; - self.secp().verify_ecdsa(&message, &signature, &pubkey)?; + let master_pubkey = PublicKey::from_slice(&resp.master_pubkey)?; + self.secp() + .verify_ecdsa(&message, &signature, &master_pubkey)?; - Ok(resp) + // return card chain code so user can verify it matches user provided chain code + let card_chaincode = &resp.chain_code; + + // TODO verify a user's chain code (entropy) was used in picking the private key + // 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. + + 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<(SecretKey, PublicKey), Error> { + 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) - } - - pub async fn dump(&self, slot: usize, cvc: Option) -> Result { + // 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(), &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 = SecretKey::from_slice(&privkey)?; + 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) @@ -141,21 +186,27 @@ impl SatsCard { .unwrap_or((None, None)); let dump_command = DumpCommand::new(slot, epubkey, xcvc); - self.transport.transmit(&dump_command).await + let dump_response: DumpResponse = transmit(self.transport(), &dump_command).await?; + // TODO use chaincode and master public key to verify pubkey + // TODO only return the verified slot keys + // TODO return error if `tampered` is true + Ok(dump_response) } 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 slot_pubkey = self.read(None).await?; + let pk = BitcoinPublicKey::from_slice(&slot_pubkey.serialize())?; let address = Address::p2wpkh(&pk, network); Ok(address.to_string()) } } -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 +215,15 @@ impl Read for SatsCard { } } -impl Certificate for SatsCard { +#[async_trait] +impl Certificate for SatsCard { async fn slot_pubkey(&mut self) -> Result, Error> { - let pubkey = self.read(None).await?.pubkey(None)?; + 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) diff --git a/lib/src/sats_chip.rs b/lib/src/sats_chip.rs index 8e9dfb2..6f86e01 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -1,4 +1,6 @@ +use async_trait::async_trait; use bitcoin::secp256k1::{All, PublicKey, Secp256k1}; +use std::sync::Arc; use crate::apdu::{Error, StatusResponse}; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; @@ -8,8 +10,8 @@ use crate::tap_signer::TapSignerShared; /// 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 +24,7 @@ pub struct SatsChip { pub auth_delay: Option, } -impl Authentication for SatsChip { +impl Authentication for SatsChip { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -51,19 +53,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()))?; - Ok(Self { + Ok(SatsChip { transport, secp: Secp256k1::new(), proto: status_response.proto, @@ -78,9 +84,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 +98,14 @@ impl Read for SatsChip { } } -impl Certificate for SatsChip { +#[async_trait] +impl Certificate for SatsChip { async fn slot_pubkey(&mut self) -> Result, Error> { 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/tap_signer.rs b/lib/src/tap_signer.rs index 76923dc..a371c10 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,18 +1,18 @@ -use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, ecdsa::Signature}; -use bitcoin_hashes::sha256; -use log::error; -use std::future::Future; - use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, Error, NewCommand, NewResponse, SignCommand, SignResponse, StatusCommand, StatusResponse, tap_signer::{BackupCommand, BackupResponse, ChangeCommand, ChangeResponse}, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; +use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; +use async_trait::async_trait; +use bitcoin::hex::DisplayHex; +use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, ecdsa::Signature}; +use bitcoin_hashes::sha256; +use log::error; +use std::sync::Arc; -pub struct TapSigner { - pub transport: T, +pub struct TapSigner { + pub transport: Arc, pub secp: Secp256k1, pub proto: usize, pub ver: String, @@ -76,7 +76,7 @@ pub enum PsbtSignError { InvalidPath(usize), } -impl Authentication for TapSigner { +impl Authentication for TapSigner { fn secp(&self) -> &Secp256k1 { &self.secp } @@ -105,99 +105,86 @@ 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: [u8; 32], cvc: &str) -> Result<(), TapSignerError> { + 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) } + // TODO move to pub(crate) trait /// 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(), &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(Error::CkTap(crate::apdu::CkTapError::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}, @@ -205,190 +192,178 @@ where type Error = PsbtSignError; - async { - let unsigned_tx = psbt.unsigned_tx.clone(); - let mut sighash_cache = SighashCache::new(&unsigned_tx); + 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))?; + 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(Error::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(Error::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))?; - // 1 << 31 - const HARDENED: u32 = 0x80000000; - let path = path.to_u32_vec(); + // 1 << 31 + const HARDENED: u32 = 0x80000000; + let path = path.to_u32_vec(); - if path.len() != 5 { - return Err(Error::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)); - } + if path.len() != 5 { + 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()))?; + let sub_path = vec![path[3], path[4]]; + if sub_path.iter().any(|p| *p > HARDENED) { + return Err(Error::InvalidPath(input_index)); + } - // the digest is the sighash - let digest: &[u8; 32] = sighash.as_ref(); + // 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; + + // 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 ^ (1 << 31)).take(3).collect(); + let derive_response = self.derive(&path, cvc).await; + if derive_response.is_err() { + return Err(Error::PubkeyMismatch(input_index)); + } - // 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, 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(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) + // 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) } - /// 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?; - - let card_nonce = self.card_nonce(); - let sig = &derive_response.sig; - - let mut message_bytes: Vec = Vec::new(); - message_bytes.extend("OPENDIME".as_bytes()); - message_bytes.extend(card_nonce); - 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(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) - } + /// 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: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#tapsigner-performs-subkey-derivation + async fn derive(&mut self, path: &[u32], 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?; + + let card_nonce = self.card_nonce(); + let sig = &derive_response.sig; + + let mut message_bytes: Vec = Vec::new(); + message_bytes.extend("OPENDIME".as_bytes()); + message_bytes.extend(card_nonce); + 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(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)?, + }; + + self.secp() + .verify_ecdsa(&message, &signature, &pubkey) + .map_err(Error::from)?; + + self.set_card_nonce(derive_response.card_nonce); + + 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<(), TapSignerError> { + if new_cvc.len() < 6 { + return Err(CvcChangeError::TooShort(new_cvc.len()).into()); + } - if new_cvc.len() > 32 { - return Err(CvcChangeError::TooLong(new_cvc.len()).into()); - } + if new_cvc.len() > 32 { + return Err(CvcChangeError::TooLong(new_cvc.len()).into()); + } - if new_cvc == cvc { - return Err(CvcChangeError::SameAsOld.into()); - } + if new_cvc == cvc { + return Err(CvcChangeError::SameAsOld.into()); + } - // 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(), &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()))?; - Ok(Self { + Ok(TapSigner { transport, secp: Secp256k1::new(), proto: status_response.proto, @@ -403,20 +378,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, TapSignerError> { 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 } @@ -426,13 +403,14 @@ impl Read for TapSigner { } } -impl Certificate for TapSigner { +#[async_trait] +impl Certificate for TapSigner { async fn slot_pubkey(&mut self) -> Result, Error> { 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) From 88cadfb9aac62d0bd6d7c89d297bf66f660b97c1 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Tue, 19 Aug 2025 20:27:01 -0500 Subject: [PATCH 04/15] test(swift): add test CardEmulator implementation of CkTransport --- .gitignore | 1 + Justfile | 29 +++++- cktap-ffi/src/lib.rs | 30 +++--- cktap-swift/Tests/CKTapTests/CKTapTests.swift | 52 +++++------ .../Tests/CKTapTests/CardEmulator.swift | 93 +++++++++++++++++++ .../CKTapTests/SmartCardTest.entitlements | 10 -- 6 files changed, 159 insertions(+), 56 deletions(-) create mode 100644 cktap-swift/Tests/CKTapTests/CardEmulator.swift delete mode 100644 cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements 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 b11a64c..3ca9423 100644 --- a/Justfile +++ b/Justfile @@ -11,15 +11,38 @@ 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 +# 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 +test: fmt setup source emulator_env/bin/activate && cargo test -p rust-cktap --features emulator # clean the project target directory diff --git a/cktap-ffi/src/lib.rs b/cktap-ffi/src/lib.rs index c959720..07f5147 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -6,23 +6,23 @@ use std::fmt::Debug; use std::sync::Arc; #[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum Error { +pub enum CkTapError { #[error("Core Error: {msg}")] Core { msg: String }, #[error("Transport Error: {msg}")] Transport { msg: String }, } -impl From for Error { +impl From for CkTapError { fn from(e: rust_cktap::Error) -> Self { - Error::Core { msg: e.to_string() } + CkTapError::Core { msg: e.to_string() } } } #[uniffi::export(callback_interface)] #[async_trait::async_trait] pub trait CkTransport: Send + Sync { - async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; + async fn transmit_apdu(&self, command_apdu: Vec) -> Result, CkTapError>; } pub struct CkTransportWrapper(Box); @@ -48,23 +48,23 @@ impl SatsCard { self.0.lock().await.ver().to_string() } - pub async fn address(&self) -> Result { + pub async fn address(&self) -> Result { self.0 .lock() .await .address() .await - .map_err(|e| Error::Core { msg: e.to_string() }) + .map_err(|e| CkTapError::Core { msg: e.to_string() }) } - pub async fn read(&self) -> Result, Error> { + pub async fn read(&self) -> Result, CkTapError> { self.0 .lock() .await .read(None) .await .map(|pk| pk.serialize().to_vec()) - .map_err(|e| Error::Core { msg: e.to_string() }) + .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands } @@ -78,14 +78,14 @@ impl TapSigner { self.0.lock().await.ver().to_string() } - pub async fn read(&self) -> Result, Error> { + pub async fn read(&self, cvc: String) -> Result, CkTapError> { self.0 .lock() .await - .read(None) + .read(Some(cvc)) .await .map(|pk| pk.serialize().to_vec()) - .map_err(|e| Error::Core { msg: e.to_string() }) + .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands } @@ -99,14 +99,14 @@ impl SatsChip { self.0.lock().await.ver().to_string() } - pub async fn read(&self) -> Result, Error> { + pub async fn read(&self) -> Result, CkTapError> { self.0 .lock() .await .read(None) .await .map(|pk| pk.serialize().to_vec()) - .map_err(|e| Error::Core { msg: e.to_string() }) + .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands } @@ -119,11 +119,11 @@ pub enum CkTapCard { } #[uniffi::export] -pub async fn to_cktap(transport: Box) -> Result { +pub async fn to_cktap(transport: Box) -> Result { let wrapper = CkTransportWrapper(transport); let cktap: rust_cktap::CkTapCard = rust_cktap::commands::to_cktap(Arc::new(wrapper)) .await - .map_err(Into::::into)?; + .map_err(Into::::into)?; match cktap { rust_cktap::CkTapCard::SatsCard(sc) => { diff --git a/cktap-swift/Tests/CKTapTests/CKTapTests.swift b/cktap-swift/Tests/CKTapTests/CKTapTests.swift index 1733bc0..6b04630 100644 --- a/cktap-swift/Tests/CKTapTests/CKTapTests.swift +++ b/cktap-swift/Tests/CKTapTests/CKTapTests.swift @@ -1,6 +1,5 @@ import XCTest import CKTap -import CryptoTokenKit final class CKTapTests: XCTestCase { func testHelloRandom() throws { @@ -8,32 +7,29 @@ final class CKTapTests: XCTestCase { print("Random: \(nonce)") } - func testAddress() throws { - print("Test getting an address") - let manager = TKSmartCardSlotManager() - let slots = manager.slotNames - - if slots.isEmpty { - print("No smart card slots available.") - return - } else { - if let firstSlotName = slots.first { - print("Connecting to slot: \(firstSlotName)") - manager.getSlot(withName:firstSlotName) { slot in - guard let smartCardSlot = slot?.makeSmartCard() else { - print("Failed to create smart card from slot.") - return - } - - smartCardSlot.beginSession(reply:) { success, error in - if success { - print("Session started successfully.") - } else { - print("Failed to start session: \(String(describing: error))") - } - } - } - } + func testEmulatorTransport() async throws { + print("Test with card emulator transport") + let cardEmulator = CardEmulator() + + let card: CkTapCard + do { + card = try await toCktap(transport: cardEmulator) + } catch { + throw CkTapError.Core(msg: "Failed to create CkTap instance: \(error.localizedDescription)") + } + + switch card { + case .satsCard(let satsCard): + print("Handling SatsCard with version: \(await satsCard.ver())") + let address: String = try await satsCard.address() + print("SatsCard address: \(address)") + XCTAssertEqual(address, "bc1qdu05evh9kw0w482lfl2ktxm6ylp060km28z8fr") + case .tapSigner(let tapSigner): + print("Handling TapSigner with version: \(await tapSigner.ver())") + let public_key = try await tapSigner.read(cvc: "123456") + print("TapSigner public key: \(Array(public_key))") + case .satsChip(let satsChip): + print("Handling SatsChip with version: \(await satsChip.ver())") + } } } -} diff --git a/cktap-swift/Tests/CKTapTests/CardEmulator.swift b/cktap-swift/Tests/CKTapTests/CardEmulator.swift new file mode 100644 index 0000000..adc8cb4 --- /dev/null +++ b/cktap-swift/Tests/CKTapTests/CardEmulator.swift @@ -0,0 +1,93 @@ +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/Tests/CKTapTests/SmartCardTest.entitlements b/cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements deleted file mode 100644 index b6329d8..0000000 --- a/cktap-swift/Tests/CKTapTests/SmartCardTest.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.smartcard - - - \ No newline at end of file From ecbe088079eee0f28a581c009b687eddf09cb2c5 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 20 Aug 2025 23:14:19 -0500 Subject: [PATCH 05/15] feat(satscard): add sign and sign_psbt --- Justfile | 2 +- cli/src/main.rs | 18 +++- lib/Cargo.toml | 3 +- lib/src/apdu.rs | 69 ++++++++----- lib/src/lib.rs | 5 + lib/src/sats_card.rs | 231 +++++++++++++++++++++++++++++++++++++++++- lib/src/tap_signer.rs | 32 ++++-- 7 files changed, 316 insertions(+), 44 deletions(-) diff --git a/Justfile b/Justfile index 3ca9423..369baa0 100644 --- a/Justfile +++ b/Justfile @@ -15,7 +15,7 @@ clippy: fmt # build the project build: fmt - cargo build --all-features --tests + cargo build --all-features --all-targets # setup the cktap emulator venv setup: diff --git a/cli/src/main.rs b/cli/src/main.rs index bc99715..37744f0 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -8,11 +8,12 @@ use rust_cktap::emulator; use rust_cktap::pcsc; use rust_cktap::secp256k1::rand; use rust_cktap::tap_signer::TapSignerShared; -use rust_cktap::{CkTapCard, apdu::Error, commands::Certificate, rand_chaincode}; +use rust_cktap::{CkTapCard, Psbt, apdu::Error, commands::Certificate, rand_chaincode}; use std::io; use std::io::Write; #[cfg(feature = "emulator")] use std::path::Path; +use std::str::FromStr; /// SatsCard CLI #[derive(Parser)] @@ -40,6 +41,13 @@ enum SatsCardCommand { Unseal, /// Get the payment address and verify it follows from the chain code and master public key Derive, + /// 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, } @@ -147,6 +155,14 @@ async fn main() -> Result<(), Error> { let (privkey, pubkey) = &sc.unseal(slot, &cvc()).await?; println!("privkey: {}, pubkey: {pubkey}", privkey.display_secret()) } + SatsCardCommand::Sign { slot, psbt } => { + let psbt = Psbt::from_str(&psbt).map_err(|e| Error::Psbt(e.to_string()))?; + let signed_psbt = sc + .sign_psbt(slot, psbt, &cvc()) + .await + .map_err(|e| Error::SignPsbt(e.to_string()))?; + println!("signed_psbt: {signed_psbt}"); + } SatsCardCommand::Derive => { dbg!(&sc.derive().await); } diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 39748d1..9b10f3a 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -24,7 +24,7 @@ tokio = { version = "1.44", features = ["macros"] } 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 @@ -36,6 +36,7 @@ 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 = [] diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index 407080f..cc8b691 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -10,9 +10,10 @@ 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 std::fmt; use std::fmt::{Debug, Formatter}; + 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]; @@ -34,9 +35,12 @@ pub enum Error { InvalidChaincode, #[error("UnknownCardType: {0}")] UnknownCardType(String), - #[error("Transport: {0}")] Transport(String), + #[error("PSBT: {0}")] + Psbt(String), + #[error("Sign PSBT: {0}")] + SignPsbt(String), } #[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] @@ -575,7 +579,7 @@ 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 @@ -584,43 +588,52 @@ pub struct NfcResponse { 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. +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: 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, @@ -631,7 +644,7 @@ impl SignCommand { 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, diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 6a25e83..d5717f3 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,6 +1,8 @@ extern crate core; use bitcoin::key::rand::Rng as _; +pub use bitcoin::*; + pub use commands::CkTransport; pub mod apdu; @@ -29,6 +31,9 @@ pub type SatsCard = sats_card::SatsCard; pub type TapSigner = tap_signer::TapSigner; pub type SatsChip = sats_chip::SatsChip; +// BIP 32 hardened derivation bitmask, 1 << 31 +const BIP32_HARDENED_MASK: u32 = 1 << 31; + pub enum CkTapCard { SatsCard(SatsCard), TapSigner(TapSigner), diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index 921aa52..7cdfe80 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -6,11 +6,13 @@ use bitcoin_hashes::sha256; use std::sync::Arc; use crate::apdu::{ - CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, NewCommand, - NewResponse, StatusResponse, UnsealCommand, UnsealResponse, + CkTapError, CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, + NewCommand, NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, + UnsealResponse, }; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; use crate::secp256k1; +use crate::tap_signer::PsbtSignError; use bitcoin_hashes::hex::DisplayHex; pub struct SatsCard { @@ -188,8 +190,8 @@ impl SatsCard { let dump_command = DumpCommand::new(slot, epubkey, xcvc); let dump_response: DumpResponse = transmit(self.transport(), &dump_command).await?; // TODO use chaincode and master public key to verify pubkey - // TODO only return the verified slot keys // TODO return error if `tampered` is true + // TODO only return the verified slot key and status Ok(dump_response) } @@ -200,6 +202,125 @@ impl SatsCard { let address = Address::p2wpkh(&pk, 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(), &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(Error::CkTap(CkTapError::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 = PsbtSignError; + + 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) + } } #[async_trait] @@ -237,3 +358,107 @@ impl core::fmt::Debug for SatsCard { .finish() } } + +#[cfg(feature = "emulator")] +#[cfg(test)] +mod test { + use crate::CkTapCard; + use crate::commands::Certificate; + use crate::emulator::find_emulator; + use crate::emulator::test::{CardTypeOption, EcardSubprocess}; + 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::{Bitcoin, Testnet}; + use bitcoin::hashes::Hash; + use bitcoin::{ + Address, Amount, BlockHash, FeeRate, Network, PrivateKey, PublicKey, 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 _seckey = PrivateKey::new(seckey, Bitcoin); + let pubkey = PublicKey::new(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); + } +} diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index a371c10..ceff717 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,3 +1,4 @@ +use crate::BIP32_HARDENED_MASK; use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, Error, NewCommand, NewResponse, SignCommand, SignResponse, StatusCommand, StatusResponse, @@ -11,6 +12,13 @@ use bitcoin_hashes::sha256; use log::error; use std::sync::Arc; +const BIP84_PATH_LEN: usize = 5; + +// BIP84 derivation path structure, m / 84' / 0' / account' / change / address_index + +// 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, @@ -69,11 +77,14 @@ pub enum PsbtSignError { #[error(transparent)] TapSignerError(#[from] Error), - #[error("pubkey mismatch: index: {0}")] + #[error("Pubkey mismatch: index: {0}")] PubkeyMismatch(usize), #[error("Invalid path at index: {0}")] InvalidPath(usize), + + #[error("Signing slot is not unsealed: {0}")] + SlotNotUnsealed(u8), } impl Authentication for TapSigner { @@ -130,7 +141,6 @@ pub trait TapSignerShared: Authentication { Ok(status_response) } - // TODO move to pub(crate) trait /// Sign a message digest with the tap signer async fn sign( &mut self, @@ -217,16 +227,14 @@ pub trait TapSignerShared: Authentication { .next() .ok_or(Error::MissingPubkey(input_index))?; - // 1 << 31 - const HARDENED: u32 = 0x80000000; let path = path.to_u32_vec(); - if path.len() != 5 { + if path.len() != BIP84_PATH_LEN { return Err(Error::InvalidPath(input_index)); } - let sub_path = vec![path[3], path[4]]; - if sub_path.iter().any(|p| *p > HARDENED) { + let sub_path = BIP84_HARDENED_SUBPATH.map(|i| path[i]); + if sub_path.iter().any(|p| *p > BIP32_HARDENED_MASK) { return Err(Error::InvalidPath(input_index)); } @@ -240,21 +248,25 @@ pub trait TapSignerShared: Authentication { 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 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 ^ (1 << 31)).take(3).collect(); + 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(Error::PubkeyMismatch(input_index)); } // update signature to the new one we just derived - sign_response = self.sign(*digest, sub_path, cvc).await?; + sign_response = self.sign(*digest, sub_path.to_vec(), cvc).await?; signature_raw = sign_response.sig; // if still not matching, return error From 84a73aa5d2f88d3ab7396ed98d8c27dfd6e256bd Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 22 Aug 2025 09:06:01 -0500 Subject: [PATCH 06/15] feat(cli): add SATSCARD dump command --- cli/src/main.rs | 8 ++++++++ lib/src/apdu.rs | 4 ++-- lib/src/sats_card.rs | 6 +----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/cli/src/main.rs b/cli/src/main.rs index 37744f0..19e302d 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -41,6 +41,8 @@ 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 @@ -166,6 +168,12 @@ async fn main() -> Result<(), Error> { 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, } } diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index cc8b691..61468e7 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -908,7 +908,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")] @@ -920,7 +920,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, diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index 7cdfe80..8687654 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -173,11 +173,7 @@ impl SatsCard { Ok((privkey, pubkey)) } - pub async fn dump( - &self, - slot: usize, - cvc: Option, - ) -> Result { + pub async fn dump(&self, slot: u8, cvc: Option) -> Result { let epubkey_xcvc = cvc.map(|cvc| { let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, DumpCommand::name()); (epubkey, xcvc) From 0b6273fdd7f263f0eefe36080a20c6727237f100 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sun, 24 Aug 2025 23:30:32 -0500 Subject: [PATCH 07/15] feat(satscard): simplify and decrypt dump slot result --- Justfile | 8 ++- lib/src/apdu.rs | 11 +++- lib/src/commands.rs | 1 - lib/src/emulator.rs | 4 +- lib/src/sats_card.rs | 125 ++++++++++++++++++++++++++++++++++++++----- 5 files changed, 130 insertions(+), 19 deletions(-) diff --git a/Justfile b/Justfile index 369baa0..73cd487 100644 --- a/Justfile +++ b/Justfile @@ -43,7 +43,7 @@ stop: # test the rust-cktap lib with the coinkite cktap card emulator test: fmt setup - source emulator_env/bin/activate && cargo test -p rust-cktap --features emulator + source emulator_env/bin/activate && cargo test -p rust-cktap --features emulator -- --nocapture # clean the project target directory clean: @@ -51,4 +51,8 @@ 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}} \ No newline at end of file + 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/lib/src/apdu.rs b/lib/src/apdu.rs index 61468e7..5986e68 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -41,6 +41,15 @@ pub enum Error { Psbt(String), #[error("Sign PSBT: {0}")] SignPsbt(String), + #[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(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] @@ -952,7 +961,7 @@ pub struct DumpResponse { /// public key, 33 bytes #[serde(with = "serde_bytes")] #[serde(default)] - pub pubkey: Vec, + pub pubkey: Option>, /// nonce provided by customer originally #[serde(with = "serde_bytes")] #[serde(default)] diff --git a/lib/src/commands.rs b/lib/src/commands.rs index c3c4494..50ee16b 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -280,7 +280,6 @@ mod tests { 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) => { diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 72e2795..0f9c214 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -137,7 +137,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/sats_card.rs b/lib/src/sats_card.rs index 8687654..2a39d06 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -173,22 +173,66 @@ impl SatsCard { Ok((privkey, pubkey)) } - pub async fn dump(&self, slot: u8, 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 an unsealed or unused slot number is given an error is returned. + pub async fn dump( + &mut self, + slot: u8, + cvc: Option, + ) -> Result<(Option, PublicKey), Error> { + 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 (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); + let dump_command = DumpCommand::new(slot, epubkey, xcvc.clone()); let dump_response: DumpResponse = transmit(self.transport(), &dump_command).await?; - // TODO use chaincode and master public key to verify pubkey - // TODO return error if `tampered` is true - // TODO only return the verified slot key and status - Ok(dump_response) + self.set_card_nonce(dump_response.card_nonce); + + // throw errors + if let Some(tampered) = dump_response.tampered { + if tampered { + return Err(Error::SlotTampered(slot)); + } + } else if let Some(sealed) = dump_response.sealed { + if sealed { + return Err(Error::SlotSealed(slot)); + } + } else if let Some(used) = dump_response.used { + if !used { + return Err(Error::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(), &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 = SecretKey::from_slice(&privkey)?; + Some(privkey) + } else { + None + }; + + Ok((seckey, pubkey)) } pub async fn address(&mut self) -> Result { @@ -355,13 +399,29 @@ impl core::fmt::Debug for SatsCard { } } +/// Slot details for an in-use or unsealed slot. +/// +/// Without the CVC, only the public details for each slot, is included. +/// except unsealed slots where the address in full is also provided. +#[derive(Clone, Debug)] +pub struct SlotDetails { + /// Slot number. + pub slot: usize, + /// Private key for spending (for addr) or None if slot not unsealed or no CVC given. + pub privkey: Option, + /// Public key for receiving bitcoin. + pub pubkey: PublicKey, + /// Full payment address (not censored). + pub addr: String, +} + #[cfg(feature = "emulator")] #[cfg(test)] mod test { - use crate::CkTapCard; use crate::commands::Certificate; use crate::emulator::find_emulator; use crate::emulator::test::{CardTypeOption, EcardSubprocess}; + use crate::{CkTapCard, Error}; use bdk_wallet::chain::{BlockId, ConfirmationBlockTime}; use bdk_wallet::template::P2Wpkh; use bdk_wallet::test_utils::{insert_anchor, insert_checkpoint, insert_tx, new_tx}; @@ -457,4 +517,43 @@ mod test { 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(Error::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(Error::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(Error::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(Error::SlotUnused(slot)) if slot == 1)); + } + drop(python); + } } From ec6228c8e657c85f0ed9cb37aee99b4c589ef2e9 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Tue, 26 Aug 2025 23:06:38 -0500 Subject: [PATCH 08/15] refactor!: use rust-bitcoin key types in API for pub and priv keys Also make all apdu types crate(pub) instead of pub. --- cktap-ffi/src/lib.rs | 6 +- cli/src/main.rs | 22 ++-- lib/examples/pcsc.rs | 2 +- lib/src/apdu.rs | 222 +++++++++--------------------------- lib/src/apdu/tap_signer.rs | 16 +-- lib/src/commands.rs | 31 ++--- lib/src/emulator.rs | 4 +- lib/src/error.rs | 138 ++++++++++++++++++++++ lib/src/factory_root_key.rs | 2 +- lib/src/lib.rs | 32 +++--- lib/src/sats_card.rs | 56 +++++---- lib/src/sats_chip.rs | 8 +- lib/src/tap_signer.rs | 24 ++-- 13 files changed, 291 insertions(+), 272 deletions(-) create mode 100644 lib/src/error.rs diff --git a/cktap-ffi/src/lib.rs b/cktap-ffi/src/lib.rs index 07f5147..c728d30 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -63,7 +63,7 @@ impl SatsCard { .await .read(None) .await - .map(|pk| pk.serialize().to_vec()) + .map(|pk| pk.to_bytes().to_vec()) .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands @@ -84,7 +84,7 @@ impl TapSigner { .await .read(Some(cvc)) .await - .map(|pk| pk.serialize().to_vec()) + .map(|pk| pk.to_bytes().to_vec()) .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands @@ -105,7 +105,7 @@ impl SatsChip { .await .read(None) .await - .map(|pk| pk.serialize().to_vec()) + .map(|pk| pk.to_bytes().to_vec()) .map_err(|e| CkTapError::Core { msg: e.to_string() }) } // TODO implement the rest of the commands diff --git a/cli/src/main.rs b/cli/src/main.rs index 19e302d..1235260 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -6,9 +6,9 @@ use rust_cktap::commands::{Authentication, Read, Wait}; use rust_cktap::emulator; #[cfg(not(feature = "emulator"))] use rust_cktap::pcsc; -use rust_cktap::secp256k1::rand; +use rust_cktap::rand; use rust_cktap::tap_signer::TapSignerShared; -use rust_cktap::{CkTapCard, Psbt, apdu::Error, commands::Certificate, rand_chaincode}; +use rust_cktap::{CkTapCard, Error, Psbt, commands::Certificate, rand_chaincode}; use std::io; use std::io::Write; #[cfg(feature = "emulator")] @@ -78,7 +78,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, @@ -114,7 +114,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 }, @@ -155,7 +155,7 @@ async fn main() -> Result<(), Error> { SatsCardCommand::Unseal => { let slot = sc.slot().expect("current slot number"); let (privkey, pubkey) = &sc.unseal(slot, &cvc()).await?; - println!("privkey: {}, pubkey: {pubkey}", privkey.display_secret()) + println!("privkey: {}, pubkey: {pubkey}", privkey.to_wif()) } SatsCardCommand::Sign { slot, psbt } => { let psbt = Psbt::from_str(&psbt).map_err(|e| Error::Psbt(e.to_string()))?; @@ -191,7 +191,9 @@ async fn main() -> Result<(), Error> { 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 => { @@ -205,8 +207,7 @@ async fn main() -> Result<(), Error> { } TapSignerCommand::Sign { to_sign } => { let digest: [u8; 32] = - rust_cktap::bitcoin_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:?}"); @@ -228,7 +229,7 @@ async fn main() -> Result<(), Error> { dbg!(response); } SatsChipCommand::Derive { path } => { - dbg!(&sc.derive(&path, &cvc()).await); + dbg!(&sc.derive(path.unwrap_or_default(), &cvc()).await); } SatsChipCommand::Change { new_cvc } => { @@ -237,8 +238,7 @@ async fn main() -> Result<(), Error> { } SatsChipCommand::Sign { to_sign } => { let digest: [u8; 32] = - rust_cktap::bitcoin_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:?}"); diff --git a/lib/examples/pcsc.rs b/lib/examples/pcsc.rs index 5e304fd..f15ac06 100644 --- a/lib/examples/pcsc.rs +++ b/lib/examples/pcsc.rs @@ -1,6 +1,6 @@ extern crate core; -use rust_cktap::apdu::Error; +use rust_cktap::Error; use rust_cktap::commands::{Certificate, Wait}; use rust_cktap::{CkTapCard, pcsc, rand_chaincode}; diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index 5986e68..a02ece4 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -2,15 +2,16 @@ /// 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, -}; +use crate::error::ErrorResponse; +use crate::{CkTapError, Error}; +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, Serializer}; +use serde_bytes::ByteBuf; use std::fmt; use std::fmt::{Debug, Formatter}; @@ -18,135 +19,6 @@ 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]; -// 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("Card chain code doesn't match user provided chain code")] - InvalidChaincode, - #[error("UnknownCardType: {0}")] - UnknownCardType(String), - #[error("Transport: {0}")] - Transport(String), - #[error("PSBT: {0}")] - Psbt(String), - #[error("Sign PSBT: {0}")] - SignPsbt(String), - #[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(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::Transport(e.to_string()) - } -} - -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct ErrorResponse { - pub error: String, - pub code: u16, -} - // Apdu Traits pub trait CommandApdu { fn name() -> &'static str; @@ -281,7 +153,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, @@ -318,31 +190,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())) + Signature::from_compact(self.sig.as_slice()).map_err(Error::from) } 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(Error::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(Error::from) } } @@ -381,7 +252,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]>, @@ -401,7 +276,7 @@ impl DeriveCommand { DeriveCommand { cmd: Self::name(), nonce, - path: vec![], + path: None, epubkey: None, xcvc: None, } @@ -410,13 +285,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), } @@ -485,8 +360,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 {} @@ -496,10 +370,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() } } @@ -551,10 +422,10 @@ impl CheckCommand { 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 {} @@ -569,6 +440,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, @@ -592,14 +464,14 @@ impl CommandApdu for NfcCommand { #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct NfcResponse { /// command result - pub url: String, + url: String, } impl ResponseApdu for NfcResponse {} // 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. -fn serialize_some(opt: &Option, serializer: S) -> Result +pub(crate) fn serialize_some(opt: &Option, serializer: S) -> Result where T: Serialize, S: Serializer, @@ -633,7 +505,12 @@ pub struct SignCommand { } impl SignCommand { - pub fn for_satscard(slot: u8, digest: [u8; 32], epubkey: PublicKey, xcvc: Vec) -> Self { + pub fn for_satscard( + slot: u8, + digest: [u8; 32], + epubkey: secp256k1::PublicKey, + xcvc: Vec, + ) -> Self { SignCommand { cmd: Self::name(), slot: Some(slot), @@ -647,7 +524,7 @@ impl SignCommand { pub fn for_tapsigner( sub_path: Vec, digest: [u8; 32], - epubkey: PublicKey, + epubkey: secp256k1::PublicKey, xcvc: Vec, ) -> Self { SignCommand { @@ -723,10 +600,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, } } @@ -744,10 +621,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 {} @@ -780,7 +657,7 @@ impl NewCommand { pub fn new( slot: Option, chain_code: Option<[u8; 32]>, - epubkey: PublicKey, + epubkey: secp256k1::PublicKey, xcvc: Vec, ) -> Self { let slot = slot.unwrap_or_default(); @@ -815,10 +692,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 {} @@ -848,7 +725,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, @@ -880,8 +757,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], @@ -891,13 +769,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()) } } @@ -929,7 +807,7 @@ pub struct DumpCommand { } impl DumpCommand { - pub fn new(slot: u8, epubkey: Option, xcvc: Option>) -> Self { + pub fn new(slot: u8, epubkey: Option, xcvc: Option>) -> Self { DumpCommand { cmd: Self::name(), slot, @@ -952,6 +830,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 @@ -963,10 +842,12 @@ pub struct DumpResponse { #[serde(default)] 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>, @@ -979,6 +860,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 a15117d..f9014ba 100644 --- a/lib/src/apdu/tap_signer.rs +++ b/lib/src/apdu/tap_signer.rs @@ -1,8 +1,7 @@ -use core::fmt::{self, Formatter}; - use super::{CommandApdu, ResponseApdu}; +use core::fmt::{self, Formatter}; -use bitcoin::secp256k1::PublicKey; +use bitcoin::secp256k1; use bitcoin_hashes::hex::DisplayHex as _; use serde::{Deserialize, Serialize}; @@ -26,7 +25,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, @@ -39,9 +39,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 {} @@ -82,7 +82,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, @@ -125,7 +125,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 index 50ee16b..25d32b9 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -1,11 +1,12 @@ use crate::factory_root_key::FactoryRootKey; -use crate::{CkTapCard, SatsCard, TapSigner}; +use crate::{CkTapCard, CkTapError, Error, SatsCard, TapSigner}; use crate::{apdu::*, rand_nonce}; -use bitcoin::key::rand; +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, PublicKey, Secp256k1, SecretKey}; +use bitcoin::secp256k1::{All, Message, Secp256k1}; use bitcoin_hashes::sha256; use std::convert::TryFrom; @@ -28,7 +29,11 @@ pub trait Authentication { /// 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) -> (SecretKey, PublicKey, Vec) { + 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); @@ -37,7 +42,7 @@ pub trait Authentication { let (ephemeral_private_key, ephemeral_public_key) = secp.generate_keypair(&mut rand::thread_rng()); - let session_key = SharedSecret::new(pubkey, &ephemeral_private_key); + 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(); @@ -127,7 +132,7 @@ pub trait Read: Authentication { let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, ReadCommand::name()); ( ReadCommand::authenticated(app_nonce, epubkey, xcvc), - Some(SharedSecret::new(self.pubkey(), &eprivkey)), + Some(SharedSecret::new(&self.pubkey().inner, &eprivkey)), ) } else { (ReadCommand::unauthenticated(app_nonce), None) @@ -141,7 +146,7 @@ pub trait Read: Authentication { self.secp().verify_ecdsa( &message_digest, &read_response.signature()?, // or add 'from' trait: Signature::from(response.sig: ) - &pubkey, + &pubkey.inner, )?; // update the card nonce @@ -160,7 +165,7 @@ pub trait Wait: Authentication { }); let (epubkey, xcvc) = epubkey_xcvc - .map(|(epubkey, xcvc)| (Some(epubkey.serialize()), Some(xcvc))) + .map(|(epubkey, xcvc)| (Some(epubkey), Some(xcvc))) .unwrap_or((None, None)); let wait_command = WaitCommand::new(epubkey, xcvc); @@ -200,7 +205,7 @@ pub trait Certificate: Read { message_bytes.extend(app_nonce); if let Some(pubkey) = slot_pubkey { if self.ver() != "0.9.0" { - let slot_pubkey_bytes = pubkey.serialize(); + let slot_pubkey_bytes = pubkey.inner.serialize(); message_bytes.extend(slot_pubkey_bytes); } } @@ -211,7 +216,7 @@ pub trait Certificate: Read { 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())?; + .verify_ecdsa(&message, &signature, &self.pubkey().inner)?; let mut pubkey = *self.pubkey(); for sig in &certs_response.cert_chain() { @@ -227,13 +232,13 @@ pub trait Certificate: Read { 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 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 = result?; + pubkey = PublicKey::new(result?); } - FactoryRootKey::try_from(pubkey) + FactoryRootKey::try_from(pubkey.inner) } async fn slot_pubkey(&mut self) -> Result, Error>; diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 0f9c214..77dd3d9 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -1,6 +1,6 @@ -use crate::CkTapCard; -use crate::apdu::{AppletSelect, CommandApdu, Error, StatusCommand}; +use crate::apdu::{AppletSelect, CommandApdu, StatusCommand}; use crate::commands::{CkTransport, to_cktap}; +use crate::{CkTapCard, Error}; use async_trait::async_trait; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; diff --git a/lib/src/error.rs b/lib/src/error.rs new file mode 100644 index 0000000..3cf2fa1 --- /dev/null +++ b/lib/src/error.rs @@ -0,0 +1,138 @@ +use bitcoin::secp256k1; +use serde::Deserialize; +use std::fmt::Debug; + +// 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("Card chain code doesn't match user provided chain code")] + InvalidChaincode, + #[error("UnknownCardType: {0}")] + UnknownCardType(String), + #[error("Transport: {0}")] + Transport(String), + #[error("PSBT: {0}")] + Psbt(String), + #[error("Sign PSBT: {0}")] + SignPsbt(String), + #[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(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()) + } +} + +impl From for Error { + fn from(e: bitcoin::key::FromSliceError) -> Self { + Error::CiborValue(e.to_string()) + } +} + +#[cfg(feature = "pcsc")] +impl From for Error { + fn from(e: pcsc::Error) -> Self { + Error::Transport(e.to_string()) + } +} + +#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct ErrorResponse { + pub error: String, + pub code: u16, +} diff --git a/lib/src/factory_root_key.rs b/lib/src/factory_root_key.rs index a3698fd..ab4e28b 100644 --- a/lib/src/factory_root_key.rs +++ b/lib/src/factory_root_key.rs @@ -1,4 +1,4 @@ -use crate::apdu::Error; +use crate::Error; use bitcoin::secp256k1::PublicKey; use bitcoin_hashes::hex::DisplayHex; use std::convert::TryFrom; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index d5717f3..9589304 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,21 +1,22 @@ extern crate core; -use bitcoin::key::rand::Rng as _; -pub use bitcoin::*; +pub use bitcoin::psbt::Psbt; +pub use bitcoin::secp256k1::rand; +pub use bitcoin_hashes::sha256::Hash; pub use commands::CkTransport; +pub use error::CkTapError; +pub use error::Error; -pub mod apdu; -pub mod commands; -pub mod factory_root_key; +use bitcoin::key::rand::Rng as _; -pub use bitcoin::{ - Address, Network, - key::CompressedPublicKey, - key::UntweakedPublicKey, - secp256k1::{self, rand}, -}; -pub use bitcoin_hashes; +pub(crate) mod apdu; +pub mod commands; +pub mod error; +pub(crate) mod factory_root_key; +pub mod sats_card; +pub mod sats_chip; +pub mod tap_signer; #[cfg(feature = "emulator")] pub mod emulator; @@ -23,10 +24,6 @@ pub mod emulator; #[cfg(feature = "pcsc")] pub mod pcsc; -pub mod sats_card; -pub mod sats_chip; -pub mod tap_signer; - pub type SatsCard = sats_card::SatsCard; pub type TapSigner = tap_signer::TapSigner; pub type SatsChip = sats_chip::SatsChip; @@ -40,9 +37,6 @@ pub enum CkTapCard { SatsChip(SatsChip), } -// re-export -pub use apdu::Error; - impl core::fmt::Debug for CkTapCard { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match &self { diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index 2a39d06..72a2708 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,18 +1,18 @@ use async_trait::async_trait; -use bitcoin::key::CompressedPublicKey as BitcoinPublicKey; -use bitcoin::secp256k1::{All, Message, PublicKey, Secp256k1, SecretKey, ecdsa::Signature}; -use bitcoin::{Address, Network}; +use bitcoin::secp256k1::{All, Message, Secp256k1, ecdsa::Signature}; +use bitcoin::{Address, Network, PrivateKey, PublicKey}; use bitcoin_hashes::sha256; use std::sync::Arc; +use crate::Error; use crate::apdu::{ - CkTapError, CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, Error, - NewCommand, NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, - UnsealResponse, + CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, NewCommand, + NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, UnsealResponse, }; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; -use crate::secp256k1; +use crate::error::CkTapError; use crate::tap_signer::PsbtSignError; +use bitcoin::secp256k1; use bitcoin_hashes::hex::DisplayHex; pub struct SatsCard { @@ -68,7 +68,7 @@ impl SatsCard { 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(Error::from)?; let slots = status_response .slots @@ -132,9 +132,9 @@ impl SatsCard { let signature = Signature::from_compact(&resp.sig)?; - let master_pubkey = PublicKey::from_slice(&resp.master_pubkey)?; + let master_pubkey = PublicKey::from_slice(&resp.master_pubkey).map_err(Error::from)?; self.secp() - .verify_ecdsa(&message, &signature, &master_pubkey)?; + .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; // return card chain code so user can verify it matches user provided chain code let card_chaincode = &resp.chain_code; @@ -151,7 +151,7 @@ impl SatsCard { /// Unseal a slot. /// /// Returns the private and corresponding public keys for that slot. - pub async fn unseal(&mut self, slot: u8, cvc: &str) -> Result<(SecretKey, PublicKey), Error> { + pub async fn unseal(&mut self, slot: u8, cvc: &str) -> Result<(PrivateKey, PublicKey), Error> { let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, UnsealCommand::name()); let unseal_command = UnsealCommand::new(slot, epubkey, xcvc); let unseal_response: UnsealResponse = transmit(self.transport(), &unseal_command).await?; @@ -159,7 +159,7 @@ impl SatsCard { // 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(), &eprivkey); + 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() @@ -167,8 +167,8 @@ impl SatsCard { .zip(&unseal_response.privkey) .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte) .collect(); - let privkey = SecretKey::from_slice(&privkey)?; - let pubkey = PublicKey::from_slice(&unseal_response.pubkey)?; + let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?; + let pubkey = PublicKey::from_slice(&unseal_response.pubkey).map_err(Error::from)?; // TODO should verify user provided chain code was used similar to above `derive`. Ok((privkey, pubkey)) } @@ -182,7 +182,7 @@ impl SatsCard { &mut self, slot: u8, cvc: Option, - ) -> Result<(Option, PublicKey), Error> { + ) -> Result<(Option, PublicKey), Error> { let epubkey_eprivkey_xcvc = cvc.map(|cvc| { let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, DumpCommand::name()); (epubkey, eprivkey, xcvc) @@ -213,12 +213,13 @@ impl SatsCard { // at this point pubkey must be available let pubkey_bytes = dump_response.pubkey.unwrap(); - let pubkey = PublicKey::from_slice(&pubkey_bytes)?; + let pubkey = PublicKey::from_slice(&pubkey_bytes).map_err(Error::from)?; + // 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(), &eprivkey); + 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() @@ -226,7 +227,7 @@ impl SatsCard { .zip(&privkey) .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte) .collect(); - let privkey = SecretKey::from_slice(&privkey)?; + let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?; Some(privkey) } else { None @@ -238,8 +239,8 @@ impl SatsCard { pub async fn address(&mut self) -> Result { let network = Network::Bitcoin; let slot_pubkey = self.read(None).await?; - let pk = BitcoinPublicKey::from_slice(&slot_pubkey.serialize())?; - let address = Address::p2wpkh(&pk, network); + let slot_pubkey = bitcoin::CompressedPublicKey(slot_pubkey.inner); + let address = Address::p2wpkh(&slot_pubkey, network); Ok(address.to_string()) } @@ -253,7 +254,7 @@ impl SatsCard { 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); + 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 @@ -408,7 +409,7 @@ pub struct SlotDetails { /// Slot number. pub slot: usize, /// Private key for spending (for addr) or None if slot not unsealed or no CVC given. - pub privkey: Option, + pub privkey: Option, /// Public key for receiving bitcoin. pub pubkey: PublicKey, /// Full payment address (not censored). @@ -426,11 +427,9 @@ mod test { 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::{Bitcoin, Testnet}; + use bitcoin::Network::Testnet; use bitcoin::hashes::Hash; - use bitcoin::{ - Address, Amount, BlockHash, FeeRate, Network, PrivateKey, PublicKey, Transaction, TxOut, - }; + use bitcoin::{Address, Amount, BlockHash, FeeRate, Network, Transaction, TxOut}; use std::path::Path; use std::str::FromStr; @@ -445,12 +444,9 @@ mod test { 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(); + let (_seckey, pubkey) = sc.unseal(0, "123456").await.unwrap(); assert_eq!(pubkey, slot_pubkey); - let _seckey = PrivateKey::new(seckey, Bitcoin); - let pubkey = PublicKey::new(pubkey); - let descriptor = P2Wpkh(pubkey); let mut wallet = Wallet::create_single(descriptor) .network(Network::Bitcoin) diff --git a/lib/src/sats_chip.rs b/lib/src/sats_chip.rs index 6f86e01..693fea7 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -1,8 +1,10 @@ use async_trait::async_trait; -use bitcoin::secp256k1::{All, PublicKey, Secp256k1}; +use bitcoin::PublicKey; +use bitcoin::secp256k1::{All, Secp256k1}; use std::sync::Arc; -use crate::apdu::{Error, StatusResponse}; +use crate::Error; +use crate::apdu::StatusResponse; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; use crate::tap_signer::TapSignerShared; @@ -67,7 +69,7 @@ impl SatsChip { 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(Error::from)?; Ok(SatsChip { transport, diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index ceff717..3bf5e73 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,13 +1,14 @@ -use crate::BIP32_HARDENED_MASK; 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, transmit}; +use crate::{BIP32_HARDENED_MASK, Error}; use async_trait::async_trait; +use bitcoin::PublicKey; use bitcoin::hex::DisplayHex; -use bitcoin::secp256k1::{self, All, Message, PublicKey, Secp256k1, ecdsa::Signature}; +use bitcoin::secp256k1::{self, All, Message, Secp256k1, ecdsa::Signature}; use bitcoin_hashes::sha256; use log::error; use std::sync::Arc; @@ -151,7 +152,7 @@ pub trait TapSignerShared: Authentication { 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); + 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 @@ -170,7 +171,7 @@ pub trait TapSignerShared: Authentication { transmit(self.transport(), &sign_command).await; let mut unlucky_number_retries = 0; - while let Err(Error::CkTap(crate::apdu::CkTapError::UnluckyNumber)) = sign_response { + while let Err(Error::CkTap(crate::CkTapError::UnluckyNumber)) = sign_response { let sign_command = SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); @@ -260,7 +261,7 @@ pub trait TapSignerShared: Authentication { .map(|p| p ^ BIP32_HARDENED_MASK) .take(BIP84_HARDENED_SUBPATH.len()) .collect(); - let derive_response = self.derive(&path, cvc).await; + let derive_response = self.derive(path, cvc).await; if derive_response.is_err() { return Err(Error::PubkeyMismatch(input_index)); } @@ -294,9 +295,9 @@ pub trait TapSignerShared: Authentication { /// mobile wallet. /// /// Ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#tapsigner-performs-subkey-derivation - async fn derive(&mut self, path: &[u32], cvc: &str) -> Result { + 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 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); @@ -315,13 +316,14 @@ pub trait TapSignerShared: Authentication { let message = Message::from_digest(message_bytes_hash.to_byte_array()); let signature = Signature::from_compact(sig).map_err(Error::from)?; + dbg!(&derive_response.pubkey); 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)?, }; self.secp() - .verify_ecdsa(&message, &signature, &pubkey) + .verify_ecdsa(&message, &signature, &pubkey.inner) .map_err(Error::from)?; self.set_card_nonce(derive_response.card_nonce); @@ -347,7 +349,7 @@ pub trait TapSignerShared: Authentication { 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); + 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 @@ -373,7 +375,7 @@ impl TapSigner { 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(Error::from)?; Ok(TapSigner { transport, From 2d2eab4fc7c77a7e17fb775b8f8c1a2f5d658c8c Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 27 Aug 2025 16:00:35 -0500 Subject: [PATCH 09/15] fix(tapsigner): temp work-around, don't check sig on derive with path --- lib/src/tap_signer.rs | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 3bf5e73..6ca2c20 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -302,32 +302,35 @@ pub trait TapSignerShared: Authentication { 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 card_nonce = self.card_nonce(); - let sig = &derive_response.sig; - - let mut message_bytes: Vec = Vec::new(); - message_bytes.extend("OPENDIME".as_bytes()); - message_bytes.extend(card_nonce); - 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(sig).map_err(Error::from)?; - dbg!(&derive_response.pubkey); + let master_pubkey = + PublicKey::from_slice(&derive_response.master_pubkey).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)?, + None => master_pubkey, }; - self.secp() - .verify_ecdsa(&message, &signature, &pubkey.inner) - .map_err(Error::from)?; + // 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; - self.set_card_nonce(derive_response.card_nonce); + let mut message_bytes: Vec = Vec::new(); + message_bytes.extend("OPENDIME".as_bytes()); + message_bytes.extend(card_nonce); + 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(sig).map_err(Error::from)?; + + self.secp() + .verify_ecdsa(&message, &signature, &master_pubkey.inner) + .map_err(Error::from)?; + } Ok(pubkey) } From be70e6dd424bb0d5e7130842e1d2c1cf14166c6c Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Wed, 27 Aug 2025 16:01:22 -0500 Subject: [PATCH 10/15] feat(satscard): verify chaincode returned by derive --- lib/src/sats_card.rs | 67 +++++++++++++++++++++++++++++++------------- 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index 72a2708..fd0d32c 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,9 +1,3 @@ -use async_trait::async_trait; -use bitcoin::secp256k1::{All, Message, Secp256k1, ecdsa::Signature}; -use bitcoin::{Address, Network, PrivateKey, PublicKey}; -use bitcoin_hashes::sha256; -use std::sync::Arc; - use crate::Error; use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, NewCommand, @@ -12,8 +6,15 @@ use crate::apdu::{ use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; use crate::error::CkTapError; use crate::tap_signer::PsbtSignError; +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, @@ -112,40 +113,68 @@ impl SatsCard { /// when they created the current slot, see: [`Self::new_slot`]. /// /// Ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive - pub async fn derive(&mut self) -> Result<[u8; 32], Error> { - let nonce = crate::rand_nonce(); + 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 = transmit(self.transport(), &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 signature = Signature::from_compact(&derive_response.sig)?; - let master_pubkey = PublicKey::from_slice(&resp.master_pubkey).map_err(Error::from)?; + let master_pubkey = + PublicKey::from_slice(&derive_response.master_pubkey).map_err(Error::from)?; self.secp() .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; - // return card chain code so user can verify it matches user provided chain code - let card_chaincode = &resp.chain_code; + // response chain code, user should verify it matches the chain code they provided + let response_chaincode = &derive_response.chain_code; - // TODO verify a user's chain code (entropy) was used in picking the private key // 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(Error::InvalidChaincode); + } - Ok(*card_chaincode) + Ok(card_chaincode) } /// Unseal a slot. From 459c596c261120700c8642ec1e79654f5c4b0c0e Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Thu, 28 Aug 2025 23:27:27 -0500 Subject: [PATCH 11/15] refactor!, ffi!: break up errors into smaller enums, update and add all ffi bindings --- cktap-ffi/src/error.rs | 375 ++++++++++++++++++ cktap-ffi/src/lib.rs | 172 ++++---- cktap-ffi/src/sats_card.rs | 127 ++++++ cktap-ffi/src/sats_chip.rs | 79 ++++ cktap-ffi/src/tap_signer.rs | 124 ++++++ cktap-swift/Tests/CKTapTests/CKTapTests.swift | 54 ++- .../Tests/CKTapTests/CardEmulator.swift | 175 ++++---- cktap-swift/justfile | 5 +- cli/Cargo.toml | 1 + cli/src/main.rs | 49 ++- lib/examples/pcsc.rs | 10 +- lib/src/apdu.rs | 24 +- lib/src/commands.rs | 33 +- lib/src/emulator.rs | 15 +- lib/src/error.rs | 246 ++++++++---- lib/src/factory_root_key.rs | 8 +- lib/src/lib.rs | 20 +- lib/src/pcsc.rs | 11 +- lib/src/sats_card.rs | 88 ++-- lib/src/sats_chip.rs | 8 +- lib/src/tap_signer.rs | 119 ++---- 21 files changed, 1265 insertions(+), 478 deletions(-) create mode 100644 cktap-ffi/src/error.rs create mode 100644 cktap-ffi/src/sats_card.rs create mode 100644 cktap-ffi/src/sats_chip.rs create mode 100644 cktap-ffi/src/tap_signer.rs diff --git a/cktap-ffi/src/error.rs b/cktap-ffi/src/error.rs new file mode 100644 index 0000000..b883027 --- /dev/null +++ b/cktap-ffi/src/error.rs @@ -0,0 +1,375 @@ +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 c728d30..52ce148 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -1,24 +1,24 @@ +mod error; +mod sats_card; +mod sats_chip; +mod tap_signer; + uniffi::setup_scaffolding!(); +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::commands::{Authentication, Read}; +use rust_cktap::Network; +use rust_cktap::commands::{Certificate, Read}; +use rust_cktap::factory_root_key::FactoryRootKey; use std::fmt::Debug; +use std::str::FromStr; use std::sync::Arc; -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum CkTapError { - #[error("Core Error: {msg}")] - Core { msg: String }, - #[error("Transport Error: {msg}")] - Transport { msg: String }, -} - -impl From for CkTapError { - fn from(e: rust_cktap::Error) -> Self { - CkTapError::Core { msg: e.to_string() } - } -} - #[uniffi::export(callback_interface)] #[async_trait::async_trait] pub trait CkTransport: Send + Sync { @@ -29,86 +29,94 @@ 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::Error> { + async fn transmit_apdu( + &self, + command_apdu: Vec, + ) -> Result, rust_cktap::CkTapError> { self.0 .transmit_apdu(command_apdu) .await - .map_err(|e| rust_cktap::Error::Transport(e.to_string())) + .map_err(|e| rust_cktap::CkTapError::Transport(e.to_string())) } } -// TODO de-duplicate code between SatsCard, TapSigner and SatsChip - -#[derive(uniffi::Object)] -pub struct SatsCard(Mutex); +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct PrivateKey { + inner: rust_cktap::PrivateKey, +} #[uniffi::export] -impl SatsCard { - pub async fn ver(&self) -> String { - self.0.lock().await.ver().to_string() +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 async fn address(&self) -> Result { - self.0 - .lock() - .await - .address() - .await - .map_err(|e| CkTapError::Core { msg: e.to_string() }) + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes() } +} - pub async fn read(&self) -> Result, CkTapError> { - self.0 - .lock() - .await - .read(None) - .await - .map(|pk| pk.to_bytes().to_vec()) - .map_err(|e| CkTapError::Core { msg: e.to_string() }) +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct PublicKey { + inner: rust_cktap::PublicKey, +} + +#[uniffi::export] +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() } - // TODO implement the rest of the commands } -#[derive(uniffi::Object)] -pub struct TapSigner(Mutex); +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct ChainCode { + inner: rust_cktap::ChainCode, +} #[uniffi::export] -impl TapSigner { - pub async fn ver(&self) -> String { - self.0.lock().await.ver().to_string() +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 async fn read(&self, cvc: String) -> Result, CkTapError> { - self.0 - .lock() - .await - .read(Some(cvc)) - .await - .map(|pk| pk.to_bytes().to_vec()) - .map_err(|e| CkTapError::Core { msg: e.to_string() }) + pub fn to_bytes(&self) -> Vec { + self.inner.to_bytes().to_vec() } - // TODO implement the rest of the commands } -#[derive(uniffi::Object)] -pub struct SatsChip(Mutex); +#[derive(uniffi::Object, Clone, Eq, PartialEq)] +pub struct Psbt { + inner: rust_cktap::Psbt, +} #[uniffi::export] -impl SatsChip { - pub async fn ver(&self) -> String { - self.0.lock().await.ver().to_string() +impl Psbt { + #[uniffi::constructor] + pub fn from_base64(data: String) -> Result { + Ok(Self { + inner: rust_cktap::Psbt::from_str(&data)?, + }) } - pub async fn read(&self) -> Result, CkTapError> { - self.0 - .lock() - .await - .read(None) - .await - .map(|pk| pk.to_bytes().to_vec()) - .map_err(|e| CkTapError::Core { msg: e.to_string() }) + pub fn to_base64(&self) -> String { + self.inner.to_string() } - // TODO implement the rest of the commands } #[derive(uniffi::Enum)] @@ -119,11 +127,9 @@ pub enum CkTapCard { } #[uniffi::export] -pub async fn to_cktap(transport: Box) -> Result { +pub async fn to_cktap(transport: Box) -> Result { let wrapper = CkTransportWrapper(transport); - let cktap: rust_cktap::CkTapCard = rust_cktap::commands::to_cktap(Arc::new(wrapper)) - .await - .map_err(Into::::into)?; + let cktap: rust_cktap::CkTapCard = rust_cktap::commands::to_cktap(Arc::new(wrapper)).await?; match cktap { rust_cktap::CkTapCard::SatsCard(sc) => { @@ -138,7 +144,23 @@ pub async fn to_cktap(transport: Box) -> Result Vec { - rust_cktap::rand_nonce().to_vec() +// 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..0eba617 --- /dev/null +++ b/cktap-ffi/src/sats_card.rs @@ -0,0 +1,127 @@ +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::commands::{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..620351c --- /dev/null +++ b/cktap-ffi/src/sats_chip.rs @@ -0,0 +1,79 @@ +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::commands::{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..ba454ef --- /dev/null +++ b/cktap-ffi/src/tap_signer.rs @@ -0,0 +1,124 @@ +use crate::error::{CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignPsbtError}; +use crate::{ChainCode, Psbt, PublicKey, check_cert, read}; +use futures::lock::Mutex; +use rust_cktap::commands::{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 6b04630..323d2a4 100644 --- a/cktap-swift/Tests/CKTapTests/CKTapTests.swift +++ b/cktap-swift/Tests/CKTapTests/CKTapTests.swift @@ -1,35 +1,29 @@ -import XCTest import CKTap +import XCTest final class CKTapTests: XCTestCase { - func testHelloRandom() throws { - 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) - func testEmulatorTransport() async throws { - print("Test with card emulator transport") - let cardEmulator = CardEmulator() - - let card: CkTapCard - do { - card = try await toCktap(transport: cardEmulator) - } catch { - throw CkTapError.Core(msg: "Failed to create CkTap instance: \(error.localizedDescription)") - } - - switch card { - case .satsCard(let satsCard): - print("Handling SatsCard with version: \(await satsCard.ver())") - let address: String = try await satsCard.address() - print("SatsCard address: \(address)") - XCTAssertEqual(address, "bc1qdu05evh9kw0w482lfl2ktxm6ylp060km28z8fr") - case .tapSigner(let tapSigner): - print("Handling TapSigner with version: \(await tapSigner.ver())") - let public_key = try await tapSigner.read(cvc: "123456") - print("TapSigner public key: \(Array(public_key))") - case .satsChip(let satsChip): - print("Handling SatsChip with version: \(await satsChip.ver())") - } - } + 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 index adc8cb4..60e67f0 100644 --- a/cktap-swift/Tests/CKTapTests/CardEmulator.swift +++ b/cktap-swift/Tests/CKTapTests/CardEmulator.swift @@ -6,88 +6,95 @@ import Foundation // 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 - } + 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/justfile b/cktap-swift/justfile index 9095c97..88b8cc5 100644 --- a/cktap-swift/justfile +++ b/cktap-swift/justfile @@ -1,6 +1,9 @@ default: just --list +format: + swift-format format -i Tests/CKTapTests/*.swift + build: bash ./build-xcframework.sh @@ -10,5 +13,5 @@ clean: 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 1235260..a95276a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,17 +4,37 @@ use rpassword::read_password; use rust_cktap::commands::{Authentication, 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::rand; use rust_cktap::tap_signer::TapSignerShared; -use rust_cktap::{CkTapCard, Error, Psbt, commands::Certificate, rand_chaincode}; +use rust_cktap::{ + CkTapCard, CkTapError, Psbt, PsbtParseError, SignPsbtError, commands::Certificate, + rand_chaincode, +}; 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)] #[command(author, version = option_env ! ("CARGO_PKG_VERSION").unwrap_or("unknown"), about, @@ -125,7 +145,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?; @@ -134,8 +154,6 @@ 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(); @@ -148,7 +166,7 @@ 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 chain_code = Some(rand_chaincode()); let response = &sc.new_slot(slot, chain_code, &cvc()).await?; println!("{response}") } @@ -158,11 +176,8 @@ async fn main() -> Result<(), Error> { println!("privkey: {}, pubkey: {pubkey}", privkey.to_wif()) } SatsCardCommand::Sign { slot, psbt } => { - let psbt = Psbt::from_str(&psbt).map_err(|e| Error::Psbt(e.to_string()))?; - let signed_psbt = sc - .sign_psbt(slot, psbt, &cvc()) - .await - .map_err(|e| Error::SignPsbt(e.to_string()))?; + let psbt = Psbt::from_str(&psbt)?; + let signed_psbt = sc.sign_psbt(slot, psbt, &cvc()).await?; println!("signed_psbt: {signed_psbt}"); } SatsCardCommand::Derive => { @@ -186,7 +201,7 @@ async fn main() -> Result<(), Error> { 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); } @@ -224,7 +239,7 @@ async fn main() -> Result<(), Error> { 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); } @@ -293,16 +308,10 @@ where { // 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/examples/pcsc.rs b/lib/examples/pcsc.rs index f15ac06..296a174 100644 --- a/lib/examples/pcsc.rs +++ b/lib/examples/pcsc.rs @@ -1,10 +1,8 @@ extern crate core; -use rust_cktap::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; @@ -19,12 +17,10 @@ fn get_cvc() -> String { // Example using pcsc crate #[tokio::main] -async fn main() -> Result<(), Error> { +async fn main() -> Result<(), Box> { 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(); @@ -37,7 +33,7 @@ async fn main() -> Result<(), Error> { // only do this once per card! if ts.path.is_none() { - let chain_code = rand_chaincode(rng); + let chain_code = rand_chaincode(); ts.init(chain_code, &cvc).await.unwrap(); } @@ -66,7 +62,7 @@ async fn main() -> Result<(), Error> { // only do this once per card! if chip.path.is_none() { - let chain_code = rand_chaincode(rng); + let chain_code = rand_chaincode(); chip.init(chain_code, &get_cvc()).await.unwrap(); } diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index a02ece4..c3cd9e5 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -2,8 +2,9 @@ /// reader and a smart card. This file defines the Coinkite APDU and set of command/responses. pub mod tap_signer; -use crate::error::ErrorResponse; -use crate::{CkTapError, Error}; +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; @@ -33,7 +34,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, { @@ -41,8 +42,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()?; @@ -202,18 +203,18 @@ pub struct ReadResponse { impl ResponseApdu for ReadResponse {} impl ReadResponse { - pub fn signature(&self) -> Result { - Signature::from_compact(self.sig.as_slice()).map_err(Error::from) + 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(Error::from); + 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(Error::from) + PublicKey::from_slice(pubkey_bytes).map_err(ReadError::from) } } @@ -656,11 +657,12 @@ pub struct NewCommand { impl NewCommand { pub fn new( slot: Option, - chain_code: Option<[u8; 32]>, + 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, diff --git a/lib/src/commands.rs b/lib/src/commands.rs index 25d32b9..e1d9036 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -1,5 +1,5 @@ use crate::factory_root_key::FactoryRootKey; -use crate::{CkTapCard, CkTapError, Error, SatsCard, TapSigner}; +use crate::{CardError, CkTapCard, CkTapError, SatsCard, TapSigner}; use crate::{apdu::*, rand_nonce}; use bitcoin::key::{PublicKey, rand}; @@ -11,6 +11,7 @@ use bitcoin_hashes::sha256; use std::convert::TryFrom; +use crate::error::{CertsError, ReadError, StatusError}; use crate::sats_chip::SatsChip; use async_trait::async_trait; use std::fmt::Debug; @@ -62,11 +63,14 @@ pub trait Authentication { /// Trait for exchanging APDU data with cktap cards. #[async_trait] pub trait CkTransport: Sync + Send { - async fn transmit_apdu(&self, command_apdu: Vec) -> Result, Error>; + 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 +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, @@ -77,7 +81,7 @@ where Ok(response) } -pub async fn to_cktap(transport: Arc) -> Result { +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?; @@ -96,7 +100,7 @@ pub async fn to_cktap(transport: Arc) -> Result Err(Error::UnknownCardType("Card not recognized.".to_string())), + (_, _) => Err(StatusError::CkTap(CkTapError::UnknownCardType)), } } @@ -109,7 +113,7 @@ pub trait Read: Authentication { fn slot(&self) -> Option; - async fn read(&mut self, cvc: Option) -> Result { + async fn read(&mut self, cvc: Option) -> Result { let card_nonce = *self.card_nonce(); let app_nonce = rand_nonce(); @@ -128,7 +132,7 @@ pub trait Read: Authentication { // create the read command let (cmd, session_key) = if self.requires_auth() { - let cvc = cvc.ok_or(Error::CkTap(CkTapError::NeedsAuth))?; + 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), @@ -158,7 +162,7 @@ pub trait Read: Authentication { #[async_trait] pub trait Wait: Authentication { - async fn wait(&mut self, cvc: Option) -> Result, Error> { + 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) @@ -185,7 +189,7 @@ pub trait Wait: Authentication { #[async_trait] pub trait Certificate: Read { - async fn check_certificate(&mut self) -> Result { + async fn check_certificate(&mut self) -> Result { let app_nonce = rand_nonce(); let card_nonce = *self.card_nonce(); @@ -241,7 +245,7 @@ pub trait Certificate: Read { FactoryRootKey::try_from(pubkey.inner) } - async fn slot_pubkey(&mut self) -> Result, Error>; + async fn slot_pubkey(&mut self) -> Result, ReadError>; } #[cfg(feature = "emulator")] @@ -258,8 +262,7 @@ mod tests { #[tokio::test] async fn test_new_command() { - let rng = &mut rand::thread_rng(); - let chain_code = rand_chaincode(rng); + 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); @@ -311,19 +314,19 @@ mod tests { 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); + 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(Error::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); + 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(Error::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); + matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey); } }; drop(python); diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 77dd3d9..7d1847f 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -1,6 +1,7 @@ use crate::apdu::{AppletSelect, CommandApdu, StatusCommand}; use crate::commands::{CkTransport, to_cktap}; -use crate::{CkTapCard, Error}; +use crate::error::StatusError; +use crate::{CkTapCard, CkTapError}; use async_trait::async_trait; use std::io::{Read, Write}; use std::os::unix::net::UnixStream; @@ -10,9 +11,11 @@ use std::sync::Arc; pub const CVC: &str = "123456"; -pub async fn find_emulator(pipe_path: &Path) -> Result { +pub async fn find_emulator(pipe_path: &Path) -> Result { if !pipe_path.exists() { - return Err(Error::Transport("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 }; @@ -26,7 +29,7 @@ pub struct CardEmulator { #[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) { @@ -40,12 +43,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::Transport(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::Transport(e.to_string()))?; + .map_err(|e| CkTapError::Transport(e.to_string()))?; Ok(buffer.to_vec()) } } diff --git a/lib/src/error.rs b/lib/src/error.rs index 3cf2fa1..2826638 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,43 +1,24 @@ -use bitcoin::secp256k1; use serde::Deserialize; use std::fmt::Debug; -// Errors +/// Errors returned by the card, CBOR deserialization or value encoding, or the APDU transport. #[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("Card chain code doesn't match user provided chain code")] - InvalidChaincode, - #[error("UnknownCardType: {0}")] - UnknownCardType(String), - #[error("Transport: {0}")] +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("PSBT: {0}")] - Psbt(String), - #[error("Sign PSBT: {0}")] - SignPsbt(String), - #[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), + #[error("Unknown card type")] + UnknownCardType, } +/// Errors returned by the CkTap card. #[derive(Debug, Copy, Clone, PartialEq, Eq, thiserror::Error)] -pub enum CkTapError { +pub enum CardError { #[error("Rare or unlucky value used/occurred. Start again")] UnluckyNumber, #[error("Invalid/incorrect/incomplete arguments provided to command")] @@ -62,77 +43,196 @@ pub enum CkTapError { RateLimited, } -impl CkTapError { - pub fn error_from_code(code: u16) -> Option { +impl CardError { + 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), + 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 { - 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, + 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 Error +impl From> for CkTapError where T: Debug, { fn from(e: ciborium::de::Error) -> Self { - Error::CiborDe(e.to_string()) + CkTapError::CborDe(e.to_string()) } } -impl From for Error { +impl From for CkTapError { fn from(e: ciborium::value::Error) -> Self { - Error::CiborValue(e.to_string()) + CkTapError::CborValue(e.to_string()) } } -impl From for Error { - fn from(e: secp256k1::Error) -> Self { - Error::IncorrectSignature(e.to_string()) +#[cfg(feature = "pcsc")] +impl From for CkTapError { + fn from(e: pcsc::Error) -> Self { + CkTapError::Transport(e.to_string()) } } -impl From for Error { - fn from(e: bitcoin::key::FromSliceError) -> Self { - Error::CiborValue(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 Error { +impl From for StatusError { fn from(e: pcsc::Error) -> Self { - Error::Transport(e.to_string()) + StatusError::CkTap(CkTapError::Transport(e.to_string())) } } -#[derive(Deserialize, Clone, Debug, PartialEq, Eq)] -pub struct ErrorResponse { - pub error: String, - pub code: u16, +/// 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 index ab4e28b..c46bdde 100644 --- a/lib/src/factory_root_key.rs +++ b/lib/src/factory_root_key.rs @@ -1,4 +1,4 @@ -use crate::Error; +use crate::error::CertsError; use bitcoin::secp256k1::PublicKey; use bitcoin_hashes::hex::DisplayHex; use std::convert::TryFrom; @@ -18,13 +18,13 @@ pub enum FactoryRootKey { } impl TryFrom for FactoryRootKey { - type Error = Error; + type Error = CertsError; - fn try_from(pubkey: PublicKey) -> Result { + 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( + _ => Err(CertsError::InvalidRootCert( pubkey.serialize().to_lower_hex_string(), )), } diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 9589304..7b198c8 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,19 +1,24 @@ extern crate core; -pub use bitcoin::psbt::Psbt; -pub use bitcoin::secp256k1::rand; +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 commands::CkTransport; -pub use error::CkTapError; -pub use error::Error; +pub use error::{ + CardError, CertsError, ChangeError, CkTapError, DeriveError, DumpError, ReadError, + SignPsbtError, StatusError, UnsealError, +}; use bitcoin::key::rand::Rng as _; pub(crate) mod apdu; pub mod commands; pub mod error; -pub(crate) mod factory_root_key; +pub mod factory_root_key; pub mod sats_card; pub mod sats_chip; pub mod tap_signer; @@ -55,10 +60,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] { diff --git a/lib/src/pcsc.rs b/lib/src/pcsc.rs index d70bdaf..5202f35 100644 --- a/lib/src/pcsc.rs +++ b/lib/src/pcsc.rs @@ -1,13 +1,14 @@ extern crate core; -use crate::Error; +use crate::CkTapError; use crate::commands::to_cktap; +use crate::error::StatusError; 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 { +pub async fn find_first() -> Result { // Establish a PC/SC context. let ctx = Context::establish(Scope::User)?; @@ -20,7 +21,9 @@ pub async fn find_first() -> Result { Some(reader) => Ok(reader), None => { //println!("No readers are connected."); - Err(Error::Transport("No readers are connected.".to_string())) + Err(CkTapError::Transport( + "No readers are connected.".to_string(), + )) } }?; @@ -30,7 +33,7 @@ pub async fn find_first() -> Result { #[async_trait] impl CkTransport for Card { - async fn transmit_apdu(&self, command_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(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 fd0d32c..f3bdee3 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,11 +1,11 @@ -use crate::Error; +use crate::CkTapError; use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, NewCommand, NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, UnsealResponse, }; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; -use crate::error::CkTapError; -use crate::tap_signer::PsbtSignError; +use crate::error::{CardError, DeriveError, DumpError, ReadError, UnsealError}; +use crate::error::{SignPsbtError, StatusError}; use async_trait::async_trait; use bitcoin::bip32::{ChainCode, DerivationPath, Fingerprint, Xpub}; use bitcoin::secp256k1; @@ -67,13 +67,13 @@ impl SatsCard { pub fn from_status( transport: Arc, status_response: StatusResponse, - ) -> Result { + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(Error::from)?; + 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, @@ -92,9 +92,9 @@ 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 = transmit(self.transport(), &new_command).await?; @@ -113,7 +113,7 @@ impl SatsCard { /// when they created the current slot, see: [`Self::new_slot`]. /// /// Ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive - pub async fn derive(&mut self) -> Result { + pub async fn derive(&mut self) -> Result { let app_nonce = crate::rand_nonce(); let card_nonce = *self.card_nonce(); @@ -133,8 +133,7 @@ impl SatsCard { let signature = Signature::from_compact(&derive_response.sig)?; - let master_pubkey = - PublicKey::from_slice(&derive_response.master_pubkey).map_err(Error::from)?; + let master_pubkey = PublicKey::from_slice(&derive_response.master_pubkey)?; self.secp() .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; @@ -171,7 +170,9 @@ impl SatsCard { && p2wpkh_address[p2wpkh_address.len().saturating_sub(12)..] == self_addr[self_addr.len().saturating_sub(12)..]) { - return Err(Error::InvalidChaincode); + return Err(DeriveError::InvalidChainCode( + "Unable to derive card address".to_string(), + )); } Ok(card_chaincode) @@ -180,7 +181,11 @@ impl SatsCard { /// 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), Error> { + 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 = transmit(self.transport(), &unseal_command).await?; @@ -197,7 +202,7 @@ impl SatsCard { .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).map_err(Error::from)?; + let pubkey = PublicKey::from_slice(&unseal_response.pubkey)?; // TODO should verify user provided chain code was used similar to above `derive`. Ok((privkey, pubkey)) } @@ -206,12 +211,12 @@ impl SatsCard { /// /// With the CVC the private and public slot address keys are returned. /// Without the CVC, the private key is not included. - /// If an unsealed or unused slot number is given an error is returned. + /// 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), Error> { + ) -> 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) @@ -228,21 +233,21 @@ impl SatsCard { // throw errors if let Some(tampered) = dump_response.tampered { if tampered { - return Err(Error::SlotTampered(slot)); + return Err(DumpError::SlotTampered(slot)); } } else if let Some(sealed) = dump_response.sealed { if sealed { - return Err(Error::SlotSealed(slot)); + return Err(DumpError::SlotSealed(slot)); } } else if let Some(used) = dump_response.used { if !used { - return Err(Error::SlotUnused(slot)); + 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).map_err(Error::from)?; + let pubkey = PublicKey::from_slice(&pubkey_bytes)?; // TODO use chaincode and master public key to verify pubkey or return error @@ -265,10 +270,10 @@ impl SatsCard { 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?; - let slot_pubkey = bitcoin::CompressedPublicKey(slot_pubkey.inner); + let slot_pubkey = CompressedPublicKey(slot_pubkey.inner); let address = Address::p2wpkh(&slot_pubkey, network); Ok(address.to_string()) } @@ -279,7 +284,7 @@ impl SatsCard { digest: [u8; 32], slot: u8, cvc: &str, - ) -> Result { + ) -> Result { let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name()); // Use the same session key to encrypt the new CVC @@ -297,11 +302,11 @@ impl SatsCard { let sign_command = SignCommand::for_satscard(slot, xdigest, epubkey, xcvc.clone()); - let mut sign_response: Result = + let mut sign_response: Result = transmit(self.transport(), &sign_command).await; let mut unlucky_number_retries = 0; - while let Err(Error::CkTap(CkTapError::UnluckyNumber)) = sign_response { + 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; @@ -326,13 +331,13 @@ impl SatsCard { slot: u8, mut psbt: bitcoin::Psbt, cvc: &str, - ) -> Result { + ) -> Result { use bitcoin::{ secp256k1::ecdsa, sighash::{EcdsaSighashType, SighashCache}, }; - type Error = PsbtSignError; + type Error = SignPsbtError; let unsigned_tx = psbt.unsigned_tx.clone(); let mut sighash_cache = SighashCache::new(&unsigned_tx); @@ -408,7 +413,7 @@ impl Read for SatsCard { #[async_trait] impl Certificate for SatsCard { - async fn slot_pubkey(&mut self) -> Result, Error> { + async fn slot_pubkey(&mut self) -> Result, ReadError> { let pubkey = self.read(None).await?; Ok(Some(pubkey)) } @@ -429,29 +434,14 @@ impl core::fmt::Debug for SatsCard { } } -/// Slot details for an in-use or unsealed slot. -/// -/// Without the CVC, only the public details for each slot, is included. -/// except unsealed slots where the address in full is also provided. -#[derive(Clone, Debug)] -pub struct SlotDetails { - /// Slot number. - pub slot: usize, - /// Private key for spending (for addr) or None if slot not unsealed or no CVC given. - pub privkey: Option, - /// Public key for receiving bitcoin. - pub pubkey: PublicKey, - /// Full payment address (not censored). - pub addr: String, -} - #[cfg(feature = "emulator")] #[cfg(test)] mod test { + use crate::CkTapCard; use crate::commands::Certificate; use crate::emulator::find_emulator; use crate::emulator::test::{CardTypeOption, EcardSubprocess}; - use crate::{CkTapCard, Error}; + use crate::error::DumpError; use bdk_wallet::chain::{BlockId, ConfirmationBlockTime}; use bdk_wallet::template::P2Wpkh; use bdk_wallet::test_utils::{insert_anchor, insert_checkpoint, insert_tx, new_tx}; @@ -554,10 +544,10 @@ mod test { 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(Error::SlotSealed(slot)) if slot == 0)); + 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(Error::SlotSealed(slot)) if slot == 0)); + 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 @@ -574,10 +564,10 @@ mod test { 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(Error::SlotUnused(slot)) if slot == 1)); + 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(Error::SlotUnused(slot)) if slot == 1)); + 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 693fea7..ed66a1e 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -3,9 +3,9 @@ use bitcoin::PublicKey; use bitcoin::secp256k1::{All, Secp256k1}; use std::sync::Arc; -use crate::Error; use crate::apdu::StatusResponse; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; +use crate::error::{ReadError, StatusError}; use crate::tap_signer::TapSignerShared; /// - SATSCHIP model: this product variant is a TAPSIGNER in all respects, @@ -67,9 +67,9 @@ impl SatsChip { pub fn try_from_status( transport: Arc, status_response: StatusResponse, - ) -> Result { + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(Error::from)?; + let pubkey = PublicKey::from_slice(pubkey).map_err(StatusError::from)?; Ok(SatsChip { transport, @@ -102,7 +102,7 @@ impl Read for SatsChip { #[async_trait] impl Certificate for SatsChip { - async fn slot_pubkey(&mut self) -> Result, Error> { + async fn slot_pubkey(&mut self) -> Result, ReadError> { Ok(None) } } diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 6ca2c20..aea416d 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -4,13 +4,14 @@ use crate::apdu::{ tap_signer::{BackupCommand, BackupResponse, ChangeCommand, ChangeResponse}, }; use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; -use crate::{BIP32_HARDENED_MASK, Error}; +use crate::error::{ChangeError, DeriveError, ReadError, SignPsbtError, StatusError}; +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 log::error; use std::sync::Arc; const BIP84_PATH_LEN: usize = 5; @@ -34,60 +35,6 @@ 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), - - #[error("Signing slot is not unsealed: {0}")] - SlotNotUnsealed(u8), -} - impl Authentication for TapSigner { fn secp(&self) -> &Secp256k1 { &self.secp @@ -126,7 +73,7 @@ impl Authentication for TapSigner { #[async_trait] pub trait TapSignerShared: Authentication { /// Initialize the tap signer or sats chip, can only be done once - async fn init(&mut self, chain_code: [u8; 32], cvc: &str) -> Result<(), TapSignerError> { + 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?; @@ -135,7 +82,7 @@ pub trait TapSignerShared: Authentication { } /// Get the status of the tap signer or sats chip, including the current card nonce - async fn status(&mut self) -> Result { + 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); @@ -148,7 +95,7 @@ pub trait TapSignerShared: Authentication { digest: [u8; 32], sub_path: Vec, cvc: &str, - ) -> Result { + ) -> Result { let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name()); // Use the same session key to encrypt the new CVC @@ -167,11 +114,11 @@ pub trait TapSignerShared: Authentication { let sign_command = SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); - let mut sign_response: Result = + let mut sign_response: Result = transmit(self.transport(), &sign_command).await; let mut unlucky_number_retries = 0; - while let Err(Error::CkTap(crate::CkTapError::UnluckyNumber)) = sign_response { + while let Err(CkTapError::Card(crate::CardError::UnluckyNumber)) = sign_response { let sign_command = SignCommand::for_tapsigner(sub_path.clone(), xdigest, epubkey, xcvc.clone()); @@ -195,14 +142,12 @@ pub trait TapSignerShared: Authentication { &mut self, mut psbt: bitcoin::Psbt, cvc: &str, - ) -> Result { + ) -> 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); @@ -211,14 +156,14 @@ pub trait TapSignerShared: Authentication { let witness_utxo = input .witness_utxo .as_ref() - .ok_or(Error::MissingUtxo(input_index))?; + .ok_or(SignPsbtError::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)); + return Err(SignPsbtError::InvalidScript(input_index)); } // get the public key from the PSBT @@ -226,24 +171,24 @@ pub trait TapSignerShared: Authentication { let (psbt_pubkey, (_fingerprint, path)) = key_pairs .iter() .next() - .ok_or(Error::MissingPubkey(input_index))?; + .ok_or(SignPsbtError::MissingPubkey(input_index))?; let path = path.to_u32_vec(); if path.len() != BIP84_PATH_LEN { - return Err(Error::InvalidPath(input_index)); + return Err(SignPsbtError::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(Error::InvalidPath(input_index)); + return Err(SignPsbtError::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()))?; + .map_err(|e| SignPsbtError::SighashError(e.to_string()))?; // the digest is the sighash let digest: &[u8; 32] = sighash.as_ref(); @@ -263,7 +208,7 @@ pub trait TapSignerShared: Authentication { .collect(); let derive_response = self.derive(path, cvc).await; if derive_response.is_err() { - return Err(Error::PubkeyMismatch(input_index)); + return Err(SignPsbtError::PubkeyMismatch(input_index)); } // update signature to the new one we just derived @@ -272,13 +217,13 @@ pub trait TapSignerShared: Authentication { // 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()))?; + .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); @@ -295,7 +240,7 @@ pub trait TapSignerShared: Authentication { /// mobile wallet. /// /// Ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#tapsigner-performs-subkey-derivation - async fn derive(&mut self, path: Vec, cvc: &str) -> Result { + 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(); @@ -304,10 +249,9 @@ pub trait TapSignerShared: Authentication { 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).map_err(Error::from)?; + let master_pubkey = PublicKey::from_slice(&derive_response.master_pubkey)?; let pubkey = match &derive_response.pubkey { - Some(pubkey) => PublicKey::from_slice(pubkey).map_err(Error::from)?, + Some(pubkey) => PublicKey::from_slice(pubkey)?, None => master_pubkey, }; @@ -325,27 +269,26 @@ pub trait TapSignerShared: Authentication { 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 signature = Signature::from_compact(sig)?; self.secp() - .verify_ecdsa(&message, &signature, &master_pubkey.inner) - .map_err(Error::from)?; + .verify_ecdsa(&message, &signature, &master_pubkey.inner)?; } Ok(pubkey) } /// Change the CVC used for card authentication to a new user provided one - async fn change(&mut self, new_cvc: &str, cvc: &str) -> Result<(), TapSignerError> { + async fn change(&mut self, new_cvc: &str, cvc: &str) -> Result<(), ChangeError> { if new_cvc.len() < 6 { - return Err(CvcChangeError::TooShort(new_cvc.len()).into()); + return Err(ChangeError::TooShort(new_cvc.len())); } if new_cvc.len() > 32 { - return Err(CvcChangeError::TooLong(new_cvc.len()).into()); + return Err(ChangeError::TooLong(new_cvc.len())); } if new_cvc == cvc { - return Err(CvcChangeError::SameAsOld.into()); + return Err(ChangeError::SameAsOld); } // Create session key and encrypt current CVC @@ -376,9 +319,9 @@ impl TapSigner { pub fn try_from_status( transport: Arc, status_response: StatusResponse, - ) -> Result { + ) -> Result { let pubkey = status_response.pubkey.as_slice(); - let pubkey = PublicKey::from_slice(pubkey).map_err(Error::from)?; + let pubkey = PublicKey::from_slice(pubkey)?; Ok(TapSigner { transport, @@ -395,7 +338,7 @@ 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, TapSignerError> { + 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); @@ -422,7 +365,7 @@ impl Read for TapSigner { #[async_trait] impl Certificate for TapSigner { - async fn slot_pubkey(&mut self) -> Result, Error> { + async fn slot_pubkey(&mut self) -> Result, ReadError> { Ok(None) } } From 759d65e2cd6f8e60716906cf11ac4c2a59c9c157 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 29 Aug 2025 20:22:21 -0500 Subject: [PATCH 12/15] chore: remove unneeded example, use cli instead Fixed doc error with ref URLs. --- lib/Cargo.toml | 4 -- lib/examples/pcsc.rs | 117 ------------------------------------------ lib/src/sats_card.rs | 2 +- lib/src/tap_signer.rs | 2 +- 4 files changed, 2 insertions(+), 123 deletions(-) delete mode 100644 lib/examples/pcsc.rs diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 9b10f3a..aa99e4c 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -41,7 +41,3 @@ 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 296a174..0000000 --- a/lib/examples/pcsc.rs +++ /dev/null @@ -1,117 +0,0 @@ -extern crate core; - -use rust_cktap::commands::{Certificate, Wait}; -use rust_cktap::{CkTapCard, pcsc, rand_chaincode}; - -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<(), Box> { - let card = pcsc::find_first().await?; - dbg!(&card); - - 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(); - ts.init(chain_code, &cvc).await.unwrap(); - } - - // 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(); - chip.init(chain_code, &get_cvc()).await.unwrap(); - } - - // 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/sats_card.rs b/lib/src/sats_card.rs index f3bdee3..518d120 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -112,7 +112,7 @@ impl SatsCard { /// 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: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive + /// Ref: pub async fn derive(&mut self) -> Result { let app_nonce = crate::rand_nonce(); let card_nonce = *self.card_nonce(); diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index aea416d..7f73bae 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -239,7 +239,7 @@ pub trait TapSignerShared: Authentication { /// is captured and stored long term. This is effectively calculating the XPUB to be used on the /// mobile wallet. /// - /// Ref: https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#tapsigner-performs-subkey-derivation + /// 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::>(); From f0eb0f1156e2055546444b254c4b7329e1267074 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Fri, 29 Aug 2025 21:06:49 -0500 Subject: [PATCH 13/15] chore: add copyright notice to source files --- cktap-ffi/cktap-uniffi-bindgen.rs | 3 +++ cktap-ffi/src/error.rs | 3 +++ cktap-ffi/src/lib.rs | 3 +++ cktap-ffi/src/sats_card.rs | 3 +++ cktap-ffi/src/sats_chip.rs | 3 +++ cktap-ffi/src/tap_signer.rs | 3 +++ cktap-swift/Tests/CKTapTests/CKTapTests.swift | 3 +++ cktap-swift/Tests/CKTapTests/CardEmulator.swift | 3 +++ cktap-swift/build-xcframework.sh | 5 +++++ cli/src/main.rs | 3 +++ lib/src/apdu.rs | 3 +++ lib/src/apdu/tap_signer.rs | 3 +++ lib/src/commands.rs | 3 +++ lib/src/emulator.rs | 3 +++ lib/src/error.rs | 3 +++ lib/src/factory_root_key.rs | 3 +++ lib/src/lib.rs | 3 +++ lib/src/pcsc.rs | 3 +++ lib/src/sats_card.rs | 3 +++ lib/src/sats_chip.rs | 3 +++ lib/src/tap_signer.rs | 3 +++ 21 files changed, 65 insertions(+) diff --git a/cktap-ffi/cktap-uniffi-bindgen.rs b/cktap-ffi/cktap-uniffi-bindgen.rs index f6cff6c..227f443 100644 --- a/cktap-ffi/cktap-uniffi-bindgen.rs +++ b/cktap-ffi/cktap-uniffi-bindgen.rs @@ -1,3 +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/error.rs b/cktap-ffi/src/error.rs index b883027..69a7e17 100644 --- a/cktap-ffi/src/error.rs +++ b/cktap-ffi/src/error.rs @@ -1,3 +1,6 @@ +// 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)] diff --git a/cktap-ffi/src/lib.rs b/cktap-ffi/src/lib.rs index 52ce148..0b064d7 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + mod error; mod sats_card; mod sats_chip; diff --git a/cktap-ffi/src/sats_card.rs b/cktap-ffi/src/sats_card.rs index 0eba617..54f9be0 100644 --- a/cktap-ffi/src/sats_card.rs +++ b/cktap-ffi/src/sats_card.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use crate::error::{ CertsError, CkTapError, DeriveError, DumpError, ReadError, SignPsbtError, UnsealError, }; diff --git a/cktap-ffi/src/sats_chip.rs b/cktap-ffi/src/sats_chip.rs index 620351c..d6c09e4 100644 --- a/cktap-ffi/src/sats_chip.rs +++ b/cktap-ffi/src/sats_chip.rs @@ -1,3 +1,6 @@ +// 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}; diff --git a/cktap-ffi/src/tap_signer.rs b/cktap-ffi/src/tap_signer.rs index ba454ef..ec10889 100644 --- a/cktap-ffi/src/tap_signer.rs +++ b/cktap-ffi/src/tap_signer.rs @@ -1,3 +1,6 @@ +// 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; diff --git a/cktap-swift/Tests/CKTapTests/CKTapTests.swift b/cktap-swift/Tests/CKTapTests/CKTapTests.swift index 323d2a4..649a515 100644 --- a/cktap-swift/Tests/CKTapTests/CKTapTests.swift +++ b/cktap-swift/Tests/CKTapTests/CKTapTests.swift @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + import CKTap import XCTest diff --git a/cktap-swift/Tests/CKTapTests/CardEmulator.swift b/cktap-swift/Tests/CKTapTests/CardEmulator.swift index 60e67f0..de4eed6 100644 --- a/cktap-swift/Tests/CKTapTests/CardEmulator.swift +++ b/cktap-swift/Tests/CKTapTests/CardEmulator.swift @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + import CKTap import Foundation diff --git a/cktap-swift/build-xcframework.sh b/cktap-swift/build-xcframework.sh index ab1259d..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" diff --git a/cli/src/main.rs b/cli/src/main.rs index a95276a..a0e14cb 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,6 @@ +// 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; diff --git a/lib/src/apdu.rs b/lib/src/apdu.rs index c3cd9e5..f743b69 100644 --- a/lib/src/apdu.rs +++ b/lib/src/apdu.rs @@ -1,3 +1,6 @@ +// 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; diff --git a/lib/src/apdu/tap_signer.rs b/lib/src/apdu/tap_signer.rs index f9014ba..aecbd05 100644 --- a/lib/src/apdu/tap_signer.rs +++ b/lib/src/apdu/tap_signer.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use super::{CommandApdu, ResponseApdu}; use core::fmt::{self, Formatter}; diff --git a/lib/src/commands.rs b/lib/src/commands.rs index e1d9036..7735848 100644 --- a/lib/src/commands.rs +++ b/lib/src/commands.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use crate::factory_root_key::FactoryRootKey; use crate::{CardError, CkTapCard, CkTapError, SatsCard, TapSigner}; use crate::{apdu::*, rand_nonce}; diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 7d1847f..2a981a5 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use crate::apdu::{AppletSelect, CommandApdu, StatusCommand}; use crate::commands::{CkTransport, to_cktap}; use crate::error::StatusError; diff --git a/lib/src/error.rs b/lib/src/error.rs index 2826638..0ef405e 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use serde::Deserialize; use std::fmt::Debug; diff --git a/lib/src/factory_root_key.rs b/lib/src/factory_root_key.rs index c46bdde..bd27dfb 100644 --- a/lib/src/factory_root_key.rs +++ b/lib/src/factory_root_key.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use crate::error::CertsError; use bitcoin::secp256k1::PublicKey; use bitcoin_hashes::hex::DisplayHex; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 7b198c8..5ec6069 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + extern crate core; pub use bitcoin::bip32::ChainCode; diff --git a/lib/src/pcsc.rs b/lib/src/pcsc.rs index 5202f35..4680285 100644 --- a/lib/src/pcsc.rs +++ b/lib/src/pcsc.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + extern crate core; use crate::CkTapError; diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index 518d120..eaf4b1a 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -1,3 +1,6 @@ +// 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, NewCommand, diff --git a/lib/src/sats_chip.rs b/lib/src/sats_chip.rs index ed66a1e..0158196 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use async_trait::async_trait; use bitcoin::PublicKey; use bitcoin::secp256k1::{All, Secp256k1}; diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 7f73bae..da9f17c 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -1,3 +1,6 @@ +// Copyright (c) 2025 rust-cktap contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, NewCommand, NewResponse, SignCommand, SignResponse, StatusCommand, StatusResponse, From 2d408d44376d4e2eb4f4d07fe3519d5293f83914 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sat, 30 Aug 2025 09:22:43 -0500 Subject: [PATCH 14/15] refactor!: rename commands module to shared --- cktap-ffi/src/lib.rs | 4 ++-- cktap-ffi/src/sats_card.rs | 2 +- cktap-ffi/src/sats_chip.rs | 2 +- cktap-ffi/src/tap_signer.rs | 2 +- cli/src/main.rs | 5 ++--- lib/src/emulator.rs | 2 +- lib/src/lib.rs | 4 ++-- lib/src/pcsc.rs | 2 +- lib/src/sats_card.rs | 4 ++-- lib/src/sats_chip.rs | 2 +- lib/src/{commands.rs => shared.rs} | 19 ------------------- lib/src/tap_signer.rs | 2 +- 12 files changed, 15 insertions(+), 35 deletions(-) rename lib/src/{commands.rs => shared.rs} (93%) diff --git a/cktap-ffi/src/lib.rs b/cktap-ffi/src/lib.rs index 0b064d7..0d85ec7 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -16,8 +16,8 @@ use crate::sats_chip::SatsChip; use crate::tap_signer::TapSigner; use futures::lock::Mutex; use rust_cktap::Network; -use rust_cktap::commands::{Certificate, Read}; use rust_cktap::factory_root_key::FactoryRootKey; +use rust_cktap::shared::{Certificate, Read}; use std::fmt::Debug; use std::str::FromStr; use std::sync::Arc; @@ -132,7 +132,7 @@ pub enum CkTapCard { #[uniffi::export] pub async fn to_cktap(transport: Box) -> Result { let wrapper = CkTransportWrapper(transport); - let cktap: rust_cktap::CkTapCard = rust_cktap::commands::to_cktap(Arc::new(wrapper)).await?; + let cktap: rust_cktap::CkTapCard = rust_cktap::shared::to_cktap(Arc::new(wrapper)).await?; match cktap { rust_cktap::CkTapCard::SatsCard(sc) => { diff --git a/cktap-ffi/src/sats_card.rs b/cktap-ffi/src/sats_card.rs index 54f9be0..f1b9e25 100644 --- a/cktap-ffi/src/sats_card.rs +++ b/cktap-ffi/src/sats_card.rs @@ -6,7 +6,7 @@ use crate::error::{ }; use crate::{ChainCode, PrivateKey, Psbt, PublicKey, check_cert, read}; use futures::lock::Mutex; -use rust_cktap::commands::{Authentication, Wait}; +use rust_cktap::shared::{Authentication, Wait}; use std::sync::Arc; #[derive(uniffi::Object)] diff --git a/cktap-ffi/src/sats_chip.rs b/cktap-ffi/src/sats_chip.rs index d6c09e4..ac107e6 100644 --- a/cktap-ffi/src/sats_chip.rs +++ b/cktap-ffi/src/sats_chip.rs @@ -5,7 +5,7 @@ use crate::error::{CertsError, ChangeError, CkTapError, DeriveError, ReadError, use crate::tap_signer::{change, derive, init, sign_psbt}; use crate::{ChainCode, Psbt, PublicKey, check_cert, read}; use futures::lock::Mutex; -use rust_cktap::commands::{Authentication, Wait}; +use rust_cktap::shared::{Authentication, Wait}; use std::sync::Arc; #[derive(uniffi::Object)] diff --git a/cktap-ffi/src/tap_signer.rs b/cktap-ffi/src/tap_signer.rs index ec10889..e46a4e3 100644 --- a/cktap-ffi/src/tap_signer.rs +++ b/cktap-ffi/src/tap_signer.rs @@ -4,7 +4,7 @@ use crate::error::{CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignPsbtError}; use crate::{ChainCode, Psbt, PublicKey, check_cert, read}; use futures::lock::Mutex; -use rust_cktap::commands::{Authentication, Wait}; +use rust_cktap::shared::{Authentication, Wait}; use rust_cktap::tap_signer::TapSignerShared; use std::sync::Arc; diff --git a/cli/src/main.rs b/cli/src/main.rs index a0e14cb..a265649 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -4,16 +4,15 @@ /// CLI for rust-cktap use clap::{Parser, Subcommand}; use rpassword::read_password; -use rust_cktap::commands::{Authentication, 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::shared::{Authentication, Read, Wait}; use rust_cktap::tap_signer::TapSignerShared; use rust_cktap::{ - CkTapCard, CkTapError, Psbt, PsbtParseError, SignPsbtError, commands::Certificate, - rand_chaincode, + CkTapCard, CkTapError, Psbt, PsbtParseError, SignPsbtError, rand_chaincode, shared::Certificate, }; use std::io; use std::io::Write; diff --git a/lib/src/emulator.rs b/lib/src/emulator.rs index 2a981a5..fd70dec 100644 --- a/lib/src/emulator.rs +++ b/lib/src/emulator.rs @@ -2,8 +2,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use crate::apdu::{AppletSelect, CommandApdu, StatusCommand}; -use crate::commands::{CkTransport, to_cktap}; use crate::error::StatusError; +use crate::shared::{CkTransport, to_cktap}; use crate::{CkTapCard, CkTapError}; use async_trait::async_trait; use std::io::{Read, Write}; diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 5ec6069..54a8385 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -10,20 +10,20 @@ pub use bitcoin::secp256k1::{Error as SecpError, rand}; pub use bitcoin::{Network, PrivateKey, PublicKey}; pub use bitcoin_hashes::sha256::Hash; -pub use commands::CkTransport; 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 commands; pub mod error; pub mod factory_root_key; pub mod sats_card; pub mod sats_chip; +pub mod shared; pub mod tap_signer; #[cfg(feature = "emulator")] diff --git a/lib/src/pcsc.rs b/lib/src/pcsc.rs index 4680285..876d872 100644 --- a/lib/src/pcsc.rs +++ b/lib/src/pcsc.rs @@ -4,8 +4,8 @@ extern crate core; use crate::CkTapError; -use crate::commands::to_cktap; 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}; diff --git a/lib/src/sats_card.rs b/lib/src/sats_card.rs index eaf4b1a..c9318c5 100644 --- a/lib/src/sats_card.rs +++ b/lib/src/sats_card.rs @@ -6,9 +6,9 @@ use crate::apdu::{ CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse, NewCommand, NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand, UnsealResponse, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; 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; @@ -441,10 +441,10 @@ impl core::fmt::Debug for SatsCard { #[cfg(test)] mod test { use crate::CkTapCard; - use crate::commands::Certificate; 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}; diff --git a/lib/src/sats_chip.rs b/lib/src/sats_chip.rs index 0158196..8f40f87 100644 --- a/lib/src/sats_chip.rs +++ b/lib/src/sats_chip.rs @@ -7,8 +7,8 @@ use bitcoin::secp256k1::{All, Secp256k1}; use std::sync::Arc; use crate::apdu::StatusResponse; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait}; 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, diff --git a/lib/src/commands.rs b/lib/src/shared.rs similarity index 93% rename from lib/src/commands.rs rename to lib/src/shared.rs index 7735848..b438386 100644 --- a/lib/src/commands.rs +++ b/lib/src/shared.rs @@ -335,23 +335,4 @@ mod tests { 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/tap_signer.rs b/lib/src/tap_signer.rs index da9f17c..e153101 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -6,8 +6,8 @@ use crate::apdu::{ SignResponse, StatusCommand, StatusResponse, tap_signer::{BackupCommand, BackupResponse, ChangeCommand, ChangeResponse}, }; -use crate::commands::{Authentication, Certificate, CkTransport, Read, Wait, transmit}; 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; From 4694844977f5074452b4a7c10556e6e8e9aa2e42 Mon Sep 17 00:00:00 2001 From: Steve Myers Date: Sat, 30 Aug 2025 09:31:15 -0500 Subject: [PATCH 15/15] refactor!: move factory_root_keys mod code to shared Also update README completed commands. --- README.md | 54 +++++++++++++++++++++++------------ cktap-ffi/src/lib.rs | 2 +- lib/src/factory_root_key.rs | 57 ------------------------------------- lib/src/lib.rs | 1 - lib/src/shared.rs | 54 +++++++++++++++++++++++++++++++++-- 5 files changed, 88 insertions(+), 80 deletions(-) delete mode 100644 lib/src/factory_root_key.rs 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/src/lib.rs b/cktap-ffi/src/lib.rs index 0d85ec7..e4d699a 100644 --- a/cktap-ffi/src/lib.rs +++ b/cktap-ffi/src/lib.rs @@ -16,7 +16,7 @@ use crate::sats_chip::SatsChip; use crate::tap_signer::TapSigner; use futures::lock::Mutex; use rust_cktap::Network; -use rust_cktap::factory_root_key::FactoryRootKey; +use rust_cktap::shared::FactoryRootKey; use rust_cktap::shared::{Certificate, Read}; use std::fmt::Debug; use std::str::FromStr; diff --git a/lib/src/factory_root_key.rs b/lib/src/factory_root_key.rs deleted file mode 100644 index bd27dfb..0000000 --- a/lib/src/factory_root_key.rs +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2025 rust-cktap contributors -// SPDX-License-Identifier: MIT OR Apache-2.0 - -use crate::error::CertsError; -use bitcoin::secp256k1::PublicKey; -use bitcoin_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 = CertsError; - - 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(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:?})") - } - } - } -} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 54a8385..a340db9 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -20,7 +20,6 @@ use bitcoin::key::rand::Rng as _; pub(crate) mod apdu; pub mod error; -pub mod factory_root_key; pub mod sats_card; pub mod sats_chip; pub mod shared; diff --git a/lib/src/shared.rs b/lib/src/shared.rs index b438386..8e3cd64 100644 --- a/lib/src/shared.rs +++ b/lib/src/shared.rs @@ -1,7 +1,6 @@ // Copyright (c) 2025 rust-cktap contributors // SPDX-License-Identifier: MIT OR Apache-2.0 -use crate::factory_root_key::FactoryRootKey; use crate::{CardError, CkTapCard, CkTapError, SatsCard, TapSigner}; use crate::{apdu::*, rand_nonce}; @@ -12,14 +11,63 @@ use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature}; use bitcoin::secp256k1::{All, Message, Secp256k1}; use bitcoin_hashes::sha256; -use std::convert::TryFrom; - 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;