penumbra_sdk_custody_ledger_usb/
device.rs1use std::time::Duration;
2
3use anyhow::anyhow;
4use ledger_lib::{
5 info::AppInfo, Device as _, Exchange, Filters, LedgerHandle, LedgerProvider, Transport as _,
6 DEFAULT_TIMEOUT,
7};
8use ledger_proto::ApduHeader;
9use penumbra_sdk_keys::{keys::AddressIndex, Address, FullViewingKey};
10use penumbra_sdk_proto::DomainType as _;
11use penumbra_sdk_transaction::{txhash::EffectHash, AuthorizationData, TransactionPlan};
12
13fn is_penumbra_app(info: &AppInfo) -> anyhow::Result<()> {
14 if info.name != "Penumbra" {
15 anyhow::bail!(
16 "unknown app: {}. Make sure to open the Penumbra app on your device.",
17 &info.name
18 );
19 }
20 Ok(())
21}
22
23fn address_index_to_weird_bytes(index: AddressIndex) -> [u8; 17] {
27 let mut out = [0u8; 17];
28 out[..4].copy_from_slice(&index.account.to_le_bytes());
29 out[4] = u8::from(index.randomizer != [0u8; 12]);
30 out[5..].copy_from_slice(&index.randomizer);
31 out
32}
33
34fn vec_with_fixed_derivation_path(capacity: usize) -> Vec<u8> {
35 let mut out = Vec::with_capacity(12 + capacity);
36 out.extend_from_slice(&u32::to_le_bytes(0x8000_002C));
37 out.extend_from_slice(&u32::to_le_bytes(0x8000_1984));
39 out.extend_from_slice(&u32::to_le_bytes(0x8000_0000));
40 out
41}
42
43fn check_error_code(code: u16) -> anyhow::Result<()> {
44 match code {
46 0x9000 => Ok(()),
47 0x0001 => Err(anyhow!("U2F: Unknown")),
48 0x0002 => Err(anyhow!("U2F: Bad request")),
49 0x0003 => Err(anyhow!("U2F: Configuration unsupported")),
50 0x0004 => Err(anyhow!("U2F: Device Ineligible")),
51 0x0005 => Err(anyhow!("U2F: Timeout")),
52 0x000E => Err(anyhow!("Timeout")),
53 0x5102 => Err(anyhow!("Not Enough Space")),
54 0x5501 => Err(anyhow!("User Refused on Device")),
55 0x5515 => Err(anyhow!("Device Locked")),
56 0x6300 => Err(anyhow!("GP Authentication Failed")),
57 0x63C0 => Err(anyhow!("PIN Remaining Attempts")),
58 0x6400 => Err(anyhow!("Execution Error")),
59 0x6611 => Err(anyhow!("Device Not Onboarded (Secondary)")),
60 0x662E => Err(anyhow!("Custom Image Empty")),
61 0x662F => Err(anyhow!("Custom Image Bootloader Error")),
62 0x6700 => Err(anyhow!("Wrong Length")),
63 0x6800 => Err(anyhow!("Missing Critical Parameter")),
64 0x6802 => Err(anyhow!("Error deriving keys")),
65 0x6981 => Err(anyhow!("Command Incompatible with File Structure")),
66 0x6982 => Err(anyhow!("Empty Buffer")),
67 0x6983 => Err(anyhow!("Output buffer too small")),
68 0x6984 => Err(anyhow!("Data is invalid")),
69 0x6985 => Err(anyhow!("Conditions of Use Not Satisfied")),
70 0x6986 => Err(anyhow!("Transaction rejected")),
71 0x6A80 => Err(anyhow!("Bad key handle")),
72 0x6A84 => Err(anyhow!("Not Enough Memory Space")),
73 0x6A88 => Err(anyhow!("Referenced Data Not Found")),
74 0x6A89 => Err(anyhow!("File Already Exists")),
75 0x6B00 => Err(anyhow!("Invalid P1/P2")),
76 0x6D00 => Err(anyhow!("Instruction not supported")),
77 0x6D02 => Err(anyhow!("Unknown APDU")),
78 0x6D07 => Err(anyhow!("Device Not Onboarded")),
79 0x6E00 => Err(anyhow!("CLA Not Supported")),
80 0x6E01 => Err(anyhow!("App does not seem to be open")),
81 0x6F00 => Err(anyhow!("Unknown error")),
82 0x6F01 => Err(anyhow!("Sign/verify error")),
83 0x6F42 => Err(anyhow!("Licensing Error")),
84 0x6FAA => Err(anyhow!("Device Halted")),
85 0x9001 => Err(anyhow!("Device is busy")),
86 0x9240 => Err(anyhow!("Memory Problem")),
87 0x9400 => Err(anyhow!("No EF Selected")),
88 0x9402 => Err(anyhow!("Invalid Offset")),
89 0x9404 => Err(anyhow!("File Not Found")),
90 0x9408 => Err(anyhow!("Inconsistent File")),
91 0x9484 => Err(anyhow!("Algorithm Not Supported")),
92 0x9485 => Err(anyhow!("Invalid KCV")),
93 0x9802 => Err(anyhow!("Code Not Initialized")),
94 0x9804 => Err(anyhow!("Access Condition Not Fulfilled")),
95 0x9808 => Err(anyhow!("Contradiction with Secret Code Status")),
96 0x9810 => Err(anyhow!("Contradiction Invalidation")),
97 0x9840 => Err(anyhow!("Code Blocked")),
98 0x9850 => Err(anyhow!("Maximum Value Reached")),
99 _ => Err(anyhow!("Unknown transport error")),
100 }
101}
102
103struct GenericResponse {
108 data: Vec<u8>,
109}
110
111impl GenericResponse {
112 fn payload(&self) -> anyhow::Result<&'_ [u8]> {
113 if self.data.len() < 2 {
115 anyhow::bail!("insufficient payload length");
116 }
117 let payload_end = self.data.len() - 2;
118 let code = u16::from_be_bytes(
119 self.data[payload_end..]
120 .try_into()
121 .expect("slice should have length 2"),
122 );
123 if let Err(e) = check_error_code(code) {
125 return Err(e.context(String::from_utf8_lossy(&self.data[..payload_end]).to_string()));
129 }
130 Ok(&self.data[..payload_end])
131 }
132}
133
134pub struct Device {
135 handle: LedgerHandle,
136 buf: [u8; 256],
137}
138
139impl Device {
140 pub async fn connect_to_first() -> anyhow::Result<Self> {
141 let mut provider = LedgerProvider::init().await;
142 let device_list = provider.list(Filters::Any).await?;
143
144 let Some(device_info) = device_list.into_iter().next() else {
146 anyhow::bail!("No ledger devices found.");
147 };
148
149 tracing::debug!(?device_info, "found ledger device");
150
151 let mut handle = provider.connect(device_info).await?;
152
153 let info = handle.app_info(DEFAULT_TIMEOUT).await?;
154 is_penumbra_app(&info)?;
155
156 tracing::debug!(?info, "connected to ledger device");
157
158 Ok(Self {
159 handle,
160 buf: [0u8; 256],
161 })
162 }
163
164 async fn request(
165 &mut self,
166 header: ApduHeader,
167 data: &[u8],
168 ) -> anyhow::Result<GenericResponse> {
169 let req_len = 5 + data.len();
170 assert!(req_len <= self.buf.len(), "request payload too large");
172 self.buf[0] = header.cla;
173 self.buf[1] = header.ins;
174 self.buf[2] = header.p1;
175 self.buf[3] = header.p2;
176 if !data.is_empty() {
178 self.buf[4] = data.len().try_into().expect("data length should be < 256");
179 self.buf[5..req_len].copy_from_slice(data);
180 }
181
182 let out = self
183 .handle
184 .exchange(&self.buf[..req_len], Duration::MAX)
185 .await?;
186 Ok(GenericResponse { data: out })
187 }
188
189 pub async fn get_fvk(&mut self) -> anyhow::Result<FullViewingKey> {
190 let header = ApduHeader {
192 cla: 0x80,
193 ins: 0x03,
194 p1: 0,
195 p2: 0,
196 };
197 let mut req = vec_with_fixed_derivation_path(17);
198 req.extend_from_slice(&[0u8; 17]);
200 tracing::debug!("sending FVK request");
201 let rsp = self.request(header, &req).await?;
202 let fvk = FullViewingKey::try_from(rsp.payload()?)?;
203 Ok(fvk)
204 }
205
206 pub async fn confirm_addr(&mut self, index: AddressIndex) -> anyhow::Result<Address> {
207 let header = ApduHeader {
209 cla: 0x80,
210 ins: 0x01,
211 p1: 1,
213 p2: 0,
214 };
215 let mut req = vec_with_fixed_derivation_path(17);
216 req.extend_from_slice(&address_index_to_weird_bytes(index));
218 tracing::debug!(?index, "sending confirm address request");
219 let rsp = self.request(header, &req).await?;
220 let addr = Address::try_from(rsp.payload()?)?;
221 Ok(addr)
222 }
223
224 pub async fn authorize(&mut self, plan: TransactionPlan) -> anyhow::Result<AuthorizationData> {
225 let plan_bytes = plan.encode_to_vec();
227
228 let start = vec_with_fixed_derivation_path(0);
229
230 let mut response = self
231 .request(
232 ApduHeader {
233 cla: 0x80,
234 ins: 0x02,
235 p1: 0,
236 p2: 0,
237 },
238 &start,
239 )
240 .await?;
241
242 let mut chunks = plan_bytes.chunks(250).peekable();
243 while let Some(chunk) = chunks.next() {
244 let is_last = chunks.peek().is_none();
245 response = self
246 .request(
247 ApduHeader {
248 cla: 0x80,
249 ins: 0x02,
250 p1: if is_last { 2 } else { 1 },
251 p2: 0,
252 },
253 chunk,
254 )
255 .await?;
256 }
257
258 let response_data = response.payload()?;
259 if response_data.len() != 64 + 2 + 2 {
260 anyhow::bail!("unexpected signing response");
261 }
262 let mut auth_data = AuthorizationData {
263 effect_hash: Some(EffectHash(response_data[..64].try_into()?)),
264 ..Default::default()
265 };
266 let spend_auth_count: u8 =
267 u16::from_le_bytes(response_data[64..66].try_into()?).try_into()?;
268 let delegator_auth_count: u8 =
269 u16::from_le_bytes(response_data[66..68].try_into()?).try_into()?;
270
271 for i in 0..spend_auth_count {
272 response = self
273 .request(
274 ApduHeader {
275 cla: 0x80,
276 ins: 0x05,
277 p1: i,
278 p2: 0,
279 },
280 &[],
281 )
282 .await?;
283 auth_data.spend_auths.push(response.payload()?.try_into()?);
284 }
285 for i in 0..delegator_auth_count {
286 response = self
287 .request(
288 ApduHeader {
289 cla: 0x80,
290 ins: 0x06,
291 p1: i,
292 p2: 0,
293 },
294 &[],
295 )
296 .await?;
297 auth_data
298 .delegator_vote_auths
299 .push(response.payload()?.try_into()?);
300 }
301
302 Ok(auth_data)
303 }
304}