stellar_ledger/
lib.rs

1use hd_path::HdPath;
2use ledger_transport::APDUCommand;
3pub use ledger_transport::Exchange;
4
5use ledger_transport_hid::{
6    hidapi::{HidApi, HidError},
7    LedgerHIDError,
8};
9
10pub use ledger_transport_hid::TransportNativeHID;
11
12use std::vec;
13use stellar_strkey::DecodeError;
14use stellar_xdr::curr::{
15    self as xdr, Hash, Limits, Transaction, TransactionSignaturePayload,
16    TransactionSignaturePayloadTaggedTransaction, WriteXdr,
17};
18
19pub use crate::signer::Blob;
20pub mod hd_path;
21mod signer;
22
23pub mod emulator_test_support;
24
25// this is from https://github.com/LedgerHQ/ledger-live/blob/36cfbf3fa3300fd99bcee2ab72e1fd8f280e6280/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L181
26const APDU_MAX_SIZE: u8 = 150;
27const HD_PATH_ELEMENTS_COUNT: u8 = 3;
28const BUFFER_SIZE: u8 = 1 + HD_PATH_ELEMENTS_COUNT * 4;
29const CHUNK_SIZE: u8 = APDU_MAX_SIZE - BUFFER_SIZE;
30
31// These constant values are from https://github.com/LedgerHQ/app-stellar/blob/develop/docs/COMMANDS.md
32const SIGN_TX_RESPONSE_SIZE: usize = 64;
33
34const CLA: u8 = 0xE0;
35
36const GET_PUBLIC_KEY: u8 = 0x02;
37const P1_GET_PUBLIC_KEY: u8 = 0x00;
38const P2_GET_PUBLIC_KEY_NO_DISPLAY: u8 = 0x00;
39const P2_GET_PUBLIC_KEY_DISPLAY: u8 = 0x01;
40
41const SIGN_TX: u8 = 0x04;
42const P1_SIGN_TX_FIRST: u8 = 0x00;
43const P1_SIGN_TX_NOT_FIRST: u8 = 0x80;
44const P2_SIGN_TX_LAST: u8 = 0x00;
45const P2_SIGN_TX_MORE: u8 = 0x80;
46
47const GET_APP_CONFIGURATION: u8 = 0x06;
48const P1_GET_APP_CONFIGURATION: u8 = 0x00;
49const P2_GET_APP_CONFIGURATION: u8 = 0x00;
50
51const SIGN_TX_HASH: u8 = 0x08;
52const P1_SIGN_TX_HASH: u8 = 0x00;
53const P2_SIGN_TX_HASH: u8 = 0x00;
54
55const RETURN_CODE_OK: u16 = 36864; // APDUAnswer.retcode which means success from Ledger
56
57#[derive(thiserror::Error, Debug)]
58pub enum Error {
59    #[error("Error occurred while initializing HIDAPI: {0}")]
60    HidApiError(#[from] HidError),
61
62    #[error("Error occurred while initializing Ledger HID transport: {0}")]
63    LedgerHidError(#[from] LedgerHIDError),
64
65    #[error("Error with ADPU exchange with Ledger device: {0}")]
66    APDUExchangeError(String),
67
68    #[error("Error occurred while exchanging with Ledger device: {0}")]
69    LedgerConnectionError(String),
70
71    #[error("Error occurred while parsing BIP32 path: {0}")]
72    Bip32PathError(String),
73
74    #[error(transparent)]
75    XdrError(#[from] xdr::Error),
76
77    #[error(transparent)]
78    DecodeError(#[from] DecodeError),
79}
80
81pub struct LedgerSigner<T: Exchange> {
82    transport: T,
83}
84
85unsafe impl<T> Send for LedgerSigner<T> where T: Exchange {}
86unsafe impl<T> Sync for LedgerSigner<T> where T: Exchange {}
87
88/// # Errors
89/// Could fail to make the connection to the Ledger device
90pub fn native() -> Result<LedgerSigner<TransportNativeHID>, Error> {
91    Ok(LedgerSigner {
92        transport: get_transport()?,
93    })
94}
95
96impl<T> LedgerSigner<T>
97where
98    T: Exchange,
99{
100    pub fn new(transport: T) -> Self {
101        Self { transport }
102    }
103
104    /// # Errors
105    /// Returns an error if there is an issue with connecting with the device
106    pub fn native() -> Result<LedgerSigner<TransportNativeHID>, Error> {
107        Ok(LedgerSigner {
108            transport: get_transport()?,
109        })
110    }
111    /// Get the device app's configuration
112    /// # Errors
113    /// Returns an error if there is an issue with connecting with the device or getting the config from the device
114    pub async fn get_app_configuration(&self) -> Result<Vec<u8>, Error> {
115        let command = APDUCommand {
116            cla: CLA,
117            ins: GET_APP_CONFIGURATION,
118            p1: P1_GET_APP_CONFIGURATION,
119            p2: P2_GET_APP_CONFIGURATION,
120            data: vec![],
121        };
122        self.send_command_to_ledger(command).await
123    }
124
125    /// Sign a Stellar transaction hash with the account on the Ledger device
126    /// based on impl from [https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L166](https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L166)
127    /// # Errors
128    /// Returns an error if there is an issue with connecting with the device or signing the given tx on the device. Or, if the device has not enabled hash signing
129    pub async fn sign_transaction_hash(
130        &self,
131        hd_path: impl Into<HdPath>,
132        transaction_hash: &[u8; 32],
133    ) -> Result<Vec<u8>, Error> {
134        self.sign_blob(&hd_path.into(), transaction_hash).await
135    }
136
137    /// Sign a Stellar transaction with the account on the Ledger device
138    /// # Errors
139    /// Returns an error if there is an issue with connecting with the device or signing the given tx on the device
140    #[allow(clippy::missing_panics_doc)]
141    pub async fn sign_transaction(
142        &self,
143        hd_path: impl Into<HdPath>,
144        transaction: Transaction,
145        network_id: Hash,
146    ) -> Result<Vec<u8>, Error> {
147        let tagged_transaction = TransactionSignaturePayloadTaggedTransaction::Tx(transaction);
148        let signature_payload = TransactionSignaturePayload {
149            network_id,
150            tagged_transaction,
151        };
152        let mut signature_payload_as_bytes = signature_payload.to_xdr(Limits::none())?;
153
154        let mut hd_path_to_bytes = hd_path.into().to_vec()?;
155
156        let capacity = 1 + hd_path_to_bytes.len() + signature_payload_as_bytes.len();
157        let mut data: Vec<u8> = Vec::with_capacity(capacity);
158
159        data.insert(0, HD_PATH_ELEMENTS_COUNT);
160        data.append(&mut hd_path_to_bytes);
161        data.append(&mut signature_payload_as_bytes);
162
163        let chunks = data.chunks(CHUNK_SIZE as usize);
164        let chunks_count = chunks.len();
165
166        let mut result = Vec::with_capacity(SIGN_TX_RESPONSE_SIZE);
167        for (i, chunk) in chunks.enumerate() {
168            let is_first_chunk = i == 0;
169            let is_last_chunk = chunks_count == i + 1;
170
171            let command = APDUCommand {
172                cla: CLA,
173                ins: SIGN_TX,
174                p1: if is_first_chunk {
175                    P1_SIGN_TX_FIRST
176                } else {
177                    P1_SIGN_TX_NOT_FIRST
178                },
179                p2: if is_last_chunk {
180                    P2_SIGN_TX_LAST
181                } else {
182                    P2_SIGN_TX_MORE
183                },
184                data: chunk.to_vec(),
185            };
186
187            let mut r = self.send_command_to_ledger(command).await?;
188            result.append(&mut r);
189        }
190
191        Ok(result)
192    }
193
194    /// The `display_and_confirm` bool determines if the Ledger will display the public key on its screen and requires user approval to share
195    async fn get_public_key_with_display_flag(
196        &self,
197        hd_path: impl Into<HdPath>,
198        display_and_confirm: bool,
199    ) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
200        // convert the hd_path into bytes to be sent as `data` to the Ledger
201        // the first element of the data should be the number of elements in the path
202        let hd_path = hd_path.into();
203        let hd_path_elements_count = hd_path.depth();
204        let mut hd_path_to_bytes = hd_path.to_vec()?;
205        hd_path_to_bytes.insert(0, hd_path_elements_count);
206
207        let p2 = if display_and_confirm {
208            P2_GET_PUBLIC_KEY_DISPLAY
209        } else {
210            P2_GET_PUBLIC_KEY_NO_DISPLAY
211        };
212
213        // more information about how to build this command can be found at https://github.com/LedgerHQ/app-stellar/blob/develop/docs/COMMANDS.md
214        let command = APDUCommand {
215            cla: CLA,
216            ins: GET_PUBLIC_KEY,
217            p1: P1_GET_PUBLIC_KEY,
218            p2,
219            data: hd_path_to_bytes,
220        };
221
222        tracing::info!("APDU in: {}", hex::encode(command.serialize()));
223
224        self.send_command_to_ledger(command)
225            .await
226            .and_then(|p| Ok(stellar_strkey::ed25519::PublicKey::from_payload(&p)?))
227    }
228
229    async fn send_command_to_ledger(
230        &self,
231        command: APDUCommand<Vec<u8>>,
232    ) -> Result<Vec<u8>, Error> {
233        match self.transport.exchange(&command).await {
234            Ok(response) => {
235                tracing::info!(
236                    "APDU out: {}\nAPDU ret code: {:x}",
237                    hex::encode(response.apdu_data()),
238                    response.retcode(),
239                );
240                // Ok means we successfully connected with the Ledger but it doesn't mean our request succeeded. We still need to check the response.retcode
241                if response.retcode() == RETURN_CODE_OK {
242                    return Ok(response.data().to_vec());
243                }
244
245                let retcode = response.retcode();
246                let error_string = format!("Ledger APDU retcode: 0x{retcode:X}");
247                Err(Error::APDUExchangeError(error_string))
248            }
249            Err(_err) => Err(Error::LedgerConnectionError(
250                "Error connecting to ledger device".to_string(),
251            )),
252        }
253    }
254}
255
256#[async_trait::async_trait]
257impl<T> Blob for LedgerSigner<T>
258where
259    T: Exchange,
260{
261    type Key = HdPath;
262    type Error = Error;
263    /// Get the public key from the device
264    /// # Errors
265    /// Returns an error if there is an issue with connecting with the device or getting the public key from the device
266    async fn get_public_key(
267        &self,
268        index: &Self::Key,
269    ) -> Result<stellar_strkey::ed25519::PublicKey, Error> {
270        self.get_public_key_with_display_flag(*index, false).await
271    }
272
273    /// Sign a blob of data with the account on the Ledger device
274    /// based on impl from [https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L166](https://github.com/LedgerHQ/ledger-live/blob/develop/libs/ledgerjs/packages/hw-app-str/src/Str.ts#L166)
275    /// # Errors
276    /// Returns an error if there is an issue with connecting with the device or signing the given tx on the device. Or, if the device has not enabled hash signing
277    async fn sign_blob(&self, index: &Self::Key, blob: &[u8]) -> Result<Vec<u8>, Error> {
278        let mut hd_path_to_bytes = index.to_vec()?;
279
280        let capacity = 1 + hd_path_to_bytes.len() + blob.len();
281        let mut data: Vec<u8> = Vec::with_capacity(capacity);
282
283        data.insert(0, HD_PATH_ELEMENTS_COUNT);
284        data.append(&mut hd_path_to_bytes);
285        data.extend_from_slice(blob);
286
287        let command = APDUCommand {
288            cla: CLA,
289            ins: SIGN_TX_HASH,
290            p1: P1_SIGN_TX_HASH,
291            p2: P2_SIGN_TX_HASH,
292            data,
293        };
294
295        self.send_command_to_ledger(command).await
296    }
297}
298
299fn get_transport() -> Result<TransportNativeHID, Error> {
300    // instantiate the connection to Ledger, this will return an error if Ledger is not connected
301    let hidapi = HidApi::new().map_err(Error::HidApiError)?;
302    TransportNativeHID::new(&hidapi).map_err(Error::LedgerHidError)
303}
304
305pub const TEST_NETWORK_PASSPHRASE: &[u8] = b"Test SDF Network ; September 2015";
306#[cfg(test)]
307pub fn test_network_hash() -> Hash {
308    use sha2::Digest;
309    Hash(sha2::Sha256::digest(TEST_NETWORK_PASSPHRASE).into())
310}
311
312#[cfg(all(test, feature = "http-transport"))]
313mod test {
314    use httpmock::prelude::*;
315    use serde_json::json;
316
317    use super::emulator_test_support::http_transport::Emulator;
318    use crate::Blob;
319
320    use std::vec;
321
322    use super::xdr::{self, Operation, OperationBody, Transaction, Uint256};
323
324    use crate::{test_network_hash, Error, LedgerSigner};
325
326    use stellar_xdr::curr::{
327        Memo, MuxedAccount, PaymentOp, Preconditions, SequenceNumber, TransactionExt,
328    };
329
330    fn ledger(server: &MockServer) -> LedgerSigner<Emulator> {
331        let transport = Emulator::new(&server.host(), server.port());
332        LedgerSigner::new(transport)
333    }
334
335    #[tokio::test]
336    async fn test_get_public_key() {
337        let server = MockServer::start();
338        let mock_server = server.mock(|when, then| {
339            when.method(POST)
340                .path("/")
341                .header("accept", "application/json")
342                .header("content-type", "application/json")
343                .json_body(json!({ "apduHex": "e00200000d038000002c8000009480000000" }));
344            then.status(200)
345                .header("content-type", "application/json")
346                .json_body(json!({"data": "e93388bbfd2fbd11806dd0bd59cea9079e7cc70ce7b1e154f114cdfe4e466ecd9000"}));
347        });
348        let ledger = ledger(&server);
349        let public_key = ledger.get_public_key(&0u32.into()).await.unwrap();
350        let public_key_string = public_key.to_string();
351        let expected_public_key = "GDUTHCF37UX32EMANXIL2WOOVEDZ47GHBTT3DYKU6EKM37SOIZXM2FN7";
352        assert_eq!(public_key_string, expected_public_key);
353
354        mock_server.assert();
355    }
356
357    #[tokio::test]
358    async fn test_get_app_configuration() {
359        let server = MockServer::start();
360        let mock_server = server.mock(|when, then| {
361            when.method(POST)
362                .path("/")
363                .header("accept", "application/json")
364                .header("content-type", "application/json")
365                .json_body(json!({ "apduHex": "e006000000" }));
366            then.status(200)
367                .header("content-type", "application/json")
368                .json_body(json!({"data": "000500039000"}));
369        });
370        let ledger = ledger(&server);
371        let config = ledger.get_app_configuration().await.unwrap();
372        assert_eq!(config, vec![0, 5, 0, 3]);
373
374        mock_server.assert();
375    }
376
377    #[tokio::test]
378    async fn test_sign_tx() {
379        let server = MockServer::start();
380        let mock_request_1 = server.mock(|when, then| {
381            when.method(POST)
382                .path("/")
383                .header("accept", "application/json")
384                .header("content-type", "application/json")
385                .json_body(json!({ "apduHex": "e004008089038000002c8000009480000000cee0302d59844d32bdca915c8203dd44b33fbb7edc19051ea37abedf28ecd472000000020000000000000000000000000000000000000000000000000000000000000000000000000000006400000000000000010000000000000001000000075374656c6c6172000000000100000001000000000000000000000000" }));
386            then.status(200)
387                .header("content-type", "application/json")
388                .json_body(json!({"data": "9000"}));
389        });
390
391        let mock_request_2 = server.mock(|when, then| {
392            when.method(POST)
393                .path("/")
394                .header("accept", "application/json")
395                .header("content-type", "application/json")
396                .json_body(json!({ "apduHex": "e0048000500000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006400000000" }));
397            then.status(200)
398                .header("content-type", "application/json")
399                .json_body(json!({"data": "5c2f8eb41e11ab922800071990a25cf9713cc6e7c43e50e0780ddc4c0c6da50c784609ef14c528a12f520d8ea9343b49083f59c51e3f28af8c62b3edeaade60e9000"}));
400        });
401
402        let ledger = ledger(&server);
403
404        let fake_source_acct = [0; 32];
405        let fake_dest_acct = [0; 32];
406        let tx = Transaction {
407            source_account: MuxedAccount::Ed25519(Uint256(fake_source_acct)),
408            fee: 100,
409            seq_num: SequenceNumber(1),
410            cond: Preconditions::None,
411            memo: Memo::Text("Stellar".as_bytes().try_into().unwrap()),
412            ext: TransactionExt::V0,
413            operations: [Operation {
414                source_account: Some(MuxedAccount::Ed25519(Uint256(fake_source_acct))),
415                body: OperationBody::Payment(PaymentOp {
416                    destination: MuxedAccount::Ed25519(Uint256(fake_dest_acct)),
417                    asset: xdr::Asset::Native,
418                    amount: 100,
419                }),
420            }]
421            .try_into()
422            .unwrap(),
423        };
424
425        let response = ledger
426            .sign_transaction(0, tx, test_network_hash())
427            .await
428            .unwrap();
429        assert_eq!(
430            hex::encode(response),
431            "5c2f8eb41e11ab922800071990a25cf9713cc6e7c43e50e0780ddc4c0c6da50c784609ef14c528a12f520d8ea9343b49083f59c51e3f28af8c62b3edeaade60e"
432        );
433
434        mock_request_1.assert();
435        mock_request_2.assert();
436    }
437
438    #[tokio::test]
439    async fn test_sign_tx_hash_when_hash_signing_is_not_enabled() {
440        let server = MockServer::start();
441        let mock_server = server.mock(|when, then| {
442            when.method(POST)
443                .path("/")
444                .header("accept", "application/json")
445                .header("content-type", "application/json")
446                .json_body(json!({ "apduHex": "e00800004d038000002c800000948000000033333839653966306631613635663139373336636163663534346332653832353331336538343437663536393233336262386462333961613630376338383839" }));
447            then.status(200)
448                .header("content-type", "application/json")
449                .json_body(json!({"data": "6c66"}));
450        });
451
452        let ledger = ledger(&server);
453        let path = 0;
454        let test_hash = b"3389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889";
455
456        let err = ledger.sign_blob(&path.into(), test_hash).await.unwrap_err();
457        if let Error::APDUExchangeError(msg) = err {
458            assert_eq!(msg, "Ledger APDU retcode: 0x6C66");
459        } else {
460            panic!("Unexpected error: {err:?}");
461        }
462
463        mock_server.assert();
464    }
465
466    #[tokio::test]
467    async fn test_sign_tx_hash_when_hash_signing_is_enabled() {
468        let server = MockServer::start();
469        let mock_server = server.mock(|when, then| {
470            when.method(POST)
471                .path("/")
472                .header("accept", "application/json")
473                .header("content-type", "application/json")
474                .json_body(json!({ "apduHex": "e00800002d038000002c80000094800000003389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889" }));
475            then.status(200)
476                .header("content-type", "application/json")
477                .json_body(json!({"data": "6970b9c9d3a6f4de7fb93e8d3920ec704fc4fece411873c40570015bbb1a60a197622bc3bf5644bb38ae73e1b96e4d487d716d142d46c7e944f008dece92df079000"}));
478        });
479
480        let ledger = ledger(&server);
481        let path = 0;
482        let mut test_hash = vec![0u8; 32];
483
484        hex::decode_to_slice(
485            "3389e9f0f1a65f19736cacf544c2e825313e8447f569233bb8db39aa607c8889",
486            &mut test_hash as &mut [u8],
487        )
488        .unwrap();
489
490        let response = ledger.sign_blob(&path.into(), &test_hash).await.unwrap();
491
492        assert_eq!(
493            hex::encode(response),
494            "6970b9c9d3a6f4de7fb93e8d3920ec704fc4fece411873c40570015bbb1a60a197622bc3bf5644bb38ae73e1b96e4d487d716d142d46c7e944f008dece92df07"
495        );
496
497        mock_server.assert();
498    }
499}