Skip to main content

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