Skip to main content

rust_cktap/
shared.rs

1// Copyright (c) 2025 rust-cktap contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{CardError, CkTapCard, CkTapError, SatsCard, TapSigner};
5use crate::{apdu::*, rand_nonce};
6
7use bitcoin::key::{PublicKey, rand};
8use bitcoin::secp256k1;
9use bitcoin::secp256k1::ecdh::SharedSecret;
10use bitcoin::secp256k1::ecdsa::{RecoverableSignature, RecoveryId, Signature};
11use bitcoin::secp256k1::{All, Message, Secp256k1};
12use bitcoin_hashes::sha256;
13
14use crate::error::{CertsError, ReadError, StatusError};
15use crate::sats_chip::SatsChip;
16use async_trait::async_trait;
17use bitcoin_hashes::hex::DisplayHex;
18use std::convert::TryFrom;
19use std::fmt;
20use std::fmt::Debug;
21use std::sync::Arc;
22
23/// Published Coinkite factory root keys.
24const PUB_FACTORY_ROOT_KEY: &str =
25    "03028a0e89e70d0ec0d932053a89ab1da7d9182bdc6d2f03e706ee99517d05d9e1";
26/// Obsolete dev value, but keeping for a little while longer.
27const DEV_FACTORY_ROOT_KEY: &str =
28    "027722ef208e681bac05f1b4b3cc478d6bf353ac9a09ff0c843430138f65c27bab";
29
30pub enum FactoryRootKey {
31    Pub(secp256k1::PublicKey),
32    Dev(secp256k1::PublicKey),
33}
34
35impl TryFrom<secp256k1::PublicKey> for FactoryRootKey {
36    type Error = CertsError;
37
38    fn try_from(pubkey: secp256k1::PublicKey) -> Result<Self, CertsError> {
39        match pubkey.serialize().to_lower_hex_string().as_str() {
40            PUB_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Pub(pubkey)),
41            DEV_FACTORY_ROOT_KEY => Ok(FactoryRootKey::Dev(pubkey)),
42            _ => Err(CertsError::InvalidRootCert(
43                pubkey.serialize().to_lower_hex_string(),
44            )),
45        }
46    }
47}
48
49impl FactoryRootKey {
50    pub fn name(&self) -> String {
51        match &self {
52            FactoryRootKey::Pub(_) => "Root Factory Certificate".to_string(),
53            FactoryRootKey::Dev(_) => "Root Factory Certificate (TESTING ONLY)".to_string(),
54        }
55    }
56}
57
58impl Debug for FactoryRootKey {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match &self {
61            FactoryRootKey::Pub(pk) => {
62                write!(f, "FactoryRootKey::Pub({pk:?})")
63            }
64            FactoryRootKey::Dev(pk) => {
65                write!(f, "FactoryRootKey::Dev({pk:?})")
66            }
67        }
68    }
69}
70
71/// Helper functions for authenticated commands.
72pub trait Authentication {
73    fn secp(&self) -> &Secp256k1<All>;
74    fn ver(&self) -> &str;
75    fn pubkey(&self) -> &PublicKey;
76    fn card_nonce(&self) -> &[u8; 16];
77    fn set_card_nonce(&mut self, new_nonce: [u8; 16]);
78    fn auth_delay(&self) -> &Option<usize>;
79    fn set_auth_delay(&mut self, auth_delay: Option<usize>);
80    fn transport(&self) -> Arc<dyn CkTransport>;
81
82    /// Calculate ephemeral key pair and XOR'd CVC.
83    /// ref: ["Authenticating Commands with CVC"](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#authenticating-commands-with-cvc)
84    fn calc_ekeys_xcvc(
85        &self,
86        cvc: &str,
87        command: &str,
88    ) -> (secp256k1::SecretKey, secp256k1::PublicKey, Vec<u8>) {
89        let secp = Self::secp(self);
90        let pubkey = Self::pubkey(self);
91        let nonce = Self::card_nonce(self);
92        let cvc_bytes = cvc.as_bytes();
93        let card_nonce_command = [nonce, command.as_bytes()].concat();
94        let (ephemeral_private_key, ephemeral_public_key) =
95            secp.generate_keypair(&mut rand::thread_rng());
96
97        let session_key = SharedSecret::new(&pubkey.inner, &ephemeral_private_key);
98        let md = sha256::Hash::hash(card_nonce_command.as_slice());
99        let md: &[u8; 32] = md.as_ref();
100
101        let mask: Vec<u8> = session_key
102            .as_ref()
103            .iter()
104            .zip(md)
105            .map(|(x, y)| x ^ y)
106            .take(cvc_bytes.len())
107            .collect();
108
109        let xcvc = cvc_bytes.iter().zip(mask).map(|(x, y)| x ^ y).collect();
110        (ephemeral_private_key, ephemeral_public_key, xcvc)
111    }
112}
113
114/// Trait for exchanging APDU data with cktap cards.
115#[async_trait]
116pub trait CkTransport: Sync + Send {
117    async fn transmit_apdu(&self, command_apdu: Vec<u8>) -> Result<Vec<u8>, CkTapError>;
118}
119
120/// Helper function for serialize APDU commands, transmit them and deserialize responses.
121pub(crate) async fn transmit<C, R>(
122    transport: Arc<dyn CkTransport>,
123    command: &C,
124) -> Result<R, CkTapError>
125where
126    C: CommandApdu + serde::Serialize + Debug + Send + Sync,
127    R: ResponseApdu + serde::de::DeserializeOwned + Debug + Send,
128{
129    let command_apdu = command.apdu_bytes();
130    let rapdu = transport.transmit_apdu(command_apdu).await?;
131    let response = R::from_cbor(rapdu.to_vec())?;
132    Ok(response)
133}
134
135pub async fn to_cktap(transport: Arc<dyn CkTransport>) -> Result<CkTapCard, StatusError> {
136    // Get status from card
137    let cmd = AppletSelect::default();
138    let status_response: StatusResponse = transmit(transport.clone(), &cmd).await?;
139
140    // Return correct card variant using status
141    match (status_response.tapsigner, status_response.satschip) {
142        (Some(true), None) => {
143            let tap_signer = TapSigner::try_from_status(transport, status_response)?;
144            Ok(CkTapCard::TapSigner(tap_signer))
145        }
146        (Some(true), Some(true)) => {
147            let sats_chip = SatsChip::try_from_status(transport, status_response)?;
148            Ok(CkTapCard::SatsChip(sats_chip))
149        }
150        (None, None) => {
151            let sats_card = SatsCard::from_status(transport, status_response)?;
152            Ok(CkTapCard::SatsCard(sats_card))
153        }
154        (_, _) => Err(StatusError::CkTap(CkTapError::UnknownCardType)),
155    }
156}
157
158/// Command to read and authenticate the cktap card's public key.
159/// SatsCard does not require a CVC but TapSigner and SatsChip do.
160/// ref: [read](https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#read)
161#[async_trait]
162pub trait Read: Authentication {
163    fn requires_auth(&self) -> bool;
164
165    fn slot(&self) -> Option<u8>;
166
167    async fn read(&mut self, cvc: Option<String>) -> Result<PublicKey, ReadError> {
168        let card_nonce = *self.card_nonce();
169        let app_nonce = rand_nonce();
170
171        // create the message digest
172        let mut message_bytes: Vec<u8> = Vec::new();
173        message_bytes.extend("OPENDIME".as_bytes());
174        message_bytes.extend(card_nonce);
175        message_bytes.extend(app_nonce);
176        if let Some(slot) = self.slot() {
177            message_bytes.push(slot);
178        } else {
179            message_bytes.push(0);
180        }
181        let hash = sha256::Hash::hash(message_bytes.as_slice());
182        let message_digest = Message::from_digest(hash.to_byte_array());
183
184        // create the read command
185        let (cmd, session_key) = if self.requires_auth() {
186            let cvc = cvc.ok_or(CkTapError::Card(CardError::NeedsAuth))?;
187            let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, ReadCommand::name());
188            (
189                ReadCommand::authenticated(app_nonce, epubkey, xcvc),
190                Some(SharedSecret::new(&self.pubkey().inner, &eprivkey)),
191            )
192        } else {
193            (ReadCommand::unauthenticated(app_nonce), None)
194        };
195
196        // send the read command to the card
197        let read_response: ReadResponse = transmit(self.transport(), &cmd).await?;
198
199        // convert the response to a PublicKey
200        let pubkey = read_response.pubkey(session_key)?;
201        self.secp().verify_ecdsa(
202            &message_digest,
203            &read_response.signature()?, // or add 'from' trait: Signature::from(response.sig: )
204            &pubkey.inner,
205        )?;
206
207        // update the card nonce
208        self.set_card_nonce(read_response.card_nonce);
209
210        Ok(pubkey)
211    }
212}
213
214#[async_trait]
215pub trait Wait: Authentication {
216    async fn wait(&mut self, cvc: Option<String>) -> Result<Option<usize>, CkTapError> {
217        let epubkey_xcvc = cvc.map(|cvc| {
218            let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, WaitCommand::name());
219            (epubkey, xcvc)
220        });
221
222        let (epubkey, xcvc) = epubkey_xcvc
223            .map(|(epubkey, xcvc)| (Some(epubkey), Some(xcvc)))
224            .unwrap_or((None, None));
225
226        let wait_command = WaitCommand::new(epubkey, xcvc);
227
228        let wait_response: WaitResponse = transmit(self.transport(), &wait_command).await?;
229        // TODO throw error if success == false
230        if wait_response.auth_delay > 0 {
231            let auth_delay = Some(wait_response.auth_delay);
232            self.set_auth_delay(auth_delay);
233            Ok(auth_delay)
234        } else {
235            self.set_auth_delay(None);
236            Ok(None)
237        }
238    }
239}
240
241#[async_trait]
242pub trait Certificate: Read {
243    async fn check_certificate(&mut self) -> Result<FactoryRootKey, CertsError> {
244        let app_nonce = rand_nonce();
245        let card_nonce = *self.card_nonce();
246
247        let certs_cmd = CertsCommand::default();
248        let certs_response: CertsResponse = transmit(self.transport(), &certs_cmd).await?;
249
250        let check_cmd = CheckCommand::new(app_nonce);
251        let check_response: CheckResponse = transmit(self.transport(), &check_cmd).await?;
252        self.set_card_nonce(check_response.card_nonce);
253
254        let slot_pubkey = self.slot_pubkey().await?;
255
256        // create message digest with slot pubkey
257        let mut message_bytes: Vec<u8> = Vec::new();
258        message_bytes.extend("OPENDIME".as_bytes());
259        message_bytes.extend(card_nonce);
260        message_bytes.extend(app_nonce);
261        if let Some(pubkey) = slot_pubkey {
262            if self.ver() != "0.9.0" {
263                let slot_pubkey_bytes = pubkey.inner.serialize();
264                message_bytes.extend(slot_pubkey_bytes);
265            }
266        }
267        let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice());
268        let message = Message::from_digest(message_bytes_hash.to_byte_array());
269
270        // verify the signature with the message and pubkey
271        let signature = Signature::from_compact(check_response.auth_sig.as_slice())
272            .expect("Failed to construct ECDSA signature from check response");
273        self.secp()
274            .verify_ecdsa(&message, &signature, &self.pubkey().inner)?;
275
276        let mut pubkey = *self.pubkey();
277        for sig in &certs_response.cert_chain() {
278            // BIP-137: https://github.com/bitcoin/bips/blob/master/bip-0137.mediawiki
279            let subtract_by = match sig[0] {
280                27..=30 => 27, // P2PKH uncompressed
281                31..=34 => 31, // P2PKH compressed
282                35..=38 => 35, // Segwit P2SH
283                39..=42 => 39, // Segwit Bech32
284                _ => panic!("Unrecognized BIP-137 address"),
285            };
286            let rec_id = RecoveryId::from_i32((sig[0] as i32) - subtract_by)?;
287            let (_, sig) = sig.split_at(1);
288            let result = RecoverableSignature::from_compact(sig, rec_id);
289            let rec_sig = result?;
290            let pubkey_hash = sha256::Hash::hash(&pubkey.inner.serialize());
291            let md = Message::from_digest(pubkey_hash.to_byte_array());
292            let result = self.secp().recover_ecdsa(&md, &rec_sig);
293            pubkey = PublicKey::new(result?);
294        }
295
296        FactoryRootKey::try_from(pubkey.inner)
297    }
298
299    async fn slot_pubkey(&mut self) -> Result<Option<PublicKey>, ReadError>;
300}
301
302#[async_trait]
303pub trait Nfc: Authentication {
304    async fn nfc(&mut self) -> Result<String, CkTapError> {
305        let nfc_cmd = NfcCommand::default();
306        let nfc_response: NfcResponse = transmit(self.transport(), &nfc_cmd).await?;
307        Ok(nfc_response.url)
308    }
309}
310
311#[cfg(feature = "emulator")]
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use std::path::Path;
316
317    use crate::emulator::CVC;
318    use crate::emulator::find_emulator;
319    use crate::emulator::test::{CardTypeOption, EcardSubprocess};
320    use crate::rand_chaincode;
321    use crate::tap_signer::TapSignerShared;
322
323    #[tokio::test]
324    async fn test_new_command() {
325        let chain_code = rand_chaincode();
326        for card_type in CardTypeOption::values() {
327            let pipe_path = format!("/tmp/test-new-command-pipe{card_type}");
328            let pipe_path = Path::new(&pipe_path);
329            let python = EcardSubprocess::new(pipe_path, &card_type).unwrap();
330            let emulator = find_emulator(pipe_path).await.unwrap();
331            match emulator {
332                CkTapCard::SatsCard(mut sc) => {
333                    assert_eq!(card_type, CardTypeOption::SatsCard);
334                    let current_slot = sc.slots.0;
335                    let response = sc.unseal(current_slot, CVC).await;
336                    assert!(response.is_ok());
337                    let response = sc.new_slot(current_slot + 1, Some(chain_code), CVC).await;
338                    assert!(response.is_ok());
339                    assert_eq!(sc.slots.0, current_slot + 1);
340                    // test with no new chain_code
341                    let current_slot = sc.slots.0;
342                    let response = sc.unseal(current_slot, CVC).await;
343                    assert!(response.is_ok());
344                    let response = sc.new_slot(current_slot + 1, None, CVC).await;
345                    assert!(response.is_ok());
346                    assert_eq!(sc.slots.0, current_slot + 1);
347                }
348                CkTapCard::TapSigner(mut ts) => {
349                    assert_eq!(card_type, CardTypeOption::TapSigner);
350                    let response = ts.init(chain_code, CVC).await;
351                    assert!(response.is_ok())
352                }
353                CkTapCard::SatsChip(mut sc) => {
354                    assert_eq!(card_type, CardTypeOption::SatsChip);
355                    let response = sc.init(chain_code, CVC).await;
356                    assert!(response.is_ok())
357                }
358            };
359            drop(python);
360        }
361    }
362
363    #[tokio::test]
364    async fn test_cert_command() {
365        for card_type in CardTypeOption::values() {
366            let emulator_root_pubkey =
367                "0312d005ca1501b1603c3b00412eefe27c6b20a74c29377263b357b3aff12de6fa".to_string();
368            let pipe_path = format!("/tmp/test-cert-command-pipe{card_type}");
369            let pipe_path = Path::new(&pipe_path);
370            let python = EcardSubprocess::new(pipe_path, &card_type).unwrap();
371            let emulator = find_emulator(pipe_path).await.unwrap();
372            match emulator {
373                CkTapCard::SatsCard(mut sc) => {
374                    assert_eq!(card_type, CardTypeOption::SatsCard);
375                    let response = sc.check_certificate().await;
376                    assert!(response.is_err());
377                    matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey);
378                }
379                CkTapCard::TapSigner(mut ts) => {
380                    assert_eq!(card_type, CardTypeOption::TapSigner);
381                    let response = ts.check_certificate().await;
382                    assert!(response.is_err());
383                    matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey);
384                }
385                CkTapCard::SatsChip(mut sc) => {
386                    assert_eq!(card_type, CardTypeOption::SatsChip);
387                    let response = sc.check_certificate().await;
388                    assert!(response.is_err());
389                    matches!(response, Err(CertsError::InvalidRootCert(pubkey)) if pubkey == emulator_root_pubkey);
390                }
391            };
392            drop(python);
393        }
394    }
395
396    #[tokio::test]
397    async fn test_nfc_command() {
398        for card_type in CardTypeOption::values() {
399            let pipe_path = format!("/tmp/test-nfc-command-pipe{card_type}");
400            let pipe_path = Path::new(&pipe_path);
401            let python = EcardSubprocess::new(pipe_path, &card_type).unwrap();
402            let emulator = find_emulator(pipe_path).await.unwrap();
403            match emulator {
404                CkTapCard::SatsCard(mut sc) => {
405                    assert_eq!(card_type, CardTypeOption::SatsCard);
406                    let response = sc.nfc().await;
407                    assert!(response.is_ok());
408                    assert!(
409                        response
410                            .unwrap()
411                            .starts_with("https://getsatscard.com/start")
412                    )
413                }
414                CkTapCard::TapSigner(mut ts) => {
415                    assert_eq!(card_type, CardTypeOption::TapSigner);
416                    let response = ts.nfc().await;
417                    assert!(response.is_ok());
418                    assert!(response.unwrap().starts_with("https://tapsigner.com/start"))
419                }
420                CkTapCard::SatsChip(mut sc) => {
421                    assert_eq!(card_type, CardTypeOption::SatsChip);
422                    let response = sc.nfc().await;
423                    assert!(response.is_ok());
424                    assert!(response.unwrap().starts_with("https://satschip.com/start"))
425                }
426            };
427            drop(python);
428        }
429    }
430}