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("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
88pub 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 pub fn native() -> Result<LedgerSigner<TransportNativeHID>, Error> {
107 Ok(LedgerSigner {
108 transport: get_transport()?,
109 })
110 }
111 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 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 #[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 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 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 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 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 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 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 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}