From da00396cd7eb42f50611a7044b5dcb14e1675167 Mon Sep 17 00:00:00 2001 From: Giacomo Pasini Date: Tue, 25 May 2021 15:08:49 +0200 Subject: [PATCH 1/2] port kedqr to catalyst-toolbox --- Cargo.lock | 21 ++++ Cargo.toml | 5 + src/bin/cli/kedqr/mod.rs | 71 ++++++++++++++ src/bin/cli/mod.rs | 4 + src/kedqr/mod.rs | 206 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 6 files changed, 308 insertions(+) create mode 100644 src/bin/cli/kedqr/mod.rs create mode 100644 src/kedqr/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 3fd8c4e2..454da9ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -320,10 +320,14 @@ dependencies = [ "chrono", "csv", "fixed", + "hex", + "image", "jcli", "jormungandr-lib", "jormungandr-testing-utils", "log", + "qrcode", + "quircs", "rand 0.8.3", "rand_chacha 0.3.0", "regex", @@ -334,6 +338,7 @@ dependencies = [ "sscanf", "stderrlog", "structopt", + "symmetric-cipher", "thiserror", "url", "versionisator", @@ -487,6 +492,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "checked_int_cast" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17cc5e6b5ab06331c33589842070416baa137e8b0eb912b008cfd4a78ada7919" + [[package]] name = "chrono" version = "0.4.19" @@ -2605,6 +2616,16 @@ dependencies = [ "url", ] +[[package]] +name = "qrcode" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d2f1455f3630c6e5107b4f2b94e74d76dea80736de0981fd27644216cff57f" +dependencies = [ + "checked_int_cast", + "image", +] + [[package]] name = "qrcodegen" version = "1.6.0" diff --git a/Cargo.toml b/Cargo.toml index 1db59712..d0896c69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,11 @@ serde_yaml = "0.8.17" sscanf = "0.1" thiserror = "1.0" url = "2.2" +hex = "0.4" +image = "0.23.12" +qrcode = "0.12" +quircs = "0.10.0" +symmetric-cipher = { git = "https://github.com/input-output-hk/chain-wallet-libs.git", branch = "master" } [dev-dependencies] rand_chacha = "0.3" diff --git a/src/bin/cli/kedqr/mod.rs b/src/bin/cli/kedqr/mod.rs new file mode 100644 index 00000000..bb2abc4d --- /dev/null +++ b/src/bin/cli/kedqr/mod.rs @@ -0,0 +1,71 @@ +use catalyst_toolbox::kedqr::{KeyQrCode, QRPin}; +use chain_crypto::bech32::Bech32; +use chain_crypto::{Ed25519Extended, SecretKey}; +use std::{ + error::Error, + fs::OpenOptions, + io::{BufRead, BufReader}, + path::PathBuf, +}; +use structopt::StructOpt; + +/// QCode CLI toolkit +#[derive(Debug, PartialEq, StructOpt)] +#[structopt(rename_all = "kebab-case")] +pub struct QRcodeApp { + /// Path to file containing ed25519extended bech32 value. + #[structopt(short, long, parse(from_os_str))] + input: PathBuf, + /// Path to file to save qr code output, if not provided console output will be attempted. + #[structopt(short, long, parse(from_os_str))] + output: Option, + /// Pin code. 4-digit number is used on Catalyst. + #[structopt(short, long, parse(try_from_str))] + pin: QRPin, +} + +impl QRcodeApp { + pub fn exec(self) -> Result<(), Box> { + let QRcodeApp { input, output, pin } = self; + // open input key and parse it + let key_file = OpenOptions::new() + .create(false) + .read(true) + .write(false) + .append(false) + .open(&input) + .expect("Could not open input file."); + + let mut reader = BufReader::new(key_file); + let mut key_str = String::new(); + let _key_len = reader + .read_line(&mut key_str) + .expect("Could not read input file."); + let sk = key_str.trim_end().to_string(); + + let secret_key: SecretKey = + SecretKey::try_from_bech32_str(&sk).expect("Malformed secret key."); + // use parsed pin from args + let pwd = pin.password; + // generate qrcode with key and parsed pin + let qr = KeyQrCode::generate(secret_key, &pwd); + // process output + match output { + Some(path) => { + // save qr code to file, or print to stdout if it fails + let img = qr.to_img(); + if let Err(e) = img.save(path) { + println!("Error: {}", e); + println!(); + println!("{}", qr); + } + } + None => { + // prints qr code to stdout when no path is specified + println!(); + println!("{}", qr); + } + } + Ok(()) + } +} diff --git a/src/bin/cli/mod.rs b/src/bin/cli/mod.rs index 5de9f753..d96271e4 100644 --- a/src/bin/cli/mod.rs +++ b/src/bin/cli/mod.rs @@ -1,3 +1,4 @@ +mod kedqr; mod logs; mod notifications; mod recovery; @@ -34,6 +35,8 @@ pub enum CatalystCommand { Recover(recovery::Recover), /// Download, compare and get stats from sentry and persistent fragment logs Logs(logs::Logs), + /// Generate qr codes + Kedqr(kedqr::QRcodeApp), } impl Cli { @@ -60,6 +63,7 @@ impl CatalystCommand { PushNotification(notifications) => notifications.exec()?, Recover(recover) => recover.exec()?, Logs(logs) => logs.exec()?, + Kedqr(kedqr) => kedqr.exec()?, }; Ok(()) } diff --git a/src/kedqr/mod.rs b/src/kedqr/mod.rs new file mode 100644 index 00000000..c239cfa1 --- /dev/null +++ b/src/kedqr/mod.rs @@ -0,0 +1,206 @@ +use chain_crypto::{Ed25519Extended, SecretKey, SecretKeyError}; +use image::{DynamicImage, ImageBuffer, Luma}; +use qrcode::{ + render::{svg, unicode}, + EcLevel, QrCode, +}; +use std::fmt; +use std::fs::File; +use std::io::{self, prelude::*}; +use std::path::Path; +use std::str::FromStr; +use symmetric_cipher::{decrypt, encrypt, Error as SymmetricCipherError}; +use thiserror::Error; + +pub const PIN_LENGTH: usize = 4; + +pub struct KeyQrCode { + inner: QrCode, +} + +#[derive(Error, Debug)] +pub enum KeyQrCodeError { + #[error("encryption-decryption protocol error")] + SymmetricCipher(#[from] SymmetricCipherError), + #[error("io error")] + Io(#[from] io::Error), + #[error("invalid secret key")] + SecretKey(#[from] SecretKeyError), + #[error("couldn't decode QR code")] + QrDecodeError(#[from] QrDecodeError), + #[error("failed to decode hex")] + HexDecodeError(#[from] hex::FromHexError), +} + +#[derive(Error, Debug)] +pub enum QrDecodeError { + #[error("couldn't decode QR code")] + DecodeError(#[from] quircs::DecodeError), + #[error("couldn't extract QR code")] + ExtractError(#[from] quircs::ExtractError), + #[error("QR code payload is not valid uf8")] + NonUtf8Payload, +} + +impl KeyQrCode { + pub fn generate(key: SecretKey, password: &[u8]) -> Self { + let secret = key.leak_secret(); + let rng = rand::thread_rng(); + // this won't fail because we already know it's an ed25519extended key, + // so it is safe to unwrap + let enc = encrypt(password, secret.as_ref(), rng).unwrap(); + // Using binary would make the QR codes more compact and probably less + // prone to scanning errors. + let enc_hex = hex::encode(enc); + let inner = QrCode::with_error_correction_level(&enc_hex, EcLevel::H).unwrap(); + + KeyQrCode { inner } + } + + pub fn write_svg(&self, path: impl AsRef) -> Result<(), KeyQrCodeError> { + let mut out = File::create(path)?; + let svg_file = self + .inner + .render() + .quiet_zone(true) + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build(); + out.write_all(svg_file.as_bytes())?; + out.flush()?; + Ok(()) + } + + pub fn to_img(&self) -> ImageBuffer, Vec> { + let qr = &self.inner; + let img = qr.render::>().build(); + img + } + + pub fn decode( + img: DynamicImage, + password: &[u8], + ) -> Result>, KeyQrCodeError> { + let mut decoder = quircs::Quirc::default(); + + let img = img.into_luma8(); + + let codes = decoder.identify(img.width() as usize, img.height() as usize, &img); + + codes + .map(|code| -> Result<_, KeyQrCodeError> { + let decoded = code + .map_err(QrDecodeError::ExtractError) + .and_then(|c| c.decode().map_err(QrDecodeError::DecodeError))?; + + // TODO: I actually don't know if this can fail + let h = std::str::from_utf8(&decoded.payload) + .map_err(|_| QrDecodeError::NonUtf8Payload)?; + let encrypted_bytes = hex::decode(h)?; + let key = decrypt(password, &encrypted_bytes)?; + Ok(SecretKey::from_binary(&key)?) + }) + .collect() + } +} + +impl fmt::Display for KeyQrCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let qr_img = self + .inner + .render::() + .quiet_zone(true) + .dark_color(unicode::Dense1x2::Light) + .light_color(unicode::Dense1x2::Dark) + .build(); + write!(f, "{}", qr_img) + } +} + +#[derive(Debug, PartialEq)] +pub struct QRPin { + pub password: [u8; 4], +} + +#[derive(Error, Debug)] + +pub enum BadPinError { + #[error("The PIN must consist of {PIN_LENGTH} digits, found {0}")] + InvalidLength(usize), + #[error("Invalid digit {0}")] + InvalidDigit(char), +} + +impl FromStr for QRPin { + type Err = BadPinError; + + fn from_str(s: &str) -> Result { + if s.chars().count() != PIN_LENGTH { + return Err(BadPinError::InvalidLength(s.len())); + } + + let mut pwd = [0u8; 4]; + for (i, digit) in s.chars().enumerate() { + pwd[i] = digit.to_digit(10).ok_or(BadPinError::InvalidDigit(digit))? as u8; + } + Ok(QRPin { password: pwd }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_pin_successfully() { + for (pin, pwd) in &[ + ("0000", [0, 0, 0, 0]), + ("1123", [1, 1, 2, 3]), + ("0002", [0, 0, 0, 2]), + ] { + let qr_pin = QRPin::from_str(pin).unwrap(); + assert_eq!(qr_pin, QRPin { password: *pwd }) + } + } + #[test] + fn pins_that_do_not_satisfy_length_reqs_return_error() { + for bad_pin in &["", "1", "11", "111", "11111"] { + let qr_pin = QRPin::from_str(bad_pin); + assert!(qr_pin.is_err(),) + } + } + + #[test] + fn pins_that_do_not_satisfy_content_reqs_return_error() { + for bad_pin in &[" ", " 111", "llll", "000u"] { + let qr_pin = QRPin::from_str(bad_pin); + assert!(qr_pin.is_err(),) + } + } + + // TODO: Improve into an integration test using a temporary directory. + // Leaving here as an example. + #[test] + fn generate_svg() { + const PASSWORD: &[u8] = &[1, 2, 3, 4]; + let sk = SecretKey::generate(rand::thread_rng()); + let qr = KeyQrCode::generate(sk, PASSWORD); + qr.write_svg("qr-code.svg").unwrap(); + } + + #[test] + fn encode_decode() { + const PASSWORD: &[u8] = &[1, 2, 3, 4]; + let sk = SecretKey::generate(rand::thread_rng()); + let qr = KeyQrCode::generate(sk.clone(), PASSWORD); + let img = qr.to_img(); + // img.save("qr.png").unwrap(); + assert_eq!( + sk.leak_secret().as_ref(), + KeyQrCode::decode(DynamicImage::ImageLuma8(img), PASSWORD).unwrap()[0] + .clone() + .leak_secret() + .as_ref() + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index a492f773..2b39ef3c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod kedqr; pub mod logs; pub mod notifications; pub mod recovery; From caa44713937c6851673087071f4dc7ca071ebc0e Mon Sep 17 00:00:00 2001 From: Mikhail Zabaluev Date: Mon, 19 Jul 2021 15:25:35 +0300 Subject: [PATCH 2/2] Renamed kedqr command and data types "kedqr" is meaningless, use qr-code. Correct Rust capitalization in data types that deal with QR codes. --- src/bin/cli/kedqr/mod.rs | 10 +++++----- src/bin/cli/mod.rs | 4 ++-- src/kedqr/mod.rs | 14 +++++++------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/bin/cli/kedqr/mod.rs b/src/bin/cli/kedqr/mod.rs index bb2abc4d..4a93f50d 100644 --- a/src/bin/cli/kedqr/mod.rs +++ b/src/bin/cli/kedqr/mod.rs @@ -1,4 +1,4 @@ -use catalyst_toolbox::kedqr::{KeyQrCode, QRPin}; +use catalyst_toolbox::kedqr::{KeyQrCode, QrPin}; use chain_crypto::bech32::Bech32; use chain_crypto::{Ed25519Extended, SecretKey}; use std::{ @@ -12,7 +12,7 @@ use structopt::StructOpt; /// QCode CLI toolkit #[derive(Debug, PartialEq, StructOpt)] #[structopt(rename_all = "kebab-case")] -pub struct QRcodeApp { +pub struct QrCodeCmd { /// Path to file containing ed25519extended bech32 value. #[structopt(short, long, parse(from_os_str))] input: PathBuf, @@ -21,12 +21,12 @@ pub struct QRcodeApp { output: Option, /// Pin code. 4-digit number is used on Catalyst. #[structopt(short, long, parse(try_from_str))] - pin: QRPin, + pin: QrPin, } -impl QRcodeApp { +impl QrCodeCmd { pub fn exec(self) -> Result<(), Box> { - let QRcodeApp { input, output, pin } = self; + let QrCodeCmd { input, output, pin } = self; // open input key and parse it let key_file = OpenOptions::new() .create(false) diff --git a/src/bin/cli/mod.rs b/src/bin/cli/mod.rs index d96271e4..13125b38 100644 --- a/src/bin/cli/mod.rs +++ b/src/bin/cli/mod.rs @@ -36,7 +36,7 @@ pub enum CatalystCommand { /// Download, compare and get stats from sentry and persistent fragment logs Logs(logs::Logs), /// Generate qr codes - Kedqr(kedqr::QRcodeApp), + QrCode(kedqr::QrCodeCmd), } impl Cli { @@ -63,7 +63,7 @@ impl CatalystCommand { PushNotification(notifications) => notifications.exec()?, Recover(recover) => recover.exec()?, Logs(logs) => logs.exec()?, - Kedqr(kedqr) => kedqr.exec()?, + QrCode(kedqr) => kedqr.exec()?, }; Ok(()) } diff --git a/src/kedqr/mod.rs b/src/kedqr/mod.rs index c239cfa1..cf60aa55 100644 --- a/src/kedqr/mod.rs +++ b/src/kedqr/mod.rs @@ -118,7 +118,7 @@ impl fmt::Display for KeyQrCode { } #[derive(Debug, PartialEq)] -pub struct QRPin { +pub struct QrPin { pub password: [u8; 4], } @@ -131,7 +131,7 @@ pub enum BadPinError { InvalidDigit(char), } -impl FromStr for QRPin { +impl FromStr for QrPin { type Err = BadPinError; fn from_str(s: &str) -> Result { @@ -143,7 +143,7 @@ impl FromStr for QRPin { for (i, digit) in s.chars().enumerate() { pwd[i] = digit.to_digit(10).ok_or(BadPinError::InvalidDigit(digit))? as u8; } - Ok(QRPin { password: pwd }) + Ok(QrPin { password: pwd }) } } @@ -158,14 +158,14 @@ mod tests { ("1123", [1, 1, 2, 3]), ("0002", [0, 0, 0, 2]), ] { - let qr_pin = QRPin::from_str(pin).unwrap(); - assert_eq!(qr_pin, QRPin { password: *pwd }) + let qr_pin = QrPin::from_str(pin).unwrap(); + assert_eq!(qr_pin, QrPin { password: *pwd }) } } #[test] fn pins_that_do_not_satisfy_length_reqs_return_error() { for bad_pin in &["", "1", "11", "111", "11111"] { - let qr_pin = QRPin::from_str(bad_pin); + let qr_pin = QrPin::from_str(bad_pin); assert!(qr_pin.is_err(),) } } @@ -173,7 +173,7 @@ mod tests { #[test] fn pins_that_do_not_satisfy_content_reqs_return_error() { for bad_pin in &[" ", " 111", "llll", "000u"] { - let qr_pin = QRPin::from_str(bad_pin); + let qr_pin = QrPin::from_str(bad_pin); assert!(qr_pin.is_err(),) } }