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