Skip to main content

rust_cktap/
sats_card.rs

1// Copyright (c) 2025 rust-cktap contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::CkTapError;
5use crate::apdu::{
6    AppletSelect, CommandApdu as _, DeriveCommand, DeriveResponse, DumpCommand, DumpResponse,
7    NewCommand, NewResponse, SignCommand, SignResponse, StatusResponse, UnsealCommand,
8    UnsealResponse,
9};
10use crate::error::{CardError, DeriveError, DumpError, ReadError, UnsealError};
11use crate::error::{SignPsbtError, StatusError};
12use crate::shared::{
13    Authentication, Certificate, CkTransport, Nfc, Read, Wait, card_pubkey_to_ident, transmit,
14};
15use async_trait::async_trait;
16use bitcoin::bip32::{ChainCode, DerivationPath, Fingerprint, Xpub};
17use bitcoin::secp256k1;
18use bitcoin::secp256k1::{All, Message, Secp256k1, ecdsa::Signature};
19use bitcoin::{Address, CompressedPublicKey, Network, NetworkKind, PrivateKey, PublicKey};
20use bitcoin_hashes::hex::DisplayHex;
21use bitcoin_hashes::sha256;
22use std::str::FromStr;
23use std::sync::Arc;
24
25pub struct SatsCard {
26    pub transport: Arc<dyn CkTransport>,
27    pub secp: Secp256k1<All>,
28    pub proto: u32,
29    pub ver: String,
30    pub birth: u32,
31    pub slots: (u8, u8),
32    pub addr: Option<String>,
33    pub pubkey: PublicKey,
34    pub card_nonce: [u8; 16],
35    pub auth_delay: Option<u8>,
36}
37
38impl Authentication for SatsCard {
39    fn secp(&self) -> &Secp256k1<All> {
40        &self.secp
41    }
42
43    fn ver(&self) -> &str {
44        &self.ver
45    }
46
47    fn pubkey(&self) -> &PublicKey {
48        &self.pubkey
49    }
50
51    fn card_nonce(&self) -> &[u8; 16] {
52        &self.card_nonce
53    }
54
55    fn set_card_nonce(&mut self, new_nonce: [u8; 16]) {
56        self.card_nonce = new_nonce;
57    }
58
59    fn auth_delay(&self) -> Option<u8> {
60        self.auth_delay
61    }
62
63    fn set_auth_delay(&mut self, auth_delay: Option<u8>) {
64        self.auth_delay = auth_delay;
65    }
66
67    fn transport(&self) -> Arc<dyn CkTransport> {
68        self.transport.clone()
69    }
70}
71
72impl SatsCard {
73    pub fn card_ident(&self) -> String {
74        card_pubkey_to_ident(&self.pubkey)
75    }
76
77    pub fn from_status(
78        transport: Arc<dyn CkTransport>,
79        status_response: StatusResponse,
80    ) -> Result<Self, StatusError> {
81        let pubkey = status_response.pubkey.as_slice();
82        let pubkey = PublicKey::from_slice(pubkey)?;
83
84        let slots = status_response
85            .slots
86            .ok_or_else(|| CkTapError::CborValue("Missing slots".to_string()))?;
87
88        Ok(Self {
89            transport,
90            secp: Secp256k1::new(),
91            proto: status_response.proto,
92            ver: status_response.ver,
93            birth: status_response.birth,
94            pubkey,
95            card_nonce: status_response.card_nonce,
96            auth_delay: status_response.auth_delay,
97            slots,
98            addr: status_response.addr,
99        })
100    }
101
102    pub async fn new_slot(
103        &mut self,
104        slot: u8,
105        chain_code: Option<ChainCode>,
106        cvc: &str,
107    ) -> Result<u8, CkTapError> {
108        let (_, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, NewCommand::name());
109        let new_command = NewCommand::new(Some(slot), chain_code, epubkey, xcvc);
110        let new_response: NewResponse = transmit(self.transport(), &new_command).await?;
111        self.card_nonce = new_response.card_nonce;
112
113        // Update card fields that changed by re-getting status
114        let status_cmd = AppletSelect::default();
115        let status_response: StatusResponse = transmit(self.transport(), &status_cmd).await?;
116        self.slots = status_response.slots.unwrap();
117        self.addr = status_response.addr;
118        self.card_nonce = status_response.card_nonce;
119
120        Ok(new_response.slot)
121    }
122
123    /// Verify the master public key and chain code used to derive the payment public key.
124    ///
125    /// The derivation is fixed as m/0, meaning the first non-hardened derived key. SATSCARD
126    /// always uses that derived key as the payment address.
127    ///
128    /// The user should verify the returned card chain code matches the chain code they provided
129    /// when they created the current slot, see: [`Self::new_slot`].
130    ///
131    /// Ref: <https://github.com/coinkite/coinkite-tap-proto/blob/master/docs/protocol.md#derive>
132    pub async fn derive(&mut self) -> Result<ChainCode, DeriveError> {
133        let app_nonce = crate::rand_nonce();
134        let card_nonce = *self.card_nonce();
135
136        let cmd = DeriveCommand::for_satscard(app_nonce);
137        let derive_response: DeriveResponse = transmit(self.transport(), &cmd).await?;
138        self.set_card_nonce(derive_response.card_nonce);
139
140        // Verify signature
141        let mut message_bytes: Vec<u8> = Vec::new();
142        message_bytes.extend("OPENDIME".as_bytes());
143        message_bytes.extend(card_nonce);
144        message_bytes.extend(app_nonce);
145        message_bytes.extend(derive_response.chain_code);
146
147        let message_bytes_hash = sha256::Hash::hash(message_bytes.as_slice());
148        let message = Message::from_digest(message_bytes_hash.to_byte_array());
149
150        let signature = Signature::from_compact(&derive_response.sig)?;
151
152        let master_pubkey = PublicKey::from_slice(&derive_response.master_pubkey)?;
153        self.secp()
154            .verify_ecdsa(&message, &signature, &master_pubkey.inner)?;
155
156        // response chain code, user should verify it matches the chain code they provided
157        let response_chaincode = &derive_response.chain_code;
158
159        // SATSCARD returns the card's chain code and master public key
160        // With the master pubkey and the chain code, reconstructs a BIP-32 XPUB (extended public key)
161        // and verify it matches the payment address the card shares (i.e., the slot's pubkey)
162        // it must equal the BIP-32 derived key (m/0) constructed from that XPUB.
163        let card_chaincode = ChainCode::from(*response_chaincode);
164        let xpub = Xpub {
165            network: NetworkKind::Main,
166            depth: 0,
167            parent_fingerprint: Fingerprint::default(), // No parent for master key
168            child_number: bitcoin::bip32::ChildNumber::from_normal_idx(0).unwrap(),
169            public_key: master_pubkey.inner,
170            chain_code: card_chaincode,
171        };
172
173        // Derive the child key at path m/0
174        let derivation_path = DerivationPath::from_str("m/0").unwrap();
175        let derived_xpub = xpub.derive_pub(self.secp(), &derivation_path).unwrap();
176
177        // Create a compressed public key for address generation
178        let bitcoin_pubkey = CompressedPublicKey(derived_xpub.public_key);
179
180        // P2WPKH (Native SegWit address starting with 'bc1')
181        let p2wpkh_address = Address::p2wpkh(&bitcoin_pubkey, Network::Bitcoin).to_string();
182        let self_addr = self.addr.clone().unwrap();
183
184        // if derived address and card address don't match then chaincode in response was not used
185        if !(p2wpkh_address[..12] == self_addr[..12]
186            && p2wpkh_address[p2wpkh_address.len().saturating_sub(12)..]
187                == self_addr[self_addr.len().saturating_sub(12)..])
188        {
189            return Err(DeriveError::InvalidChainCode(
190                "Unable to derive card address".to_string(),
191            ));
192        }
193
194        Ok(card_chaincode)
195    }
196
197    /// Unseal a slot.
198    ///
199    /// Returns the private and corresponding public keys for that slot.
200    pub async fn unseal(
201        &mut self,
202        slot: u8,
203        cvc: &str,
204    ) -> Result<(PrivateKey, PublicKey), UnsealError> {
205        let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, UnsealCommand::name());
206        let unseal_command = UnsealCommand::new(slot, epubkey, xcvc);
207        let unseal_response: UnsealResponse = transmit(self.transport(), &unseal_command).await?;
208        self.set_card_nonce(unseal_response.card_nonce);
209
210        // Update card fields that changed by re-getting status
211        let status_cmd = AppletSelect::default();
212        let status_response: StatusResponse = transmit(self.transport(), &status_cmd).await?;
213        self.slots = status_response.slots.unwrap();
214        self.addr = status_response.addr;
215        self.set_card_nonce(status_response.card_nonce);
216
217        // private key for spending (for addr), 32 bytes
218        // the private key is encrypted, XORed with the session key, need to decrypt
219        let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey);
220        // decrypt the private key by XORing with the session key
221        let privkey: Vec<u8> = session_key
222            .as_ref()
223            .iter()
224            .zip(&unseal_response.privkey)
225            .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte)
226            .collect();
227        let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?;
228        let pubkey = PublicKey::from_slice(&unseal_response.pubkey)?;
229        Ok((privkey, pubkey))
230    }
231
232    /// Dumps the slot key(s) for an unsealed slot.
233    ///
234    /// With the CVC the private and public slot address keys are returned.
235    /// Without the CVC, the private key is not included.
236    /// If a sealed or unused slot number is given an error is returned.
237    pub async fn dump(
238        &mut self,
239        slot: u8,
240        cvc: Option<String>,
241    ) -> Result<(Option<PrivateKey>, PublicKey), DumpError> {
242        let epubkey_eprivkey_xcvc = cvc.map(|cvc| {
243            let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(&cvc, DumpCommand::name());
244            (epubkey, eprivkey, xcvc)
245        });
246
247        let (epubkey, eprivkey, xcvc) = epubkey_eprivkey_xcvc
248            .map(|(epubkey, eprivkey, xcvc)| (Some(epubkey), Some(eprivkey), Some(xcvc)))
249            .unwrap_or((None, None, None));
250
251        let dump_command = DumpCommand::new(slot, epubkey, xcvc.clone());
252        let dump_response: DumpResponse = transmit(self.transport(), &dump_command).await?;
253        self.set_card_nonce(dump_response.card_nonce);
254
255        // throw errors
256        if let Some(tampered) = dump_response.tampered {
257            if tampered {
258                return Err(DumpError::SlotTampered(slot));
259            }
260        } else if let Some(sealed) = dump_response.sealed {
261            if sealed {
262                return Err(DumpError::SlotSealed(slot));
263            }
264        } else if let Some(used) = dump_response.used {
265            if !used {
266                return Err(DumpError::SlotUnused(slot));
267            }
268        }
269
270        // at this point the card is expected to include the pubkey; if not, treat it as sealed.
271        let pubkey_bytes = match dump_response.pubkey {
272            Some(bytes) => bytes,
273            None => return Err(DumpError::SlotSealed(slot)),
274        };
275        let pubkey = PublicKey::from_slice(&pubkey_bytes)?;
276
277        // TODO use chaincode and master public key to verify pubkey or return error
278
279        let seckey = if let (Some(privkey), Some(eprivkey)) = (dump_response.privkey, eprivkey) {
280            // the private key is encrypted, XORed with the session key, need to decrypt
281            let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey);
282            // decrypt the private key by XORing with the session key
283            let privkey: Vec<u8> = session_key
284                .as_ref()
285                .iter()
286                .zip(&privkey)
287                .map(|(session_key_byte, privkey_byte)| session_key_byte ^ privkey_byte)
288                .collect();
289            let privkey = PrivateKey::from_slice(&privkey, Network::Bitcoin)?;
290            Some(privkey)
291        } else {
292            None
293        };
294
295        Ok((seckey, pubkey))
296    }
297
298    pub async fn address(&mut self) -> Result<String, ReadError> {
299        let network = Network::Bitcoin;
300        let slot_pubkey = self.read(None).await?;
301        let slot_pubkey = CompressedPublicKey(slot_pubkey.inner);
302        let address = Address::p2wpkh(&slot_pubkey, network);
303        Ok(address.to_string())
304    }
305
306    /// Sign a message digest with the SATSCARD.
307    pub async fn sign(
308        &mut self,
309        digest: [u8; 32],
310        slot: u8,
311        cvc: &str,
312    ) -> Result<SignResponse, CkTapError> {
313        let (eprivkey, epubkey, xcvc) = self.calc_ekeys_xcvc(cvc, SignCommand::name());
314
315        // Use the same session key to encrypt the new CVC
316        let session_key = secp256k1::ecdh::SharedSecret::new(&self.pubkey().inner, &eprivkey);
317
318        // encrypt the digest by XORing with the session key
319        let xdigest_vec: Vec<u8> = session_key
320            .as_ref()
321            .iter()
322            .zip(digest)
323            .map(|(session_key_byte, digest_byte)| session_key_byte ^ digest_byte)
324            .collect();
325
326        let xdigest: [u8; 32] = xdigest_vec.try_into().expect("input is also 32 bytes");
327
328        let sign_command = SignCommand::for_satscard(slot, xdigest, epubkey, xcvc.clone());
329
330        let mut sign_response: Result<SignResponse, CkTapError> =
331            transmit(self.transport(), &sign_command).await;
332
333        let mut unlucky_number_retries = 0;
334        while let Err(CkTapError::Card(CardError::UnluckyNumber)) = sign_response {
335            let sign_command = SignCommand::for_satscard(slot, xdigest, epubkey, xcvc.clone());
336
337            sign_response = transmit(self.transport(), &sign_command).await;
338            unlucky_number_retries += 1;
339
340            if unlucky_number_retries > 3 {
341                // TODO shouldn't this return an Err(UnluckyNumber) ?
342                break;
343            }
344        }
345
346        let sign_response = sign_response?;
347        self.set_card_nonce(sign_response.card_nonce);
348        Ok(sign_response)
349    }
350
351    /// Sign P2WPKH inputs for a PSBT with unsealed slot private key m/0
352    /// This function will return a signed but not finalized PSBT. You will need to finalize the
353    /// PSBT yourself before it can be broadcast.
354    pub async fn sign_psbt(
355        &mut self,
356        slot: u8,
357        mut psbt: bitcoin::Psbt,
358        cvc: &str,
359    ) -> Result<bitcoin::Psbt, SignPsbtError> {
360        use bitcoin::{
361            secp256k1::ecdsa,
362            sighash::{EcdsaSighashType, SighashCache},
363        };
364
365        type Error = SignPsbtError;
366
367        let unsigned_tx = psbt.unsigned_tx.clone();
368        let mut sighash_cache = SighashCache::new(&unsigned_tx);
369
370        for (input_index, input) in psbt.inputs.iter_mut().enumerate() {
371            // extract previous output data from the PSBT
372            let witness_utxo = input
373                .witness_utxo
374                .as_ref()
375                .ok_or(Error::MissingUtxo(input_index as u32))?;
376
377            let amount = witness_utxo.value;
378
379            // extract the P2WPKH script from PSBT
380            let script_pubkey = &witness_utxo.script_pubkey;
381            if !script_pubkey.is_p2wpkh() {
382                return Err(Error::InvalidScript(input_index as u32));
383            }
384
385            // get the public key from the PSBT
386            let key_pairs = &input.bip32_derivation;
387            let (psbt_pubkey, (_fingerprint, path)) = key_pairs
388                .iter()
389                .next()
390                .ok_or(Error::MissingPubkey(input_index as u32))?;
391
392            if !path.is_empty() {
393                return Err(Error::InvalidPath(input_index as u32));
394            }
395
396            // calculate sighash
397            let script = script_pubkey.as_script();
398            let sighash = sighash_cache
399                .p2wpkh_signature_hash(input_index, script, amount, EcdsaSighashType::All)
400                .map_err(|e| Error::SighashError(e.to_string()))?;
401
402            // the digest is the sighash
403            let digest: &[u8; 32] = sighash.as_ref();
404
405            // send digest to SATSCARD for signing
406            let sign_response = self.sign(*digest, slot, cvc).await?;
407            let signature_raw = sign_response.sig;
408
409            // verify that SATSCARD used the same public key as the PSBT
410            if sign_response.pubkey != psbt_pubkey.serialize() {
411                return Err(Error::PubkeyMismatch(input_index as u32));
412            }
413
414            // update the PSBT input with the signature
415            let ecdsa_sig = ecdsa::Signature::from_compact(&signature_raw)
416                .map_err(|e| Error::SignatureError(e.to_string()))?;
417
418            let final_sig = bitcoin::ecdsa::Signature::sighash_all(ecdsa_sig);
419            input.partial_sigs.insert((*psbt_pubkey).into(), final_sig);
420        }
421
422        Ok(psbt)
423    }
424}
425
426#[async_trait]
427impl Wait for SatsCard {}
428
429#[async_trait]
430impl Read for SatsCard {
431    fn requires_auth(&self) -> bool {
432        false
433    }
434    fn slot(&self) -> Option<u8> {
435        Some(self.slots.0)
436    }
437}
438
439#[async_trait]
440impl Certificate for SatsCard {
441    async fn slot_pubkey(&mut self) -> Result<Option<PublicKey>, ReadError> {
442        let pubkey = self.read(None).await?;
443        Ok(Some(pubkey))
444    }
445}
446
447#[async_trait]
448impl Nfc for SatsCard {}
449
450impl core::fmt::Debug for SatsCard {
451    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
452        f.debug_struct("SatsCard")
453            .field("proto", &self.proto)
454            .field("ver", &self.ver)
455            .field("birth", &self.birth)
456            .field("slots", &self.slots)
457            .field("addr", &self.addr)
458            .field("card_ident", &self.card_ident())
459            .field("pubkey", &self.pubkey)
460            .field("card_nonce", &self.card_nonce.to_lower_hex_string())
461            .field("auth_delay", &self.auth_delay)
462            .finish()
463    }
464}
465
466#[cfg(feature = "emulator")]
467#[cfg(test)]
468mod test {
469    #![allow(deprecated)] // bdk_wallet::SignOptions is deprecated upstream; tests still rely on it.
470    use crate::CkTapCard;
471    use crate::emulator::find_emulator;
472    use crate::emulator::test::{CardTypeOption, EcardSubprocess};
473    use crate::error::DumpError;
474    use crate::shared::Certificate;
475    use bdk_wallet::chain::{BlockId, ConfirmationBlockTime};
476    use bdk_wallet::template::P2Wpkh;
477    use bdk_wallet::test_utils::{insert_anchor, insert_checkpoint, insert_tx, new_tx};
478    use bdk_wallet::{KeychainKind, SignOptions, Wallet};
479    use bitcoin::Network::Testnet;
480    use bitcoin::hashes::Hash;
481    use bitcoin::{Address, Amount, BlockHash, FeeRate, Network, Transaction, TxOut};
482    use std::path::Path;
483    use std::str::FromStr;
484
485    // verify a signed psbt can be finalized
486    #[tokio::test]
487    async fn test_satscard_sign_psbt() {
488        let card_type = CardTypeOption::SatsCard;
489        let pipe_path = "/tmp/test-satscard-sign-psbt-pipe";
490        let pipe_path = Path::new(&pipe_path);
491        let python = EcardSubprocess::new(pipe_path, &card_type).unwrap();
492        let emulator = find_emulator(pipe_path).await.unwrap();
493        if let CkTapCard::SatsCard(mut sc) = emulator {
494            let slot_pubkey = sc.slot_pubkey().await.unwrap().unwrap();
495            let card_address = sc.address().await.unwrap();
496            let (_seckey, pubkey) = sc.unseal(0, "123456").await.unwrap();
497            assert_eq!(pubkey, slot_pubkey);
498
499            let descriptor = P2Wpkh(pubkey);
500            let mut wallet = Wallet::create_single(descriptor)
501                .network(Network::Bitcoin)
502                .create_wallet_no_persist()
503                .unwrap();
504            let wallet_address = wallet.reveal_next_address(KeychainKind::External).address;
505            assert_eq!(&wallet_address.to_string(), &card_address);
506
507            let tx0 = Transaction {
508                output: vec![TxOut {
509                    value: Amount::from_sat(76_000),
510                    script_pubkey: wallet_address.script_pubkey(),
511                }],
512                ..new_tx(0)
513            };
514
515            insert_checkpoint(
516                &mut wallet,
517                BlockId {
518                    height: 1_000,
519                    hash: BlockHash::all_zeros(),
520                },
521            );
522            insert_checkpoint(
523                &mut wallet,
524                BlockId {
525                    height: 2_000,
526                    hash: BlockHash::all_zeros(),
527                },
528            );
529            insert_tx(&mut wallet, tx0.clone());
530            insert_anchor(
531                &mut wallet,
532                tx0.compute_txid(),
533                ConfirmationBlockTime {
534                    block_id: BlockId {
535                        height: 1_000,
536                        hash: BlockHash::all_zeros(),
537                    },
538                    confirmation_time: 100,
539                },
540            );
541            let balance = wallet.balance();
542            assert_eq!(balance.confirmed, Amount::from_sat(76_000));
543            let mut builder = wallet.build_tx();
544            builder
545                .add_recipient(
546                    Address::from_str("tb1qlvuuza7al8k6shl67qv00g8va3eepy70a42nxd")
547                        .unwrap()
548                        .require_network(Testnet)
549                        .unwrap(),
550                    Amount::from_sat(1000),
551                )
552                .fee_rate(FeeRate::from_sat_per_vb(2).unwrap());
553            let psbt = builder.finish().unwrap();
554            let mut signed_psbt = sc.sign_psbt(0, psbt, "123456").await.unwrap();
555            let finalized = wallet
556                .finalize_psbt(&mut signed_psbt, SignOptions::default())
557                .unwrap();
558            assert!(finalized);
559        } else {
560            panic!("Expected SatsCard");
561        }
562
563        drop(python);
564    }
565
566    // test dumping a sealed, unsealed, and unsealed slot
567    #[tokio::test]
568    async fn test_satscard_dump() {
569        let card_type = CardTypeOption::SatsCard;
570        let pipe_path = "/tmp/test-satscard-dump-pipe";
571        let pipe_path = Path::new(&pipe_path);
572        let python = EcardSubprocess::new(pipe_path, &card_type).unwrap();
573        let emulator = find_emulator(pipe_path).await.unwrap();
574        if let CkTapCard::SatsCard(mut sc) = emulator {
575            // slot 0 is sealed, with cvc return sealed error
576            let slot_keys = sc.dump(0, Some("123456".to_string())).await;
577            assert!(matches!(slot_keys, Err(DumpError::SlotSealed(slot)) if slot == 0));
578            // slot 0 is sealed, with no cvc return sealed error
579            let slot_keys = sc.dump(0, None).await;
580            assert!(matches!(slot_keys, Err(DumpError::SlotSealed(slot)) if slot == 0));
581            // unseal slot 0
582            sc.unseal(0, "123456").await.unwrap();
583            // slot 0 is unsealed, with cvc return privkey
584            let slot_keys = sc.dump(0, Some("123456".to_string())).await;
585            assert!(slot_keys.is_ok());
586            assert!(matches!(slot_keys, Ok((Some(_), _))));
587            let slot_keys = slot_keys.unwrap();
588            // verify pubkey matches pubkey derived from decrypted private key
589            let pubkey_from_privkey = slot_keys.0.unwrap().public_key(&sc.secp);
590            assert_eq!(pubkey_from_privkey, slot_keys.1);
591            // slot 0 is unsealed, with no cvc don't return privkey
592            let slot_keys = sc.dump(0, None).await;
593            assert!(slot_keys.is_ok());
594            assert!(matches!(slot_keys, Ok((None, _))));
595            // slot 1 is unused, with cvc return unused error
596            let dump_response = sc.dump(1, Some("123456".to_string())).await;
597            assert!(matches!(dump_response, Err(DumpError::SlotUnused(slot)) if slot == 1));
598            // slot 1 is unused, with no cvc also return unused error
599            let dump_response = sc.dump(1, None).await;
600            assert!(matches!(dump_response, Err(DumpError::SlotUnused(slot)) if slot == 1));
601        }
602        drop(python);
603    }
604}