diff --git a/Cargo.lock b/Cargo.lock index 3f1fd2a..a43c544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7131,6 +7131,7 @@ dependencies = [ "hex", "iso_currency", "jubjub", + "minicbor", "nokhwa", "orchard", "pczt", diff --git a/Cargo.toml b/Cargo.toml index 5748fe5..46a96ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ iso_currency = { version = "0.5", features = ["with-serde"] } rust_decimal = "1" # PCZT QR codes +minicbor = { version = "0.19", optional = true } nokhwa = { version = "0.10", optional = true, features = ["input-native"] } qrcode = { version = "0.14", optional = true, default-features = false } rqrr = { version = "0.8", optional = true } @@ -64,7 +65,7 @@ tui-logger = { version = "0.14", optional = true, features = ["tracing-support"] [features] default = ["transparent-inputs"] -pczt-qr = ["dep:nokhwa", "dep:qrcode", "dep:rqrr", "dep:ur"] +pczt-qr = ["dep:minicbor", "dep:nokhwa", "dep:qrcode", "dep:rqrr", "dep:ur"] transparent-inputs = [ "zcash_client_sqlite/transparent-inputs", ] diff --git a/src/commands.rs b/src/commands.rs index cc5cc1f..c38b1fe 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -13,3 +13,6 @@ pub(crate) mod reset; pub(crate) mod send; pub(crate) mod sync; pub(crate) mod upgrade; + +#[cfg(feature = "pczt-qr")] +pub(crate) mod keystone; diff --git a/src/commands/keystone.rs b/src/commands/keystone.rs new file mode 100644 index 0000000..dd80258 --- /dev/null +++ b/src/commands/keystone.rs @@ -0,0 +1,160 @@ +use std::time::Duration; + +use anyhow::anyhow; +use gumdrop::Options; +use minicbor::data::{Int, Tag}; +use qrcode::{render::unicode, QrCode}; +use tokio::io::{stdout, AsyncWriteExt}; +use uuid::Uuid; +use zcash_client_backend::data_api::{Account, WalletRead}; +use zcash_client_sqlite::{AccountUuid, WalletDb}; + +use crate::{config::WalletConfig, data::get_db_paths, ShutdownListener}; + +const ZCASH_ACCOUNTS: &str = "zcash-accounts"; + +#[derive(Debug, Options)] +pub(crate) enum Command { + #[options(help = "emulate the Keystone enrollment protocol")] + Enroll(Enroll), +} + +// Options accepted for the `keystone enroll` command +#[derive(Debug, Options)] +pub(crate) struct Enroll { + #[options(free, required, help = "the UUID of the account to enroll")] + account_id: Uuid, + + #[options( + help = "the duration in milliseconds to wait between QR codes (default is 500)", + default = "500" + )] + interval: u64, +} + +impl Enroll { + pub(crate) async fn run( + self, + mut shutdown: ShutdownListener, + wallet_dir: Option, + ) -> Result<(), anyhow::Error> { + let config = WalletConfig::read(wallet_dir.as_ref())?; + let params = config.network(); + + let (_, db_data) = get_db_paths(wallet_dir.as_ref()); + let db_data = WalletDb::for_path(db_data, params)?; + let account_id = AccountUuid::from_uuid(self.account_id); + let account = db_data + .get_account(account_id)? + .ok_or(anyhow!("Account missing: {:?}", account_id))?; + + let key_derivation = account + .source() + .key_derivation() + .ok_or(anyhow!("Cannot enroll account without spending key"))?; + + let mut accounts_packet = vec![]; + minicbor::encode( + &ZcashAccounts { + seed_fingerprint: key_derivation.seed_fingerprint().to_bytes(), + accounts: vec![ZcashUnifiedFullViewingKey { + ufvk: account + .ufvk() + .ok_or(anyhow!("Cannot enroll account without UFVK"))? + .encode(¶ms), + index: key_derivation.account_index().into(), + name: account.name().map(String::from), + }], + }, + &mut accounts_packet, + ) + .map_err(|e| anyhow!("Failed to encode accounts packet: {:?}", e))?; + + let mut encoder = ur::Encoder::new(&accounts_packet, 100, ZCASH_ACCOUNTS) + .map_err(|e| anyhow!("Failed to build UR encoder: {e}"))?; + + let mut stdout = stdout(); + let mut interval = tokio::time::interval(Duration::from_millis(self.interval)); + loop { + interval.tick().await; + + if shutdown.requested() { + return Ok(()); + } + + let ur = encoder + .next_part() + .map_err(|e| anyhow!("Failed to encode PCZT part: {e}"))?; + let code = QrCode::new(&ur.to_uppercase())?; + let string = code + .render::() + .dark_color(unicode::Dense1x2::Dark) + .light_color(unicode::Dense1x2::Light) + .quiet_zone(false) + .build(); + + stdout.write_all(format!("{string}\n").as_bytes()).await?; + stdout.write_all(format!("{ur}\n\n\n\n").as_bytes()).await?; + stdout.flush().await?; + } + } +} + +struct ZcashAccounts { + seed_fingerprint: [u8; 32], + accounts: Vec, +} + +struct ZcashUnifiedFullViewingKey { + ufvk: String, + index: u32, + name: Option, +} + +const SEED_FINGERPRINT: u8 = 1; +const ACCOUNTS: u8 = 2; +const ZCASH_UNIFIED_FULL_VIEWING_KEY: u64 = 49203; +const UFVK: u8 = 1; +const INDEX: u8 = 2; +const NAME: u8 = 3; + +impl minicbor::Encode for ZcashAccounts { + fn encode( + &self, + e: &mut minicbor::Encoder, + _ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + e.map(2)?; + + e.int(Int::from(SEED_FINGERPRINT))? + .bytes(&self.seed_fingerprint)?; + + e.int(Int::from(ACCOUNTS))? + .array(self.accounts.len() as u64)?; + for account in &self.accounts { + e.tag(Tag::Unassigned(ZCASH_UNIFIED_FULL_VIEWING_KEY))?; + ZcashUnifiedFullViewingKey::encode(account, e, _ctx)?; + } + + Ok(()) + } +} + +impl minicbor::Encode for ZcashUnifiedFullViewingKey { + fn encode( + &self, + e: &mut minicbor::Encoder, + _ctx: &mut C, + ) -> Result<(), minicbor::encode::Error> { + e.map(2 + u64::from(self.name.is_some()))?; + + e.int(Int::from(UFVK))?.str(&self.ufvk)?; + e.int(Int::from(INDEX))?.u32(self.index)?; + + if let Some(name) = &self.name { + e.int(Int::from(NAME))?.str(name)?; + } + + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 6be67c3..42d5486 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,6 +81,10 @@ enum Command { #[options(help = "send funds using PCZTs")] Pczt(commands::pczt::Command), + + #[cfg(feature = "pczt-qr")] + #[options(help = "emulate a Keystone device")] + Keystone(commands::keystone::Command), } fn main() -> Result<(), anyhow::Error> { @@ -166,6 +170,12 @@ fn main() -> Result<(), anyhow::Error> { #[cfg(feature = "pczt-qr")] commands::pczt::Command::FromQr(command) => command.run(shutdown).await, }, + #[cfg(feature = "pczt-qr")] + Some(Command::Keystone(command)) => match command { + commands::keystone::Command::Enroll(command) => { + command.run(shutdown, opts.wallet_dir).await + } + }, None => Ok(()), } })