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
25const XDR_DEPTH_LIMIT: u32 = 500;
29
30const 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
36const 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; #[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
105pub 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 pub fn native() -> Result<LedgerSigner<TransportNativeHID>, Error> {
124 Ok(LedgerSigner {
125 transport: get_transport()?,
126 })
127 }
128 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 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 #[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 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 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 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 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 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 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 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}