diff --git a/cktap-ffi/src/error.rs b/cktap-ffi/src/error.rs index 25df813..17c422f 100644 --- a/cktap-ffi/src/error.rs +++ b/cktap-ffi/src/error.rs @@ -336,6 +336,26 @@ impl From for SignPsbtError { } } +/// Errors returned by the `sign_digest` FFI entry point. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] +pub enum SignDigestError { + #[error(transparent)] + CkTap { + #[from] + err: CkTapError, + }, + #[error("digest must be exactly 32 bytes, was {len} bytes")] + InvalidDigestLength { len: u32 }, + #[error("failed to derive recovery id for signature: {msg}")] + RecoveryId { msg: String }, +} + +impl From for SignDigestError { + fn from(value: rust_cktap::CkTapError) -> Self { + SignDigestError::CkTap { err: value.into() } + } +} + /// Errors returned by the `change` command. #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error, uniffi::Error)] pub enum ChangeError { diff --git a/cktap-ffi/src/tap_signer.rs b/cktap-ffi/src/tap_signer.rs index 901cdc4..392e977 100644 --- a/cktap-ffi/src/tap_signer.rs +++ b/cktap-ffi/src/tap_signer.rs @@ -2,10 +2,15 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use crate::error::{ - CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignPsbtError, XpubError, + CertsError, ChangeError, CkTapError, DeriveError, ReadError, SignDigestError, SignPsbtError, + XpubError, }; use crate::{check_cert, read}; use futures::lock::Mutex; +use rust_cktap::bitcoin::secp256k1::{ + Message, Secp256k1, + ecdsa::{RecoverableSignature, RecoveryId}, +}; use rust_cktap::shared::{Authentication, Nfc, Wait}; use rust_cktap::tap_signer::TapSignerShared; use rust_cktap::{Psbt, rand_chaincode}; @@ -14,6 +19,20 @@ use std::str::FromStr; #[derive(uniffi::Object)] pub struct TapSigner(pub Mutex); +/// Result of signing an arbitrary 32-byte digest with a TAPSIGNER. +/// +/// `signature` is the 64-byte compact ECDSA signature, `pubkey` is the 33-byte compressed +/// public key the card used to sign, and `rec_id` is the recovery id (0..=3) that lets a +/// verifier recover `pubkey` from `signature` and the digest. Together these are sufficient +/// to construct a BIP-137 "Bitcoin Signed Message" header byte or to verify the signature +/// locally without an extra round-trip to the card. +#[derive(uniffi::Record, Debug, Clone)] +pub struct SignedDigest { + pub signature: Vec, + pub pubkey: Vec, + pub rec_id: u8, +} + #[derive(uniffi::Record, Debug, Clone)] pub struct TapSignerStatus { pub proto: u32, @@ -68,6 +87,21 @@ impl TapSigner { Ok(psbt) } + /// Sign an arbitrary 32-byte `digest` with the key derived at `sub_path`. + /// + /// Use this for BIP-137 "Bitcoin Signed Message", proof-of-key challenges, or any + /// other flow where the digest is computed off-card. Errors with + /// [`SignDigestError::InvalidDigestLength`] if `digest.len() != 32`. + pub async fn sign_digest( + &self, + digest: Vec, + sub_path: Vec, + cvc: String, + ) -> Result { + let mut card = self.0.lock().await; + sign_digest(&mut *card, digest, sub_path, cvc).await + } + 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?; @@ -115,6 +149,58 @@ pub async fn sign_psbt( Ok(psbt.to_string()) } +/// Sign a 32-byte digest and return the signature alongside the pubkey and recovery id. +pub async fn sign_digest( + card: &mut (impl TapSignerShared + Send + Sync), + digest: Vec, + sub_path: Vec, + cvc: String, +) -> Result { + let digest: [u8; 32] = + digest + .as_slice() + .try_into() + .map_err(|_| SignDigestError::InvalidDigestLength { + len: digest.len() as u32, + })?; + + let sign_response = card.sign(digest, sub_path, &cvc).await?; + + let rec_id = derive_recovery_id(&digest, &sign_response.sig, &sign_response.pubkey)?; + + Ok(SignedDigest { + signature: sign_response.sig.to_vec(), + pubkey: sign_response.pubkey.to_vec(), + rec_id, + }) +} + +/// Try each of the four recovery ids until the one that recovers `expected_pubkey` is found. +fn derive_recovery_id( + digest: &[u8; 32], + signature: &[u8; 64], + expected_pubkey: &[u8; 33], +) -> Result { + let secp = Secp256k1::verification_only(); + let message = Message::from_digest(*digest); + for i in 0..4i32 { + let rec_id = RecoveryId::from_i32(i) + .map_err(|e| SignDigestError::RecoveryId { msg: e.to_string() })?; + let rec_sig = match RecoverableSignature::from_compact(signature, rec_id) { + Ok(s) => s, + Err(_) => continue, + }; + if let Ok(recovered) = secp.recover_ecdsa(&message, &rec_sig) { + if recovered.serialize() == *expected_pubkey { + return Ok(i as u8); + } + } + } + Err(SignDigestError::RecoveryId { + msg: "no recovery id recovered the expected pubkey".to_string(), + }) +} + /// Derive the pubkey at the given derivation path, return as hex serialized string pub async fn derive( card: &mut (impl TapSignerShared + Send + Sync), @@ -133,3 +219,67 @@ pub async fn change( card.change(&new_cvc, &cvc).await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use rust_cktap::bitcoin::secp256k1::{PublicKey, SecretKey}; + + fn sign_with(secret_bytes: [u8; 32], digest: [u8; 32]) -> ([u8; 64], [u8; 33], u8) { + let secp = Secp256k1::new(); + let secret = SecretKey::from_slice(&secret_bytes).expect("valid secret"); + let pubkey = PublicKey::from_secret_key(&secp, &secret).serialize(); + let message = Message::from_digest(digest); + let rec_sig = secp.sign_ecdsa_recoverable(&message, &secret); + let (rec_id, sig) = rec_sig.serialize_compact(); + (sig, pubkey, rec_id.to_i32() as u8) + } + + #[test] + fn derive_recovery_id_matches_signer_rec_id() { + let digest = [0x11u8; 32]; + let secret = [ + 0xc0, 0x01, 0xd0, 0x0d, 0xfe, 0xed, 0xfa, 0xce, 0xba, 0xad, 0xbe, 0xef, 0xde, 0xad, + 0xbe, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x0f, 0x1e, 0x2d, 0x3c, + 0x4b, 0x5a, 0x69, 0x78, + ]; + let (sig, pubkey, expected_rec_id) = sign_with(secret, digest); + + let rec_id = derive_recovery_id(&digest, &sig, &pubkey).expect("should recover the pubkey"); + + assert_eq!(rec_id, expected_rec_id); + } + + #[test] + fn derive_recovery_id_covers_both_compressed_ids() { + // Compressed-pubkey ECDSA signatures only ever recover to ids 0 or 1; exercise + // both branches by sweeping seeds until we have observed each. + let mut saw = [false; 2]; + for seed in 0u8..16 { + let digest = [seed; 32]; + let mut secret = [0u8; 32]; + secret[31] = seed.wrapping_add(1); + secret[0] = 0xaa; + let (sig, pubkey, expected) = sign_with(secret, digest); + let got = derive_recovery_id(&digest, &sig, &pubkey).expect("recover"); + assert_eq!(got, expected); + if (expected as usize) < 2 { + saw[expected as usize] = true; + } + } + assert!(saw[0] && saw[1], "expected to observe both rec_id 0 and 1"); + } + + #[test] + fn derive_recovery_id_errors_when_pubkey_does_not_match() { + let digest = [0x22u8; 32]; + let secret_a = [0x01u8; 32]; + let secret_b = [0x02u8; 32]; + let (sig, _pubkey_a, _) = sign_with(secret_a, digest); + let (_, pubkey_b, _) = sign_with(secret_b, digest); + + let err = derive_recovery_id(&digest, &sig, &pubkey_b) + .expect_err("should not recover an unrelated pubkey"); + assert!(matches!(err, SignDigestError::RecoveryId { .. })); + } +} diff --git a/lib/src/lib.rs b/lib/src/lib.rs index b6667d4..59286b9 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -3,6 +3,7 @@ extern crate core; +pub use bitcoin; pub use bitcoin::Network; pub use bitcoin::bip32::ChainCode; pub use bitcoin::key::FromSliceError; diff --git a/lib/src/tap_signer.rs b/lib/src/tap_signer.rs index 2ad50d9..071caa5 100644 --- a/lib/src/tap_signer.rs +++ b/lib/src/tap_signer.rs @@ -359,6 +359,21 @@ impl TapSigner { }) } + /// Sign an arbitrary 32-byte digest with the key derived at `sub_path`. + /// + /// Thin public wrapper around the [`TapSignerShared::sign`] trait method. Useful for + /// building higher-level flows such as BIP-137 "Bitcoin Signed Message" or generic + /// proof-of-key challenges, which compute the digest off-card and ask the TAPSIGNER to + /// sign it. + pub async fn sign_digest( + &mut self, + digest: [u8; 32], + sub_path: Vec, + cvc: &str, + ) -> Result { + ::sign(self, digest, sub_path, cvc).await + } + /// 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, ChangeError> { let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, "backup");