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