diff --git a/Cargo.toml b/Cargo.toml index f084e0d..caccc56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "duralumin" -version = "0.2.0" +version = "0.3.0" edition = "2021" [lib] @@ -33,6 +33,10 @@ osshkeys = {git = "https://github.com/noonebtw/rust-osshkeys.git", branch = "mas sha2 = {version = "0.9", optional = true} rpassword = {version = "5.0", optional = true} zeroize = {version = "1.5"} -rust-argon2 = "1.0" +argon2 = "0.5.3" thiserror = "1.0" anyhow = "1.0" + +itertools = "0.13.0" +russh-keys = "0.44.0" +ed25519-dalek = "2.1.1" \ No newline at end of file diff --git a/src/bin/duralumin-keygen.rs b/src/bin/duralumin-keygen.rs index d44b6d9..99dcb68 100644 --- a/src/bin/duralumin-keygen.rs +++ b/src/bin/duralumin-keygen.rs @@ -1,11 +1,4 @@ -use std::{io::Write, str::FromStr}; - use clap::Parser; -use libduralumin::ed25519::randomart; -use osshkeys::PublicParts; -use zeroize::Zeroizing; - -use crate::keygen::HashType; /// program that generates ed25519 keypairs seeded by a passphrase and an optional ID. #[derive(Parser)] @@ -15,27 +8,10 @@ use crate::keygen::HashType; author = "No One " )] struct Opts { - #[clap(short, long, default_value = "id_ed25519")] + #[clap(short, long, default_value = "duralumin")] file: String, } -fn fix_newline_ref(line: &mut String) { - if line.ends_with('\n') { - line.pop(); - - if line.ends_with('\r') { - line.pop(); - } - } -} - -#[allow(dead_code)] -fn fix_newline(mut line: String) -> String { - fix_newline_ref(&mut line); - - line -} - pub mod keygen { use std::str::FromStr; @@ -119,11 +95,7 @@ pub mod keygen { self } - pub fn with_hash_type_and_iterations( - self, - hash_type: HashType, - iterations: i16, - ) -> Self { + pub fn with_hash_type_and_iterations(self, hash_type: HashType, iterations: i16) -> Self { self.with_hash_type(hash_type).with_iterations(iterations) } @@ -144,17 +116,12 @@ pub mod keygen { self } - pub fn with_passphrase>>( - mut self, - passphrase: Z, - ) -> Self { + pub fn with_passphrase>>(mut self, passphrase: Z) -> Self { self.passphrase = passphrase.into(); self } - pub fn build_keypair( - self, - ) -> OsshResult<((Zeroizing, String), KeyPair)> { + pub fn build_keypair(self) -> OsshResult<((Zeroizing, String), KeyPair)> { let hash_seed = Zeroizing::new( self.passphrase .chars() @@ -162,42 +129,38 @@ pub mod keygen { .collect::(), ); - let seed = match self.hash_type { + let mut seed = [0u8; 32]; + match self.hash_type { HashType::Sha256 => { let mut digest = sha2::Sha256::digest(hash_seed.as_bytes()); assert!(self.iterations <= 1); for _ in 0..(self.iterations - 1) { - digest = sha2::Sha256::digest(&digest); + digest = sha2::Sha256::digest(digest.as_slice()); } - digest.to_vec() + + seed.copy_from_slice(&digest[..32]); } HashType::Argon2 => { // TODO: make more random salt let salt = b"thissaltneedsupdating"; - let config = argon2::Config { - variant: argon2::Variant::Argon2i, - version: argon2::Version::Version13, - mem_cost: 65536, - time_cost: self.iterations as u32, - lanes: 4, - thread_mode: argon2::ThreadMode::Parallel, - hash_length: 32, - ..Default::default() - }; + let argon = argon2::Argon2::new( + argon2::Algorithm::Argon2i, + argon2::Version::V0x13, + argon2::Params::new(65536, self.iterations as u32, 4, Some(32)) + .expect("argon2 params"), + ); - argon2::hash_raw(hash_seed.as_bytes(), salt, &config) - .expect("hash passphrase") + argon + .hash_password_into(hash_seed.as_bytes(), salt, &mut seed) + .expect("hash passphrase"); } - } - .try_into() - .expect("unwrap seed into [u8; 32]"); + }; let rng = rand_chacha::ChaChaRng::from_seed(seed).gen::<[u8; 32]>(); - let keypair = Ed25519KeyPair::from_seed(&rng) - .map(|key| Into::::into(key))?; + let keypair = Ed25519KeyPair::from_seed(&rng).map(|key| Into::::into(key))?; let private_key = Zeroizing::new(keypair.serialize_openssh( self.encrypt_key.then(|| self.passphrase.as_str()), @@ -209,107 +172,39 @@ pub mod keygen { } } -fn read_line() -> std::io::Result { - let mut line = String::new(); - std::io::stdin().read_line(&mut line)?; - fix_newline_ref(&mut line); - - Ok(line) -} - -fn read_non_empty_line() -> std::io::Result> { - let line = read_line()?; - - Ok(if line.is_empty() { None } else { Some(line) }) -} - fn main() -> anyhow::Result<()> { let opts = Opts::parse(); println!("Generating ed25519 ssh keypair:"); - let ((private_key, public_key), keypair) = keygen::SshKeyBuilder::default() - .with_passphrase({ - print!("Enter a passphrase: "); - std::io::stdout().flush()?; - let passphrase = Zeroizing::new(rpassword::read_password()?); - print!("Re-enter the same passphrase: "); - std::io::stdout().flush()?; - let passphrase2 = Zeroizing::new(rpassword::read_password()?); - - if passphrase == passphrase2 { - Ok(passphrase) - } else { - println!("passphrases do not match."); - Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "passphrase did not match.", - )) - } - }?) - .with_maybe_tag({ - print!("Enter an optional ID []: "); - std::io::stdout().flush()?; - - read_non_empty_line()? - }) - .with_encrypt({ - print!("Encrypt keypair with passphrase? [Y/n]: "); - std::io::stdout().flush()?; - - let encrypting = read_line()? != "n"; - if encrypting { - println!("Encrypting keypair..") - }; - - encrypting - }) - .with_hash_type({ - print!("Use hash algorithm (sha256, argon2)[argon2]: "); - std::io::stdout().flush()?; - - read_non_empty_line()? - .map(|line| HashType::from_str(&line)) - .unwrap_or(Ok(HashType::Argon2))? - }) - .with_iterations({ - print!("Number of hashing iterations [4]: "); - std::io::stdout().flush()?; - - read_non_empty_line()? - .map(|line| i16::from_str(&line)) - .unwrap_or(Ok(4))? - }) - .build_keypair()?; - - let fingerprint = - keypair.fingerprint(osshkeys::keys::FingerprintHash::SHA256)?; + let desc = libduralumin::key_gen::cli::keygen_desc_from_stdin()?; + let keypair = libduralumin::key_gen::generate_key(desc)?; println!( "Your key fingerprint is: Sha256:{}", - base64::encode(&fingerprint) + keypair.fingerprint_base64() ); - let randomart = randomart::RandomArt::from_digest(&fingerprint) - .render("ED25519 256", "SHA256")?; - - println!("RandomArt:\n{}", randomart); + println!( + "RandomArt:\n{}", + keypair.randomart().render("ED25519 256", "SHA256")? + ); let private_path = opts.file.clone(); let public_path = opts.file.clone() + ".pub"; + let (private_key, public_key) = keypair.encode_keys()?; std::fs::write(&private_path, private_key)?; std::fs::write(&public_path, public_key)?; #[cfg(target_family = "unix")] - use std::fs::Permissions; - #[cfg(target_family = "unix")] - use std::os::unix::fs::PermissionsExt; + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; - #[cfg(target_family = "unix")] - std::fs::set_permissions(private_path, Permissions::from_mode(0o0600))?; - #[cfg(target_family = "unix")] - std::fs::set_permissions(public_path, Permissions::from_mode(0o0600))?; + std::fs::set_permissions(private_path, Permissions::from_mode(0o0600))?; + std::fs::set_permissions(public_path, Permissions::from_mode(0o0600))?; + } Ok(()) } diff --git a/src/key_gen.rs b/src/key_gen.rs new file mode 100644 index 0000000..9706c4f --- /dev/null +++ b/src/key_gen.rs @@ -0,0 +1,369 @@ +use russh_keys::PublicKeyBase64; +use sha2::Digest; +use zeroize::Zeroizing; + +use crate::ed25519::randomart; + +pub mod cli { + use zeroize::Zeroizing; + + use crate::key_gen::{HashDesc, KeygenDesc}; + + fn fix_newline_ref(line: &mut String) { + if line.ends_with('\n') { + line.pop(); + + if line.ends_with('\r') { + line.pop(); + } + } + } + + #[allow(dead_code)] + fn fix_newline(mut line: String) -> String { + fix_newline_ref(&mut line); + + line + } + pub fn read_line() -> std::io::Result { + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + fix_newline_ref(&mut line); + + Ok(line) + } + + pub fn read_non_empty_line() -> std::io::Result> { + let line = read_line()?; + + Ok(if line.is_empty() { None } else { Some(line) }) + } + + #[derive(thiserror::Error, Debug)] + pub enum Error { + #[error(transparent)] + Io(#[from] std::io::Error), + #[error(transparent)] + ParseInt(#[from] std::num::ParseIntError), + #[error("Mismatching secret.")] + MismatchingSecret, + #[error("Invalid hash method.")] + InvalidHashMethod, + } + + pub type Result = std::result::Result; + + fn read_passphrase() -> Result> { + let passphrase = Zeroizing::new(rpassword::prompt_password_stdout("Enter a passphrase: ")?); + let passphrase2 = + Zeroizing::new(rpassword::prompt_password_stdout("Enter a passphrase: ")?); + + if passphrase == passphrase2 { + println!( + "Passphrase entropy (rough estimate): {:.2}", + crate::entropy(&passphrase) + ); + Ok(passphrase) + } else { + Err(Error::MismatchingSecret) + } + } + + pub fn read_argon_desc() -> Result { + print!("Use argon2 variant (argon2i, argon2d, argon2id) [argon2id]: "); + let variant = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) { + Some("argon2i") => argon2::Algorithm::Argon2i, + Some("argon2d") => argon2::Algorithm::Argon2d, + Some("argon2id") | None => argon2::Algorithm::Argon2id, + _ => { + return Err(Error::InvalidHashMethod); + } + }; + + print!("Use argon2 version (16,19) [argon2id]: "); + let version = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) { + Some("16") | Some("10") => argon2::Version::V0x10, + Some("19") | Some("13") | None => argon2::Version::V0x13, + _ => { + return Err(Error::InvalidHashMethod); + } + }; + + print!("memory size (memory cost) (KiB if no unit specified) (min 19 MiB) [64 MiB]: "); + let memory = 'mem: { + let Some(line) = read_non_empty_line()? else { + break 'mem 64 * 1024; + }; + let line = line.trim(); + let chars = line.char_indices(); + let digits_end = chars + .take_while(|(_, c)| ('0'..='9').contains(c)) + .map(|(i, _)| i) + .last() + .unwrap_or(line.len()); + let digits = &line[..digits_end]; + let unit = &line[digits_end..]; + + let number = digits.parse::()?; + let factor = match unit { + "KiB" | "kib" | "" => 1, + "MiB" | "mib" => 1024, + _ => { + return Err(Error::InvalidHashMethod); + } + }; + + number * factor + }; + + print!("Iterations (time cost) [4]: "); + let iterations = read_non_empty_line()? + .map(|s| s.parse::()) + .unwrap_or(Ok(4))?; + + print!("Lanes (parallelism cost) [1]: "); + let lanes = read_non_empty_line()? + .map(|s| s.parse::()) + .unwrap_or(Ok(1))?; + + Ok(HashDesc::Argon { + variant, + version, + memory, + time: iterations as u32, + lanes: lanes as u32, + hash_length: 32, + }) + } + + pub fn keygen_desc_from_stdin() -> Result { + let passphrase = read_passphrase()?; + + print!("Enter an optional ID []: "); + let tag = read_non_empty_line()?.map(|s| Zeroizing::new(s)); + + print!("Encrypt keypair with passphrase? [Y/n]: "); + let encrypt = read_line()? == "Y"; + + print!("Use hash algorithm (sha256, argon2) [argon2]: "); + let hash = match read_non_empty_line()?.as_ref().map(|s| s.as_str()) { + Some("argon2") | None => read_argon_desc()?, + Some("sha256") => { + print!("Iterations [4]: "); + let iterations = read_non_empty_line()? + .map(|s| s.parse::()) + .unwrap_or(Ok(4))?; + HashDesc::Sha256 { + iterations: iterations as u32, + } + } + _ => return Err(Error::InvalidHashMethod), + }; + + Ok(KeygenDesc { + hash, + salt: KeygenDesc::SALT.to_vec(), + tag, + passphrase, + encrypt, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("Argon2 Error: {0}")] + Argon2(argon2::Error), + #[error(transparent)] + SshKeys(#[from] russh_keys::Error), +} + +impl From for Error { + fn from(value: argon2::Error) -> Self { + Self::Argon2(value) + } +} + +pub type Result = core::result::Result; + +pub struct KeyPair { + pub passphrase: Option>, + pub private_key: ed25519_dalek::SigningKey, + pub public_key: ed25519_dalek::VerifyingKey, +} + +impl KeyPair { + pub fn fingerprint(&self) -> Vec { + sha2::Sha256::digest(self.public_key.as_bytes()).to_vec() + } + + pub fn fingerprint_base64(&self) -> String { + base64::encode(&self.fingerprint()) + } + + pub fn randomart(&self) -> randomart::RandomArt { + randomart::RandomArt::from_digest(&self.fingerprint()) + } + + pub fn encode_keys(&self) -> Result<(Zeroizing, String)> { + let keypair = russh_keys::key::KeyPair::Ed25519(self.private_key.clone()); + let public_key = keypair.clone_public_key()?; + + let mut private_key = Vec::new(); + match &self.passphrase { + Some(passphrase) => { + russh_keys::encode_pkcs8_pem_encrypted( + &keypair, + passphrase.as_bytes(), + 1, + &mut private_key, + )?; + } + None => { + russh_keys::encode_pkcs8_pem(&keypair, &mut private_key)?; + } + } + + let private_key = Zeroizing::new(core::str::from_utf8(&private_key).unwrap().to_string()); + let public_key = public_key.public_key_base64(); + Ok((private_key, public_key)) + } +} + +pub fn generate_key(desc: KeygenDesc) -> Result { + let seed = Zeroizing::new( + desc.passphrase + .chars() + .chain(desc.tag.unwrap_or_default().chars()) + .collect::(), + ); + + let mut hash = [0u8; 32]; + match desc.hash { + HashDesc::Sha256 { iterations } => { + let mut hasher = sha2::Sha256::new(); + hasher.update(seed.as_bytes()); + hasher.update(desc.salt.as_slice()); + + let mut digest = hasher.finalize(); + + for _ in 0..(iterations - 1) { + digest = sha2::Sha256::digest(digest.as_slice()); + } + + hash.copy_from_slice(&digest[..32]); + } + HashDesc::Argon { + variant, + version, + memory, + time, + lanes, + hash_length, + } => { + let argon = argon2::Argon2::new( + variant, + version, + argon2::Params::new(memory, time, lanes, Some(hash_length as usize))?, + ); + + argon.hash_password_into(seed.as_bytes(), &desc.salt, &mut hash)?; + } + }; + + // let hash = rand_chacha::ChaChaRng::from_seed(hash).gen::<[u8; 32]>(); + let private_key = ed25519_dalek::SigningKey::from_bytes(&hash); + let public_key = private_key.verifying_key(); + + return Ok(KeyPair { + passphrase: desc.encrypt.then_some(desc.passphrase), + private_key, + public_key, + }); + // let keypair = russh_keys::key::KeyPair::Ed25519(private_key); + // let public_key = keypair.clone_public_key().expect("pubkey"); + // let fingerprint = public_key.fingerprint(); + + // let pubbytes = public_key.public_key_bytes(); + + // let randomart = randomart::RandomArt::from_digest(sha2::Sha256::digest(&pubbytes).as_slice()) + // .render("ED25519 256", "SHA256") + // .expect("randomart"); + + // let mut private_key = Vec::new(); + // if desc.encrypt { + // russh_keys::encode_pkcs8_pem_encrypted( + // &keypair, + // desc.passphrase.as_bytes(), + // 1, + // &mut private_key, + // ) + // .expect("writing private key"); + // } else { + // russh_keys::encode_pkcs8_pem(&keypair, &mut private_key).expect("writing private key"); + // } +} + +#[derive(Debug)] +pub struct KeygenDesc { + pub hash: HashDesc, + pub salt: Vec, + pub tag: Option>, + pub passphrase: Zeroizing, + pub encrypt: bool, +} + +impl Default for KeygenDesc { + fn default() -> Self { + Self { + hash: Default::default(), + salt: Self::SALT.to_vec(), + tag: None, + passphrase: Zeroizing::new("password123".to_string()), + encrypt: true, + } + } +} + +impl KeygenDesc { + /// known random salt. + pub const SALT: &'static [u8; 32] = b"10ru4YBD5wvdQZc2Mw7Brx45jCxGUM3F"; +} + +#[derive(Debug)] +pub enum HashDesc { + Sha256 { + iterations: u32, + }, + Argon { + variant: argon2::Algorithm, + version: argon2::Version, + memory: u32, + time: u32, + lanes: u32, + hash_length: u32, + }, +} + +impl Default for HashDesc { + fn default() -> Self { + Self::Argon { + variant: argon2::Algorithm::Argon2id, + version: argon2::Version::V0x13, + memory: 65536, + time: 4, + lanes: 1, + hash_length: 32, + } + } +} + +#[cfg(test)] +mod tests { + use super::{generate_key, KeygenDesc}; + + #[test] + fn test_edkey() { + generate_key(KeygenDesc::default()); + } +} diff --git a/src/lib.rs b/src/lib.rs index c3da522..1175ab2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +use itertools::Itertools; + #[cfg(feature = "passphrase-gen")] pub mod passphrase_gen; @@ -6,3 +8,52 @@ pub mod password_gen; #[cfg(feature = "ed25519")] pub mod ed25519; + +#[cfg(feature = "ed25519")] +pub mod key_gen; + +fn entropy(pass: &str) -> f32 { + let mut r = 0; + if pass.chars().any(|c| c.is_ascii_lowercase()) { + r += 26; + } + if pass.chars().any(|c| c.is_ascii_uppercase()) { + r += 26; + } + if pass.chars().any(|c| c.is_ascii_punctuation()) { + r += 36; + } + if pass.chars().any(|c| c.is_ascii_digit()) { + r += 10; + } + let len = pass.chars().count(); + let other = pass + .chars() + .filter(|c| { + !(c.is_ascii_lowercase() + || c.is_ascii_uppercase() + || c.is_ascii_digit() + || c.is_ascii_punctuation()) + }) + .dedup() + .count(); + + ((r + other) as f32).log2() * len as f32 +} + +#[cfg(test)] +mod tests { + use crate::entropy; + + #[test] + fn test_entropy() { + println!( + "{}", + entropy("this is a long passphrase with quite a few words!") + ); + println!( + "{}", + entropy("This Is A Long Passphrase With Quite A Few Words!") + ); + } +}