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
25const 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
31const 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; #[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
100pub 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 pub fn native() -> Result<LedgerSigner<TransportNativeHID>, Error> {
119 Ok(LedgerSigner {
120 transport: get_transport()?,
121 })
122 }
123 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 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 #[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 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 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 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 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 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 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 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}