penumbra_sdk_custody_ledger_usb/
device.rs

1use 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
23/// Necessary because an extra byte is needed to optimize the case where there's no randomizer.
24///
25/// c.f. https://github.com/Zondax/ledger-penumbra-js/blob/d0af0e447d73de9050a258d80db8082e32734046/src/app.ts#L272
26fn 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    // :)
38    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    // https://github.com/Zondax/ledger-js/blob/58248aa02ebfe65f5e0e853f3dca66f60c95eacf/src/consts.ts.
45    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
103/// All responses in this particular app follow a common decoding scheme.
104///
105/// This wraps this scheme, providing a nicer interface, returning `anyhow::Result`,
106/// instead of using the somewhat limited [`ledger_proto::ApduError`] type.
107struct GenericResponse {
108    data: Vec<u8>,
109}
110
111impl GenericResponse {
112    fn payload(&self) -> anyhow::Result<&'_ [u8]> {
113        // c.f. https://github.com/Zondax/ledger-js/blob/58248aa02ebfe65f5e0e853f3dca66f60c95eacf/src/common.ts#L41.
114        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        // #golang
124        if let Err(e) = check_error_code(code) {
125            // When an error happens, the rest of the payload is an additional message.
126            // This should be ASCII (and thus UTF-8), but we can just ignore
127            // bad characters, using [`String::from_utf8_lossy`];
128            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        // NOTE: Should we do more than just pick the first device?
145        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        // For a better error message.
171        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        // For empty data, we don't write the length at all.
177        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        // https://github.com/Zondax/ledger-penumbra/blob/9f57b82ad3b843bc18e22ba841f971659bcd0fe8/docs/APDUSPEC.md#ins_get_fvk
191        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        // The request requires an address index which doesn't actually influence the result.
199        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        // https://github.com/Zondax/ledger-penumbra/blob/9f57b82ad3b843bc18e22ba841f971659bcd0fe8/docs/APDUSPEC.md#ins_get_addr        todo!()
208        let header = ApduHeader {
209            cla: 0x80,
210            ins: 0x01,
211            // We want the user to confirm the address.
212            p1: 1,
213            p2: 0,
214        };
215        let mut req = vec_with_fixed_derivation_path(17);
216        // The request requires an address index which doesn't actually influence the result.
217        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        // c.f. https://github.com/Zondax/ledger-penumbra-js/blob/d0af0e447d73de9050a258d80db8082e32734046/src/app.ts#L116
226        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}