Skip to main content

lsdk/
device.rs

1//! High level device handle with the dashboard commands.
2
3use crate::apdu::{Apdu, Response, StatusWord};
4use crate::error::{Error, Result};
5use crate::os::{
6    self, AppAndVersion, BatteryStatus, BatteryStatusKind, EndorsementInfo, EndorsementSlot,
7    InstalledApp, LanguagePack, OnboardIdentity, OsVersion, ScreenSaver, ScreenSaverAnimation,
8    ScreenSaverSpeed,
9};
10use crate::transport::Transport;
11
12/// A device reachable through a [`Transport`].
13pub struct Device<T: Transport> {
14    transport: T,
15}
16
17impl<T: Transport> Device<T> {
18    pub fn new(transport: T) -> Self {
19        Self { transport }
20    }
21
22    pub fn transport(&self) -> &T {
23        &self.transport
24    }
25
26    pub fn into_inner(self) -> T {
27        self.transport
28    }
29
30    /// Send raw bytes and get the raw answer (data + SW).
31    pub async fn exchange_raw(&self, apdu: &[u8]) -> Result<Vec<u8>> {
32        self.transport.exchange(apdu).await
33    }
34
35    /// Send an APDU and get the parsed response (whatever the status).
36    pub async fn exchange(&self, apdu: &Apdu) -> Result<Response> {
37        let raw = self.transport.exchange(&apdu.encode()?).await?;
38        Response::from_raw(raw)
39    }
40
41    /// Send an APDU and return its data, failing on a non `0x9000` status.
42    pub async fn send(&self, apdu: &Apdu) -> Result<Vec<u8>> {
43        self.exchange(apdu).await?.ok()
44    }
45
46    /// Firmware / hardware information (`GET_VERSION`). Must be on the dashboard.
47    pub async fn get_version(&self) -> Result<OsVersion> {
48        let r = self.exchange(&os::GET_VERSION).await?;
49        match r.sw {
50            StatusWord::DEVICE_NOT_ONBOARDED | StatusWord::DEVICE_NOT_ONBOARDED_2 => {
51                Err(Error::App("device is not onboarded".into()))
52            }
53            StatusWord::CLA_NOT_SUPPORTED | StatusWord::INS_NOT_SUPPORTED => {
54                Err(Error::DashboardExpected)
55            }
56            _ => OsVersion::parse(&r.ok()?),
57        }
58    }
59
60    /// Currently running app name and version (works in any app).
61    pub async fn get_app_and_version(&self) -> Result<AppAndVersion> {
62        AppAndVersion::parse(&self.send(&os::GET_APP_AND_VERSION).await?)
63    }
64
65    /// Whether the device is on the dashboard (or in bootloader).
66    pub async fn is_on_dashboard(&self) -> Result<bool> {
67        let r = self.exchange(&os::GET_APP_AND_VERSION).await?;
68        match r.sw {
69            StatusWord::OK => Ok(AppAndVersion::parse(&r.data)?.is_dashboard()),
70            StatusWord::CLA_NOT_SUPPORTED | StatusWord::CLA_NOT_SUPPORTED_BOOTLOADER => Ok(true),
71            StatusWord::INS_NOT_SUPPORTED => Ok(false),
72            sw => Err(sw.into()),
73        }
74    }
75
76    /// Ask the running app to exit back to the dashboard.
77    ///
78    /// Note: on most models the USB device re-enumerates when switching apps,
79    /// so the transport may need to be reopened afterwards.
80    pub async fn quit_app(&self) -> Result<()> {
81        self.send(&os::QUIT_APP).await.map(|_| ())
82    }
83
84    /// Open an app by name (from the dashboard). Requires user confirmation on
85    /// some firmwares. See [`Device::quit_app`] about re-enumeration.
86    pub async fn open_app(&self, name: &str) -> Result<()> {
87        let r = self.exchange(&os::open_app(name)).await?;
88        match r.sw {
89            StatusWord::OK => Ok(()),
90            StatusWord::APP_NOT_FOUND | StatusWord(0x6984) => {
91                Err(Error::NotFound(format!("app {name:?} is not installed")))
92            }
93            sw if sw.is_refused() => Err(Error::UserRefused),
94            sw => Err(sw.into()),
95        }
96    }
97
98    /// List installed apps (requires user consent on the device unless the
99    /// secure channel was already allowed).
100    pub async fn list_apps(&self) -> Result<Vec<InstalledApp>> {
101        let mut apps = Vec::new();
102        let mut r = self.exchange(&os::LIST_APPS).await?;
103        loop {
104            match r.sw {
105                StatusWord::OK => {}
106                sw if sw.is_refused() => return Err(Error::UserRefused),
107                sw => return Err(sw.into()),
108            }
109            if r.data.is_empty() {
110                break;
111            }
112            apps.extend(os::parse_list_apps(&r.data)?);
113            r = self.exchange(&os::LIST_APPS_CONTINUE).await?;
114        }
115        Ok(apps)
116    }
117
118    /// Device name (as shown over Bluetooth). The device asks the user to
119    /// allow it, so avoid calling this implicitly.
120    pub async fn get_device_name(&self) -> Result<String> {
121        let data = self.send(&os::GET_DEVICE_NAME).await?;
122        Ok(String::from_utf8_lossy(&data).into_owned())
123    }
124
125    /// Rename the device (requires user confirmation).
126    pub async fn set_device_name(&self, name: &str) -> Result<()> {
127        if name.is_empty() || name.len() > 32 {
128            return Err(Error::InvalidInput("name must be 1..=32 bytes".into()));
129        }
130        match self.exchange(&os::set_device_name(name)).await?.sw {
131            StatusWord::OK => Ok(()),
132            sw if sw.is_refused() => Err(Error::UserRefused),
133            sw => Err(sw.into()),
134        }
135    }
136
137    fn screen_saver_err(sw: StatusWord) -> Error {
138        match sw {
139            sw if sw.is_refused() => Error::UserRefused,
140            // The dashboard answers 0x6d02 outside recovery mode and for a
141            // malformed request (verified on a Nano S Plus).
142            StatusWord(0x6d00..=0x6dff) => Error::App(
143                "screensaver command rejected: only accepted in recovery mode (Nano X / Nano S Plus), with a valid request"
144                    .into(),
145            ),
146            sw => sw.into(),
147        }
148    }
149
150    /// Current custom screensaver, `None` for the default one. Recovery mode only.
151    pub async fn get_screen_saver(&self) -> Result<Option<ScreenSaver>> {
152        let r = self.exchange(&os::get_screen_saver()).await?;
153        if !r.sw.is_ok() {
154            return Err(Self::screen_saver_err(r.sw));
155        }
156        ScreenSaver::parse(&r.data)
157    }
158
159    /// Set a custom scrolling-text screensaver (user confirmation required).
160    /// Recovery mode only.
161    pub async fn set_screen_saver(
162        &self,
163        text: &str,
164        animation: ScreenSaverAnimation,
165        speed: ScreenSaverSpeed,
166    ) -> Result<()> {
167        let r = self
168            .exchange(&os::set_screen_saver(text, animation, speed)?)
169            .await?;
170        if r.sw.is_ok() {
171            Ok(())
172        } else {
173            Err(Self::screen_saver_err(r.sw))
174        }
175    }
176
177    /// Restore the default screensaver (user confirmation required). Recovery mode only.
178    pub async fn reset_screen_saver(&self) -> Result<()> {
179        let r = self.exchange(&os::reset_screen_saver()).await?;
180        if r.sw.is_ok() {
181            Ok(())
182        } else {
183            Err(Self::screen_saver_err(r.sw))
184        }
185    }
186
187    fn check(&self, r: crate::apdu::Response) -> Result<Vec<u8>> {
188        match r.sw {
189            StatusWord::OK => Ok(r.data),
190            sw if sw.is_refused() => Err(Error::UserRefused),
191            sw => Err(sw.into()),
192        }
193    }
194
195    /// Restart the dashboard (`RESET`).
196    pub async fn reset(&self) -> Result<()> {
197        match self.exchange(&os::RESET).await {
198            Ok(r) => self.check(r).map(|_| ()),
199            Err(Error::Disconnected) => Ok(()),
200            Err(e) => Err(e),
201        }
202    }
203
204    /// Bluetooth MAC address (BLE models), as `aa:bb:cc:dd:ee:ff`.
205    pub async fn get_device_mac(&self) -> Result<String> {
206        let d = self.check(self.exchange(&os::GET_DEVICE_MAC).await?)?;
207        Ok(d.iter()
208            .map(|b| format!("{b:02x}"))
209            .collect::<Vec<_>>()
210            .join(":"))
211    }
212
213    /// Set the battery charge limit (percent, models with a battery).
214    pub async fn set_battery_charge_limit(&self, percent: u8) -> Result<()> {
215        self.check(
216            self.exchange(&os::set_battery_charge_limit(percent)?)
217                .await?,
218        )
219        .map(|_| ())
220    }
221
222    /// Installed language packs.
223    pub async fn list_language_packs(&self) -> Result<Vec<LanguagePack>> {
224        let mut out = Vec::new();
225        let mut first = true;
226        loop {
227            let d = self.check(self.exchange(&os::list_language_packs(first)).await?)?;
228            match os::parse_language_pack(&d)? {
229                Some(p) => out.push(p),
230                None => break,
231            }
232            first = false;
233            if out.len() > 32 {
234                break;
235            }
236        }
237        Ok(out)
238    }
239
240    /// Replay an APDU script (one hex APDU per line, `#` comments allowed),
241    /// e.g. a signed language pack. Stops at the first non-`9000` status.
242    /// `on_progress(done, total)` counts APDUs.
243    pub async fn run_apdu_script(
244        &self,
245        script: &str,
246        on_progress: &mut dyn FnMut(usize, usize),
247    ) -> Result<()> {
248        let apdus: Vec<Vec<u8>> = script
249            .lines()
250            .map(|l| l.split('#').next().unwrap_or_default().trim())
251            .filter(|l| !l.is_empty())
252            .map(|l| hex::decode(l.replace(' ', "")))
253            .collect::<core::result::Result<_, _>>()
254            .map_err(|e| Error::InvalidInput(format!("APDU script: {e}")))?;
255        if apdus.is_empty() {
256            return Err(Error::InvalidInput("empty APDU script".into()));
257        }
258        let total = apdus.len();
259        for (i, apdu) in apdus.iter().enumerate() {
260            let r = crate::apdu::Response::from_raw(self.exchange_raw(apdu).await?)?;
261            match r.sw.0 {
262                0x9000 => {}
263                0x6985 | 0x5501 => return Err(Error::UserRefused),
264                0x6a84 | 0x5102 => return Err(Error::App("not enough space on the device".into())),
265                _ => {
266                    return Err(Error::App(format!(
267                        "APDU {} of {total} failed: {}",
268                        i + 1,
269                        r.sw
270                    )));
271                }
272            }
273            on_progress(i + 1, total);
274        }
275        Ok(())
276    }
277
278    /// Install a language pack from its signed APDU script, replacing the
279    /// installed packs (one at a time is supported).
280    pub async fn install_language_script(
281        &self,
282        script: &str,
283        on_progress: &mut dyn FnMut(usize, usize),
284    ) -> Result<()> {
285        for installed in self.list_language_packs().await? {
286            self.delete_language_pack(installed.id as u8).await?;
287        }
288        self.run_apdu_script(script, on_progress).await
289    }
290
291    /// Remove a language pack (the device may ask for confirmation).
292    pub async fn delete_language_pack(&self, language: u8) -> Result<()> {
293        self.check(self.exchange(&os::delete_language_pack(language)).await?)
294            .map(|_| ())
295    }
296
297    /// Endorsement key and certificate of a slot.
298    pub async fn endorsement_info(&self, slot: EndorsementSlot) -> Result<EndorsementInfo> {
299        let r = self.exchange(&os::endorsement_info(slot)).await?;
300        if !r.sw.is_ok() && !r.sw.is_refused() && r.sw != StatusWord::LOCKED_DEVICE {
301            return Err(Error::NotFound(format!(
302                "endorsement slot {} is not set up ({})",
303                slot as u8, r.sw
304            )));
305        }
306        EndorsementInfo::parse(&self.check(r)?)
307    }
308
309    /// Start an endorsement: the device generates the slot key and returns
310    /// `(public_key, device_signature)`; the signature (by the device
311    /// attestation key) covers `sha256(0xFF || public_key)`.
312    pub async fn endorse_start(&self, slot: EndorsementSlot) -> Result<(Vec<u8>, Vec<u8>)> {
313        let d = self.check(self.exchange(&os::endorse_set_start(slot)).await?)?;
314        if d.len() < 65 {
315            return Err(Error::InvalidResponse("short endorsement key".into()));
316        }
317        Ok((d[..65].to_vec(), d[65..].to_vec()))
318    }
319
320    /// Finish an endorsement with the owner certificate (DER signature over
321    /// `sha256(0xFE || public_key)`).
322    pub async fn endorse_commit(&self, certificate: &[u8]) -> Result<()> {
323        self.check(self.exchange(&os::endorse_set_commit(certificate)).await?)
324            .map(|_| ())
325    }
326
327    /// Onboard from the host. **Sends the PIN and recovery phrase in clear
328    /// over USB**: test devices only.
329    pub async fn onboard(
330        &self,
331        identity: OnboardIdentity,
332        pin: &str,
333        prefix: &str,
334        passphrase: &str,
335        words: &str,
336    ) -> Result<()> {
337        self.check(
338            self.exchange(&os::onboard(identity, pin, prefix, passphrase, words)?)
339                .await?,
340        )
341        .map(|_| ())
342    }
343
344    /// Factory personalization: device public key, serial and certificate
345    /// request (devices not yet personalized only).
346    pub async fn factory_get_device_public_key(&self) -> Result<Vec<u8>> {
347        self.check(self.exchange(&os::GET_DEVICE_PUBLIC_KEY).await?)
348    }
349
350    /// Factory test command (`E0 44`, devices without serial number only).
351    pub async fn factory_test(&self, p1: u8, p2: u8, data: &[u8]) -> Result<Vec<u8>> {
352        self.check(self.exchange(&os::factory_test(p1, p2, data)).await?)
353    }
354
355    /// Factory: read the cryptographic port rights (`E0 C5`).
356    pub async fn factory_get_cxport(&self) -> Result<Vec<u8>> {
357        self.check(self.exchange(&os::GET_CXPORT).await?)
358    }
359
360    /// Factory: `SET_CERTIFICATE` (`E0 41`, raw payload).
361    pub async fn factory_set_certificate(&self, p2: u8, data: &[u8]) -> Result<Vec<u8>> {
362        self.check(
363            self.exchange(&Apdu::with_data(0xe0, 0x41, 0x00, p2, data.to_vec()))
364                .await?,
365        )
366    }
367
368    /// Factory: `SET_CXPORT` (`E0 C4`, raw payload).
369    pub async fn factory_set_cxport(&self, data: &[u8]) -> Result<Vec<u8>> {
370        self.check(
371            self.exchange(&Apdu::with_data(0xe0, 0xc4, 0x00, 0x00, data.to_vec()))
372                .await?,
373        )
374    }
375
376    /// Query one battery metric (raw payload).
377    pub async fn get_battery_raw(&self, kind: BatteryStatusKind) -> Result<Vec<u8>> {
378        self.send(&os::get_battery_status(kind)).await
379    }
380
381    /// Battery summary (models with a battery only).
382    pub async fn get_battery_status(&self) -> Result<BatteryStatus> {
383        let mut st = BatteryStatus::default();
384        let p = self.get_battery_raw(BatteryStatusKind::Percentage).await?;
385        st.percentage = p.first().copied().filter(|v| *v <= 100);
386        if let Ok(v) = self.get_battery_raw(BatteryStatusKind::Voltage).await
387            && v.len() >= 2
388        {
389            st.voltage_mv = Some(u16::from_be_bytes([v[0], v[1]]));
390        }
391        if let Ok(v) = self.get_battery_raw(BatteryStatusKind::Temperature).await {
392            st.temperature_c = v.first().map(|b| *b as i8);
393        }
394        if let Ok(v) = self.get_battery_raw(BatteryStatusKind::Current).await {
395            st.current_ma = v.first().map(|b| *b as i8);
396        }
397        if let Ok(v) = self.get_battery_raw(BatteryStatusKind::Flags).await
398            && v.len() >= 4
399        {
400            let f = u32::from_be_bytes([v[0], v[1], v[2], v[3]]);
401            st.raw_flags = Some(f);
402            st.charging = f & 0x1 != 0;
403            st.usb_connected = f & 0x2 != 0;
404            st.ble_connected = f & 0x4 != 0;
405        }
406        Ok(st)
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use crate::transport::mock::MockTransport;
414
415    fn block_on<F: core::future::Future>(f: F) -> F::Output {
416        tokio::runtime::Builder::new_current_thread()
417            .build()
418            .unwrap()
419            .block_on(f)
420    }
421
422    #[test]
423    fn list_apps_paging() {
424        let mut page = String::from("01");
425        page += &format!("{:02x}", 70 + 3);
426        page += "00100000";
427        page += &"aa".repeat(32);
428        page += &"bb".repeat(32);
429        page += "03657468";
430        let t = MockTransport::new()
431            .expect("e0de000000", &format!("{page}9000"))
432            .expect("e0df000000", "9000");
433        let d = Device::new(t);
434        let apps = block_on(d.list_apps()).unwrap();
435        assert_eq!(apps.len(), 1);
436        assert_eq!(apps[0].name, "eth");
437        assert!(d.transport().is_done());
438    }
439
440    #[test]
441    fn dashboard_detection() {
442        let t = MockTransport::new().expect("b001000000", "6e01");
443        assert!(block_on(Device::new(t).is_on_dashboard()).unwrap());
444    }
445}