use std::io::Write;
use std::time::Duration;
use clap::{Parser, Subcommand};
use lsdk::apps::{btc, eth, sol};
use lsdk::hsm::HsmEvent;
use lsdk::manager::Manager;
use lsdk::path::DerivationPath;
use lsdk::transport::hid::HidTransport;
use lsdk::{Device, Error, Result};
use serde::Serialize;
#[derive(Parser)]
#[command(
name = "lsdk",
version,
about = "Manage and use hardware wallets over USB HID"
)]
struct Cli {
#[arg(long, global = true)]
device: Option<String>,
#[arg(long, global = true, default_value = lsdk::manager::api::DEFAULT_BASE_URL)]
api: String,
#[arg(long, global = true, default_value = lsdk::hsm::DEFAULT_BASE_URL)]
hsm: String,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
List,
Info,
Apps,
Catalog {
#[arg(long)]
target_id: Option<String>,
#[arg(long)]
version: Option<String>,
},
Genuine {
#[arg(long)]
offline: bool,
#[arg(long = "issuer-key")]
issuer_keys: Vec<String>,
},
Install { name: String },
Uninstall { name: String },
Open { name: String },
Quit,
Name,
Rename { name: String },
Battery,
Reset,
Mac,
ChargeLimit { percent: u8 },
Lang {
#[command(subcommand)]
cmd: LangCmd,
},
Endorse {
#[command(subcommand)]
cmd: EndorseCmd,
},
Onboard {
#[arg(long, default_value = "main")]
identity: String,
#[arg(long, default_value = "")]
pin: String,
#[arg(long, default_value = "")]
words: String,
#[arg(long, default_value = "")]
passphrase: String,
#[arg(long, default_value = "")]
prefix: String,
#[arg(long)]
i_understand_the_seed_travels_over_usb: bool,
},
Factory {
#[command(subcommand)]
cmd: FactoryCmd,
},
Ca {
#[command(subcommand)]
cmd: CaCmd,
},
App {
#[command(subcommand)]
cmd: AppCmd,
},
Meminfo {
#[arg(long)]
key: Option<std::path::PathBuf>,
},
Screensaver {
#[command(subcommand)]
cmd: ScreensaverCmd,
},
Firmware {
#[command(subcommand)]
cmd: FirmwareCmd,
},
Apdu { hex: String },
Eth {
#[command(subcommand)]
cmd: EthCmd,
},
Btc {
#[command(subcommand)]
cmd: BtcCmd,
},
Sol {
#[command(subcommand)]
cmd: SolCmd,
},
}
#[derive(Subcommand)]
enum LangCmd {
Available,
List,
Install { language: String },
Remove,
InstallFile { file: std::path::PathBuf },
}
#[derive(Subcommand)]
enum EndorseCmd {
Info { slot: u8 },
Setup {
slot: u8,
#[arg(long)]
owner_key: Option<std::path::PathBuf>,
#[arg(long)]
certificate: Option<String>,
},
Reset {
#[arg(long)]
key: Option<std::path::PathBuf>,
},
}
#[derive(Subcommand)]
enum FactoryCmd {
Test {
p1: String,
#[arg(default_value = "0")]
p2: String,
#[arg(default_value = "")]
data: String,
},
PublicKey,
Cxport,
}
#[derive(Subcommand)]
enum CaCmd {
Keygen {
#[arg(long)]
force: bool,
#[arg(long)]
key: Option<std::path::PathBuf>,
},
Show {
#[arg(long)]
key: Option<std::path::PathBuf>,
},
Install {
#[arg(long, default_value = "lsdk dev")]
name: String,
#[arg(long)]
key: Option<std::path::PathBuf>,
#[arg(long)]
public: Option<String>,
#[arg(long)]
recover: bool,
},
Remove {
#[arg(long)]
recover: bool,
#[arg(long, default_value = "lsdk dev")]
name: String,
#[arg(long)]
key: Option<std::path::PathBuf>,
#[arg(long)]
public: Option<String>,
},
}
#[derive(Subcommand)]
enum AppCmd {
Load {
file: std::path::PathBuf,
#[arg(long)]
key: Option<std::path::PathBuf>,
#[arg(long)]
no_replace: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
sign: bool,
#[arg(long)]
sign_key: Option<std::path::PathBuf>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
flags: Option<String>,
#[arg(long)]
api_level: Option<u8>,
#[arg(long)]
data_size: Option<u32>,
#[arg(long)]
install_params_size: Option<u32>,
},
Delete {
name: String,
#[arg(long)]
hash: bool,
#[arg(long)]
key: Option<std::path::PathBuf>,
},
DeleteAll {
#[arg(long)]
key: Option<std::path::PathBuf>,
},
List {
#[arg(long)]
key: Option<std::path::PathBuf>,
},
}
#[derive(Subcommand)]
enum ScreensaverCmd {
Get,
Set {
text: String,
#[arg(long, default_value = "classic")]
animation: String,
#[arg(long, default_value = "normal")]
speed: String,
},
Reset,
}
#[derive(Subcommand)]
enum FirmwareCmd {
Check,
Update,
Repair,
Hash {
#[arg(long)]
key: Option<std::path::PathBuf>,
#[arg(long, default_value = "0")]
address: String,
#[arg(long, default_value = "0")]
length: String,
},
}
#[derive(Subcommand)]
enum EthCmd {
Address {
#[arg(long, default_value = eth::DEFAULT_PATH)]
path: String,
#[arg(long)]
display: bool,
},
SignTx {
#[arg(long, default_value = eth::DEFAULT_PATH)]
path: String,
#[arg(long, default_value_t = 1)]
chain_id: u64,
#[arg(long)]
nonce: u64,
#[arg(long)]
to: String,
#[arg(long, default_value = "0")]
value: String,
#[arg(long, default_value_t = 21000)]
gas: u64,
#[arg(long)]
max_fee: Option<String>,
#[arg(long, default_value = "1")]
priority_fee: String,
#[arg(long)]
gas_price: Option<String>,
#[arg(long)]
data: Option<String>,
#[arg(long)]
token: Option<String>,
#[arg(long, default_value_t = 18)]
decimals: u32,
},
SignRaw {
#[arg(long, default_value = eth::DEFAULT_PATH)]
path: String,
hex: String,
},
SignMessage {
#[arg(long, default_value = eth::DEFAULT_PATH)]
path: String,
message: String,
},
}
#[derive(Subcommand)]
enum BtcCmd {
Xpub {
#[arg(long, default_value = "segwit")]
kind: String,
#[arg(long, default_value_t = 0)]
account: u32,
#[arg(long)]
testnet: bool,
#[arg(long)]
display: bool,
},
Address {
#[arg(long, default_value = "segwit")]
kind: String,
#[arg(long, default_value_t = 0)]
account: u32,
#[arg(long, default_value_t = 0)]
index: u32,
#[arg(long)]
change: bool,
#[arg(long)]
testnet: bool,
#[arg(long)]
display: bool,
},
SignMessage {
#[arg(long, default_value = "m/84'/0'/0'/0/0")]
path: String,
message: String,
},
SignPsbt {
#[arg(long, default_value = "segwit")]
kind: String,
#[arg(long, default_value_t = 0)]
account: u32,
#[arg(long)]
testnet: bool,
#[arg(long)]
no_finalize: bool,
psbt: String,
},
}
#[derive(Subcommand)]
enum SolCmd {
Address {
#[arg(long, default_value = sol::DEFAULT_PATH)]
path: String,
#[arg(long)]
display: bool,
},
Transfer {
#[arg(long, default_value = sol::DEFAULT_PATH)]
path: String,
#[arg(long)]
to: String,
#[arg(long)]
amount: String,
#[arg(long)]
blockhash: String,
},
}
fn print<T: Serialize>(v: &T) {
println!("{}", serde_json::to_string_pretty(v).unwrap_or_default());
}
fn progress(ev: HsmEvent) {
match ev {
HsmEvent::PermissionRequested => eprintln!("> allow the secure connection on the device"),
HsmEvent::PermissionGranted => eprintln!("> secure connection allowed"),
HsmEvent::Progress { index, total } => {
eprint!("\r> {index}/{total}");
if index == total {
eprintln!();
}
let _ = std::io::stderr().flush();
}
HsmEvent::Warning { message } => eprintln!("> warning: {message}"),
_ => {}
}
}
fn open(cli: &Cli) -> Result<Device<HidTransport>> {
let t = match &cli.device {
Some(path) => {
let info = HidTransport::list()?
.into_iter()
.find(|d| &d.path == path)
.ok_or(Error::NoDevice)?;
HidTransport::open(&info)?
}
None => HidTransport::open_first()?,
};
Ok(Device::new(t))
}
fn path(s: &str) -> Result<DerivationPath> {
s.parse()
}
fn wei(gwei: &str) -> Result<num_bigint::BigInt> {
eth::parse_units(gwei, 9)
}
#[derive(Serialize)]
struct Info {
version: lsdk::os::OsVersion,
model: Option<&'static str>,
app: Option<lsdk::os::AppAndVersion>,
}
async fn run(cli: Cli) -> Result<()> {
let manager = Manager::new(cli.api.clone(), cli.hsm.clone());
match &cli.cmd {
Cmd::List => print(&HidTransport::list()?),
Cmd::Info => {
let d = open(&cli)?;
let app = d.get_app_and_version().await.ok();
if let Some(a) = app.as_ref().filter(|a| !a.is_dashboard()) {
print(a);
eprintln!("(app {} is open: quit it to see firmware details)", a.name);
return Ok(());
}
let version = d.get_version().await?;
print(&Info {
model: version.model().map(|m| m.name()),
version,
app,
});
}
Cmd::Apps => print(&manager.installed_apps(&open(&cli)?).await?),
Cmd::Catalog { target_id, version } => {
let apps = match (target_id, version) {
(Some(t), Some(v)) => {
let t = if let Some(h) = t.strip_prefix("0x") {
u32::from_str_radix(h, 16)
} else {
t.parse()
}
.map_err(|_| Error::InvalidInput("bad target id".into()))?;
manager.api.get_apps_by_target(t, v, 1).await?
}
_ => manager.catalog(&open(&cli)?.get_version().await?).await?,
};
let mut out = std::io::stdout().lock();
for a in apps {
let line = format!(
"{:<32} {:<10} {:>8} {}",
a.version_name,
a.version,
a.bytes.unwrap_or(0),
a.hash
);
if writeln!(out, "{line}").is_err() {
break;
}
}
}
Cmd::Genuine {
offline: true,
issuer_keys,
} => {
let extra = issuer_keys
.iter()
.enumerate()
.map(|(i, k)| {
hex::decode(k.trim_start_matches("0x"))
.map(|b| (format!("--issuer-key #{}", i + 1), b))
.map_err(|e| Error::InvalidInput(format!("issuer key: {e}")))
})
.collect::<Result<Vec<_>>>()?;
let att = lsdk::attestation::offline_genuine_check(&open(&cli)?, &extra, &mut |ev| {
use lsdk::attestation::AttestationEvent::*;
match ev {
PermissionRequested { host_public_key } => eprintln!(
"> the device shows this manager key, check it matches, then allow:\n {host_public_key}"
),
PermissionGranted => eprintln!("> allowed"),
Attesting => eprintln!("> device is signing its attestation..."),
}
})
.await?;
print(&att);
println!(
"{}",
if att.genuine {
"genuine (offline attestation)"
} else {
"NOT VERIFIED"
}
);
if !att.genuine {
std::process::exit(2);
}
}
Cmd::Genuine { .. } => {
let ok = manager.genuine_check(&open(&cli)?, &mut progress).await?;
println!("{}", if ok { "genuine" } else { "NOT GENUINE" });
if !ok {
std::process::exit(2);
}
}
Cmd::Install { name } => {
manager
.install_app_with_deps(&open(&cli)?, name, None, &mut |app, ev| {
if matches!(ev, HsmEvent::Opened) {
eprintln!("installing {app}");
}
progress(ev)
})
.await?;
println!("installed {name}");
}
Cmd::Uninstall { name } => {
manager
.uninstall_app_by_name(&open(&cli)?, name, &mut progress)
.await?;
println!("uninstalled {name}");
}
Cmd::Open { name } => open(&cli)?.open_app(name).await?,
Cmd::Quit => open(&cli)?.quit_app().await?,
Cmd::Name => println!("{}", open(&cli)?.get_device_name().await?),
Cmd::Rename { name } => open(&cli)?.set_device_name(name).await?,
Cmd::Reset => {
open(&cli)?.reset().await?;
println!("dashboard reset");
}
Cmd::Mac => println!("{}", open(&cli)?.get_device_mac().await?),
Cmd::ChargeLimit { percent } => {
open(&cli)?.set_battery_charge_limit(*percent).await?;
println!("charge limit set to {percent}%");
}
Cmd::Lang { cmd } => {
let d = open(&cli)?;
match cmd {
LangCmd::Available => {
let os = d.get_version().await?;
for p in manager.language_packs(&os).await? {
println!(
"{:<12} {:<8} {:>6} bytes",
p.language,
p.version,
p.bytes.unwrap_or(0)
);
}
}
LangCmd::List => {
for p in d.list_language_packs().await? {
println!(
"{} ({}) {} bytes",
lsdk::os::language_name(p.id),
p.id,
p.size
);
}
}
LangCmd::Install { language } => {
let os = d.get_version().await?;
let pack = manager
.language_packs(&os)
.await?
.into_iter()
.find(|p| p.language.eq_ignore_ascii_case(language))
.ok_or_else(|| {
Error::NotFound(format!("no {language} pack for this firmware"))
})?;
eprintln!("> confirm on the device if asked");
manager
.install_language_pack(&d, &pack, &mut |i, n| {
eprint!("\r> {i}/{n}");
if i == n {
eprintln!();
}
})
.await?;
println!("{} {} installed", pack.language, pack.version);
}
LangCmd::InstallFile { file } => {
let script = std::fs::read_to_string(file)
.map_err(|e| Error::InvalidInput(format!("{}: {e}", file.display())))?;
eprintln!("> confirm on the device if asked");
d.install_language_script(&script, &mut |i, n| {
eprint!("\r> {i}/{n}");
if i == n {
eprintln!();
}
})
.await?;
println!("language pack installed");
}
LangCmd::Remove => {
manager.remove_language_packs(&d).await?;
println!("language packs removed");
}
}
}
Cmd::Endorse { cmd } => {
use lsdk::os::EndorsementSlot;
let slot = |n: u8| match n {
1 => Ok(EndorsementSlot::Slot1),
2 => Ok(EndorsementSlot::Slot2),
_ => Err(Error::InvalidInput("slot must be 1 or 2".into())),
};
let d = open(&cli)?;
match cmd {
EndorseCmd::Reset { key } => {
let root = root_key(key.as_ref())?;
let mut chan =
lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
eprintln!("> confirm on the device");
chan.reset_endorsement().await?;
println!("endorsement reset");
}
EndorseCmd::Info { slot: n } => print(&d.endorsement_info(slot(*n)?).await?),
EndorseCmd::Setup {
slot: n,
owner_key,
certificate,
} => {
let (public, _device_sig) = d.endorse_start(slot(*n)?).await?;
eprintln!("endorsement key: {}", hex::encode(&public));
let cert = match certificate {
Some(c) => hex::decode(c.trim_start_matches("0x"))
.map_err(|e| Error::InvalidInput(e.to_string()))?,
None => {
let owner =
read_key(&owner_key.clone().unwrap_or_else(default_ca_path))?;
lsdk::scp::endorsement_certificate(&owner, &public)?
}
};
d.endorse_commit(&cert).await?;
println!("slot {n} endorsed, certificate {}", hex::encode(&cert));
}
}
}
Cmd::Onboard {
identity,
pin,
words,
passphrase,
prefix,
i_understand_the_seed_travels_over_usb,
} => {
use lsdk::os::OnboardIdentity;
if !i_understand_the_seed_travels_over_usb {
return Err(Error::InvalidInput(
"host onboarding sends the PIN and recovery phrase in clear over USB; \
pass --i-understand-the-seed-travels-over-usb (test devices only)"
.into(),
));
}
let id = match identity.as_str() {
"main" => OnboardIdentity::Main,
"alternate" => OnboardIdentity::Alternate,
"temporary" => OnboardIdentity::Temporary,
_ => {
return Err(Error::InvalidInput(
"identity: main, alternate or temporary".into(),
));
}
};
eprintln!(
"> confirm on the device, then enter your PIN; keep it plugged in while it processes"
);
match open(&cli)?
.onboard(id, pin, prefix, passphrase, words)
.await
{
Ok(()) => {}
Err(Error::Disconnected | Error::Transport(_)) => {
eprintln!("> device re-enumerated, waiting for it to finish processing...");
std::thread::sleep(Duration::from_secs(1));
let t = HidTransport::wait_and_open(Duration::from_secs(120))?;
let raw = t.read_pending_response(Duration::from_secs(180))?;
let r = lsdk::Response::from_raw(raw)?;
match r.sw {
lsdk::StatusWord::OK => {}
sw if sw.is_refused() => return Err(Error::UserRefused),
sw => return Err(sw.into()),
}
}
Err(e) => return Err(e),
}
println!("onboarded ({identity})");
}
Cmd::Factory { cmd } => {
let d = open(&cli)?;
let num = |s: &str| {
u8::from_str_radix(s.trim_start_matches("0x"), 16)
.map_err(|e| Error::InvalidInput(e.to_string()))
};
let out = match cmd {
FactoryCmd::Test { p1, p2, data } => {
let data = hex::decode(data).map_err(|e| Error::InvalidInput(e.to_string()))?;
d.factory_test(num(p1)?, num(p2)?, &data).await?
}
FactoryCmd::PublicKey => d.factory_get_device_public_key().await?,
FactoryCmd::Cxport => d.factory_get_cxport().await?,
};
println!("{}", hex::encode(out));
}
Cmd::Ca { cmd } => match cmd {
CaCmd::Keygen { force, key } => {
let path = key.clone().unwrap_or_else(default_ca_path);
if path.exists() && !force {
return Err(Error::InvalidInput(format!(
"{} exists (use --force to replace it)",
path.display()
)));
}
let k = lsdk::scp::RootKey::generate()?;
write_key(&path, &k)?;
println!("CA key written to {}", path.display());
println!("public key: {}", hex::encode(k.public_key()));
}
CaCmd::Show { key } => {
let k = read_key(&key.clone().unwrap_or_else(default_ca_path))?;
println!("{}", hex::encode(k.public_key()));
}
CaCmd::Install {
name,
key,
public,
recover,
} => {
let public = match public {
Some(p) => hex::decode(p.trim_start_matches("0x"))
.map_err(|e| Error::InvalidInput(e.to_string()))?,
None => read_key(&key.clone().unwrap_or_else(default_ca_path))?
.public_key()
.to_vec(),
};
let d = open(&cli)?;
let root = lsdk::scp::RootKey::generate()?;
let mode = if *recover {
lsdk::scp::ScpMode::Recover
} else {
lsdk::scp::ScpMode::Default
};
let mut chan =
lsdk::scp::SecureChannel::open_mode(&d, &root, mode, &mut scp_progress).await?;
eprintln!("> confirm installing the CA \"{name}\" on the device");
if *recover {
chan.recover_set_ca(name, &public).await.map_err(ca_hint)?;
} else {
chan.setup_custom_ca(name, &public).await.map_err(ca_hint)?;
}
println!("custom CA \"{name}\" installed: {}", hex::encode(&public));
}
CaCmd::Remove {
recover,
name,
key,
public,
} => {
let d = open(&cli)?;
let root = lsdk::scp::RootKey::generate()?;
if *recover {
let public = match public {
Some(p) => hex::decode(p.trim_start_matches("0x"))
.map_err(|e| Error::InvalidInput(e.to_string()))?,
None => read_key(&key.clone().unwrap_or_else(default_ca_path))?
.public_key()
.to_vec(),
};
let mut chan = lsdk::scp::SecureChannel::open_mode(
&d,
&root,
lsdk::scp::ScpMode::Recover,
&mut scp_progress,
)
.await?;
eprintln!("> confirm removing the custom CA on the device");
chan.recover_delete_ca(name, &public)
.await
.map_err(ca_hint)?;
} else {
let mut chan =
lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
eprintln!("> confirm removing the custom CA on the device");
chan.reset_custom_ca().await.map_err(ca_hint)?;
}
println!("custom CA removed");
}
},
Cmd::App { cmd } => match cmd {
AppCmd::Load {
file,
key,
no_replace,
dry_run,
sign,
sign_key,
name,
flags,
api_level,
data_size,
install_params_size,
} => {
let raw = std::fs::read(file)
.map_err(|e| Error::InvalidInput(format!("{}: {e}", file.display())))?;
let mut img = if raw.starts_with(b"\x7fELF") {
lsdk::loader::AppImage::from_elf(&raw)?
} else {
let name = name.clone().ok_or_else(|| {
Error::InvalidInput("--name is required for HEX files".into())
})?;
lsdk::loader::AppImage::from_ihex(&String::from_utf8_lossy(&raw), &name)?
};
if let Some(f) = flags {
img.flags = u32::from_str_radix(f.trim_start_matches("0x"), 16)
.map_err(|e| Error::InvalidInput(format!("--flags: {e}")))?;
}
if api_level.is_some() {
img.api_level = *api_level;
}
if let Some(v) = data_size {
img.data_size = *v;
}
if let Some(v) = install_params_size {
img.install_params_size = *v;
}
eprintln!(
"{} {} (flags 0x{:x}, api {:?}, {} bytes, target {})",
img.name,
img.version.as_deref().unwrap_or(""),
img.flags,
img.api_level,
img.image_size(),
img.target_id
.map(|t| format!("0x{t:08x}"))
.unwrap_or_else(|| "?".into())
);
if *dry_run {
println!("create_app {}", hex::encode(img.create_app_params()?));
if let Some(t) = img.target_id {
println!("app hash {}", hex::encode(img.app_hash(t)?));
}
return Ok(());
}
let root = read_key(&key.clone().unwrap_or_else(default_ca_path)).map_err(|e| {
Error::InvalidInput(format!(
"{e}\nSideloading needs the custom CA key: run `lsdk ca keygen`, then `lsdk ca install` with the device in recovery mode"
))
})?;
let d = open(&cli)?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
let signer = match (sign_key, sign) {
(Some(p), _) => Some(read_key(p)?),
(None, true) => Some(root.clone()),
(None, false) => None,
};
let opts = lsdk::loader::InstallOptions {
replace: !no_replace,
signer: signer.as_ref(),
};
lsdk::loader::install_app_with(&mut chan, &img, &opts, &mut |sent, total| {
eprint!("\r> loading {sent}/{total}");
if sent == total {
eprintln!();
}
})
.await
.map_err(|e| match e {
Error::Status(sw) if sw.0 == 0x6a80 || sw.0 == 0x6a81 => {
Error::App(format!("{e}: an app with this name is already installed"))
}
Error::Status(sw) if sw.0 == 0x6a84 || sw.0 == 0x6a85 => {
Error::App(format!("{e}: not enough space"))
}
e => e,
})?;
println!("installed {}", img.name);
}
AppCmd::Delete { name, hash, key } => {
let d = open(&cli)?;
let root = root_key(key.as_ref())?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
if *hash {
let h: [u8; 32] = hex::decode(name)
.ok()
.and_then(|v| v.try_into().ok())
.ok_or_else(|| Error::InvalidInput("hash must be 32 bytes hex".into()))?;
chan.delete_app_by_hash(&h).await?;
} else {
chan.delete_app(name).await?;
}
println!("deleted {name}");
}
AppCmd::DeleteAll { key } => {
let d = open(&cli)?;
let root = root_key(key.as_ref())?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
eprintln!("> confirm deleting all apps on the device");
chan.delete_all_apps().await?;
println!("all apps deleted");
}
AppCmd::List { key } => {
let d = open(&cli)?;
let root = root_key(key.as_ref())?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
print(&chan.list_apps().await?);
}
},
Cmd::Meminfo { key } => {
let d = open(&cli)?;
let root = root_key(key.as_ref())?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
print(&chan.get_memory_info().await?);
}
Cmd::Screensaver { cmd } => {
use lsdk::os::{ScreenSaverAnimation, ScreenSaverSpeed};
let d = open(&cli)?;
match cmd {
ScreensaverCmd::Get => match d.get_screen_saver().await? {
Some(s) => print(&s),
None => println!("default screensaver"),
},
ScreensaverCmd::Set {
text,
animation,
speed,
} => {
eprintln!("> confirm on the device");
d.set_screen_saver(
text,
ScreenSaverAnimation::parse(animation)?,
ScreenSaverSpeed::parse(speed)?,
)
.await?;
println!("screensaver set");
}
ScreensaverCmd::Reset => {
eprintln!("> confirm on the device");
d.reset_screen_saver().await?;
println!("default screensaver restored");
}
}
}
Cmd::Battery => print(&open(&cli)?.get_battery_status().await?),
Cmd::Firmware {
cmd:
FirmwareCmd::Hash {
key,
address,
length,
},
} => {
let hexnum = |s: &str| {
u32::from_str_radix(s.trim_start_matches("0x"), 16)
.map_err(|e| Error::InvalidInput(e.to_string()))
};
let (address, length) = (hexnum(address)?, hexnum(length)?);
let d = open(&cli)?;
let root = root_key(key.as_ref())?;
let mut chan = lsdk::scp::SecureChannel::open(&d, &root, &mut scp_progress).await?;
println!(
"{}",
hex::encode(chan.hash_firmware(address, length).await?)
);
}
Cmd::Firmware { cmd } => {
let d = open(&cli)?;
let os = d.get_version().await?;
if os.is_bootloader || matches!(cmd, FirmwareCmd::Repair) {
drop(d);
repair_loop(&manager).await?;
return Ok(());
}
let update = manager.check_firmware_update(&os).await?;
match (cmd, update) {
(FirmwareCmd::Repair | FirmwareCmd::Hash { .. }, _) => unreachable!(),
(_, None) => println!("firmware {} is up to date", os.se_version),
(FirmwareCmd::Check, Some(u)) => print(&u),
(FirmwareCmd::Update, Some(u)) => {
eprintln!(
"installing OS updater {} -> {}",
os.se_version, u.final_firmware.name
);
if !os.is_osu && !os.is_bootloader {
manager.install_osu(&d, &u, &mut progress).await?;
}
drop(d);
if u.should_flash_mcu {
for _ in 0..6 {
eprintln!("waiting for the device (bootloader)...");
let d =
Device::new(HidTransport::wait_and_open(Duration::from_secs(300))?);
let os = d.get_version().await?;
if !os.is_bootloader {
break;
}
let kind = manager.flash_mcu(&d, &u, &mut progress).await?;
eprintln!("flashed {kind:?}");
std::thread::sleep(Duration::from_secs(3));
}
}
println!("update sent, follow the instructions on the device");
}
}
}
Cmd::Apdu { hex } => {
let raw = hex::decode(hex.replace(' ', ""))
.map_err(|e| Error::InvalidInput(e.to_string()))?;
println!("{}", hex::encode(open(&cli)?.exchange_raw(&raw).await?));
}
Cmd::Eth { cmd } => {
let d = open(&cli)?;
let app = eth::EthApp::new(&d);
match cmd {
EthCmd::Address { path: p, display } => {
print(&app.get_address(&path(p)?, *display, false).await?)
}
EthCmd::SignTx {
path: p,
chain_id,
nonce,
to,
value,
gas,
max_fee,
priority_fee,
gas_price,
data,
token,
decimals,
} => {
let mut tx = eth::EvmTx::default();
tx.chain_id = *chain_id;
tx.nonce = *nonce;
tx.gas = *gas;
match token {
Some(token) => {
tx.to = token.clone();
tx.data =
eth::erc20_transfer_data(to, ð::parse_units(value, *decimals)?)?;
}
None => {
tx.to = to.clone();
tx.value = eth::parse_units(value, 18)?;
if let Some(h) = data {
tx.data = hex::decode(h.trim_start_matches("0x"))
.map_err(|e| Error::InvalidInput(e.to_string()))?;
}
}
}
if let Some(gp) = gas_price {
tx.tx_type = eth::EvmTxType::Legacy;
tx.gas_fee_cap = wei(gp)?;
} else {
tx.tx_type = eth::EvmTxType::Eip1559;
tx.gas_tip_cap = wei(priority_fee)?;
tx.gas_fee_cap = wei(max_fee.as_deref().ok_or_else(|| {
Error::InvalidInput(
"--max-fee (gwei) or --gas-price is required".into(),
)
})?)?;
}
app.sign_tx(&path(p)?, &mut tx).await?;
let raw = tx.to_bytes().map_err(|e| Error::App(format!("{e:?}")))?;
println!("0x{}", hex::encode(raw));
}
EthCmd::SignRaw { path: p, hex } => {
let raw = hex::decode(hex.trim_start_matches("0x"))
.map_err(|e| Error::InvalidInput(e.to_string()))?;
let sig = app.sign_transaction(&path(p)?, &raw).await?;
println!("0x{}", hex::encode(eth::assemble_signed(&raw, &sig)?));
}
EthCmd::SignMessage { path: p, message } => {
let sig = app
.sign_personal_message(&path(p)?, message.as_bytes())
.await?;
println!("0x{}", hex::encode(sig.to_rsv()));
}
}
}
Cmd::Btc { cmd } => {
let d = open(&cli)?;
let app = btc::BtcApp::new(&d);
match cmd {
BtcCmd::Xpub {
kind,
account,
testnet,
display,
} => {
let kind = btc::AddressType::parse(kind)?;
let p = kind.account_path(*testnet, *account);
let xpub = app.get_extended_pubkey(&p, *display).await?;
let fpr = app.get_master_fingerprint().await?;
println!("{}", btc::KeyInfo::new(fpr, p, xpub));
}
BtcCmd::Address {
kind,
account,
index,
change,
testnet,
display,
} => {
let kind = btc::AddressType::parse(kind)?;
let key = app.get_account_key(kind, *testnet, *account).await?;
println!(
"{}",
app.get_wallet_address_checked(
kind, *testnet, &key, *change, *index, *display
)
.await?
);
}
BtcCmd::SignMessage { path: p, message } => {
let sig = app.sign_message(&path(p)?, message.as_bytes()).await?;
println!("{}", outscript::base64::encode(&sig));
}
BtcCmd::SignPsbt {
kind,
account,
testnet,
no_finalize,
psbt,
} => {
let kind = btc::AddressType::parse(kind)?;
let bytes = outscript::psbt::Psbt::decode_base64(psbt.trim())
.map_err(|e| Error::InvalidInput(format!("{e:?}")))?;
let policy = app.default_policy(kind, *testnet, *account).await?;
let signed = app.sign_psbt_into(&bytes, &policy, None).await?;
if *no_finalize {
println!("{}", outscript::base64::encode(&signed));
} else {
println!("{}", hex::encode(btc::psbt::finalize_and_extract(&signed)?));
}
}
}
}
Cmd::Sol { cmd } => {
let d = open(&cli)?;
let app = sol::SolApp::new(&d);
match cmd {
SolCmd::Address { path: p, display } => {
println!("{}", app.get_address(&path(p)?, *display).await?)
}
SolCmd::Transfer {
path: p,
to,
amount,
blockhash,
} => {
let p = path(p)?;
let from = app.get_address(&p, false).await?;
let lamports: u64 = eth::parse_units(amount, 9)?
.try_into()
.map_err(|_| Error::InvalidInput("amount too large".into()))?;
let mut tx = sol::build_transfer(&from, to, lamports, blockhash)?;
app.sign_tx(&p, &mut tx).await?;
let raw = tx.to_bytes().map_err(|e| Error::App(format!("{e:?}")))?;
println!("{}", outscript::base64::encode(&raw));
}
}
}
}
Ok(())
}
async fn repair_loop(manager: &Manager) -> Result<()> {
for _ in 0..6 {
let d = Device::new(HidTransport::wait_and_open(Duration::from_secs(300))?);
let os = d.get_version().await?;
if !os.is_bootloader {
println!(
"device is out of bootloader mode (firmware {})",
os.se_version
);
return Ok(());
}
eprintln!(
"bootloader {}, installed firmware {}",
os.maj_min(),
os.se_version
);
let kind = manager.repair_mcu(&d, &mut progress).await?;
eprintln!("flashed {kind:?}, waiting for the device to restart...");
drop(d);
std::thread::sleep(Duration::from_secs(5));
}
Err(Error::App(
"device still in bootloader mode after several flashes".into(),
))
}
fn config_dir() -> std::path::PathBuf {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.unwrap_or_else(|| ".".into())
.join("lsdk")
}
fn default_ca_path() -> std::path::PathBuf {
config_dir().join("ca.key")
}
fn write_key(path: &std::path::Path, key: &lsdk::scp::RootKey) -> Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| Error::App(format!("{}: {e}", dir.display())))?;
}
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts
.open(path)
.map_err(|e| Error::App(format!("{}: {e}", path.display())))?;
writeln!(f, "{}", hex::encode(key.to_bytes())).map_err(|e| Error::App(e.to_string()))
}
fn read_key(path: &std::path::Path) -> Result<lsdk::scp::RootKey> {
let s = std::fs::read_to_string(path)
.map_err(|e| Error::InvalidInput(format!("{}: {e}", path.display())))?;
lsdk::scp::RootKey::from_hex(&s)
}
fn root_key(key: Option<&std::path::PathBuf>) -> Result<lsdk::scp::RootKey> {
match key {
Some(p) => read_key(p),
None if default_ca_path().exists() => read_key(&default_ca_path()),
None => lsdk::scp::RootKey::generate(),
}
}
fn scp_progress(ev: lsdk::scp::ScpEvent) {
match ev {
lsdk::scp::ScpEvent::PermissionRequested { host_public_key } => {
eprintln!("> allow the manager on the device; it shows this key:\n {host_public_key}")
}
lsdk::scp::ScpEvent::PermissionGranted => eprintln!("> allowed"),
lsdk::scp::ScpEvent::Opened => {}
}
}
fn ca_hint(e: Error) -> Error {
match e {
Error::Status(sw) if sw.0 == 0x661e => {
Error::App("the device must be in recovery mode".into())
}
Error::Status(sw) if sw.0 == 0x6982 => {
Error::App("a custom CA is already installed: remove it first (lsdk ca remove)".into())
}
Error::Status(sw) if sw.0 == 0x6a84 => Error::App("no custom CA is installed".into()),
e => e,
}
}
fn main() {
let cli = Cli::parse();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio runtime");
if let Err(e) = rt.block_on(run(cli)) {
eprintln!("error: {e}");
std::process::exit(1);
}
}