Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

port kedqr to catalyst-toolbox #17

Merged
merged 2 commits into from
Jul 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
71 changes: 71 additions & 0 deletions src/bin/cli/kedqr/mod.rs
Original file line number Diff line number Diff line change
@@ -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 QrCodeCmd {
/// 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<PathBuf>,
/// Pin code. 4-digit number is used on Catalyst.
#[structopt(short, long, parse(try_from_str))]
pin: QrPin,
}

impl QrCodeCmd {
pub fn exec(self) -> Result<(), Box<dyn Error>> {
let QrCodeCmd { 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<Ed25519Extended> =
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(())
}
}
4 changes: 4 additions & 0 deletions src/bin/cli/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod kedqr;
mod logs;
mod notifications;
mod recovery;
Expand Down Expand Up @@ -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
QrCode(kedqr::QrCodeCmd),
}

impl Cli {
Expand All @@ -60,6 +63,7 @@ impl CatalystCommand {
PushNotification(notifications) => notifications.exec()?,
Recover(recover) => recover.exec()?,
Logs(logs) => logs.exec()?,
QrCode(kedqr) => kedqr.exec()?,
};
Ok(())
}
Expand Down
206 changes: 206 additions & 0 deletions src/kedqr/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Ed25519Extended>, 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<Path>) -> 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<Luma<u8>, Vec<u8>> {
let qr = &self.inner;
let img = qr.render::<Luma<u8>>().build();
img
}

pub fn decode(
img: DynamicImage,
password: &[u8],
) -> Result<Vec<SecretKey<Ed25519Extended>>, 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::<unicode::Dense1x2>()
.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<Self, Self::Err> {
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()
);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod kedqr;
pub mod logs;
pub mod notifications;
pub mod recovery;
Expand Down