diff --git a/Cargo.lock b/Cargo.lock index 649df34..cb36484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,6 +408,7 @@ checksum = "4a3daa8e81a3963a60642bcc1f90a670680bd4a77535faa384e9d1c79d620871" dependencies = [ "curve25519-dalek", "ed25519", + "rand_core", "serde", "sha2", "subtle", @@ -1257,6 +1258,7 @@ dependencies = [ "base64", "clap", "eyre", + "pem", "rpassword", "ssh-keys", "tracing", @@ -1269,7 +1271,9 @@ dependencies = [ "aes", "bcrypt-pbkdf", "ctr", + "ed25519-dalek", "pem", + "rand", "ssh-transport", ] diff --git a/bin/ssh-key/Cargo.toml b/bin/ssh-key/Cargo.toml index 9866962..4d403bf 100644 --- a/bin/ssh-key/Cargo.toml +++ b/bin/ssh-key/Cargo.toml @@ -11,3 +11,4 @@ tracing.workspace = true ssh-keys = { path = "../../lib/ssh-keys" } base64 = "0.22.1" rpassword = "7.3.1" +pem = "3.0.4" diff --git a/bin/ssh-key/src/main.rs b/bin/ssh-key/src/main.rs index 1b4fb4f..ec17eef 100644 --- a/bin/ssh-key/src/main.rs +++ b/bin/ssh-key/src/main.rs @@ -1,9 +1,12 @@ -use std::path::PathBuf; +use std::{ + io::Write, + path::{Path, PathBuf}, +}; use base64::Engine; use clap::Parser; use eyre::{bail, Context}; -use ssh_keys::PrivateKeyType; +use ssh_keys::{KeyEncryptionParams, PrivateKeyType}; #[derive(clap::Parser)] struct Args { @@ -13,6 +16,10 @@ struct Args { #[derive(clap::Subcommand)] enum Subcommand { + /// Strips PEM armor + Unpem { id_file: PathBuf }, + /// Extract the encrypted part of the private key + ExtractEncrypted { id_file: PathBuf }, Info { /// Decrypt the key to get more information. Will not display private information unless --show-private is used #[arg(short, long)] @@ -22,55 +29,127 @@ enum Subcommand { show_private: bool, id_file: PathBuf, }, + Generate { + #[arg(short, long = "type")] + type_: KeyType, + #[arg(short, long)] + comment: String, + #[arg(short, long)] + path: PathBuf, + }, +} + +#[derive(clap::ValueEnum, Clone)] +enum KeyType { + Ed25519, } fn main() -> eyre::Result<()> { let args = Args::parse(); match args.command { + Subcommand::Unpem { id_file } => { + let file = std::fs::read(&id_file) + .wrap_err_with(|| format!("reading file {}", id_file.display()))?; + let raw = pem::parse(&file)?; + std::io::stdout().lock().write_all(raw.contents())?; + Ok(()) + } + Subcommand::ExtractEncrypted { id_file } => { + let file = std::fs::read(&id_file) + .wrap_err_with(|| format!("reading file {}", id_file.display()))?; + let keys = ssh_keys::EncryptedPrivateKeys::parse(&file)?; + let passphrase = if keys.requires_passphrase() { + let phrase = rpassword::prompt_password("passphrase: ")?; + Some(phrase) + } else { + None + }; + + let data = keys.decrypt_encrypted_part(passphrase.as_deref())?; + std::io::stdout().lock().write_all(&data)?; + Ok(()) + } Subcommand::Info { id_file, decrypt, show_private, - } => { - if show_private && !decrypt { - bail!("cannot --show-private without --decrypt"); - } + } => info(&id_file, decrypt, show_private), + Subcommand::Generate { + type_, + comment, + path, + } => generate(type_, comment, &path), + } +} - let file = std::fs::read(&id_file) - .wrap_err_with(|| format!("reading file {}", id_file.display()))?; +fn info(id_file: &Path, decrypt: bool, show_private: bool) -> eyre::Result<()> { + if show_private && !decrypt { + bail!("cannot --show-private without --decrypt"); + } - let keys = ssh_keys::EncryptedPrivateKeys::parse_unencrypted(&file)?; + let file = + std::fs::read(&id_file).wrap_err_with(|| format!("reading file {}", id_file.display()))?; - if decrypt { - let passphrase = if keys.requires_passphrase() { - let phrase = rpassword::prompt_password("passphrase: ")?; - Some(phrase) - } else { - None - }; + let keys = ssh_keys::EncryptedPrivateKeys::parse(&file)?; - let keys = keys.parse_private(passphrase.as_deref())?; - for key in keys { - println!("{} {}", key.private_key.public_key(), key.comment); - if show_private { - match key.private_key { - PrivateKeyType::Ed25519 { private_key, .. } => { - println!( - " private key: {}", - base64::prelude::BASE64_STANDARD_NO_PAD.encode(private_key) - ) - } - } + if decrypt { + let passphrase = if keys.requires_passphrase() { + let phrase = rpassword::prompt_password("passphrase: ")?; + Some(phrase) + } else { + None + }; + + let keys = keys.parse_private(passphrase.as_deref())?; + for key in keys { + println!("{} {}", key.private_key.public_key(), key.comment); + if show_private { + match key.private_key { + PrivateKeyType::Ed25519 { private_key, .. } => { + println!( + " private key: {}", + base64::prelude::BASE64_STANDARD_NO_PAD.encode(private_key) + ) } } - } else { - for key in keys.public_keys { - println!("{key}"); - } } } + } else { + for key in keys.public_keys { + println!("{key}"); + } } + Ok(()) +} + +fn generate(type_: KeyType, comment: String, path: &Path) -> eyre::Result<()> { + let type_ = match type_ { + KeyType::Ed25519 => ssh_keys::KeyType::Ed25519, + }; + + let passphrase = rpassword::prompt_password("Enter passphrase (empty for no passphrase): ")?; + + let key = ssh_keys::PlaintextPrivateKey::generate( + comment, + ssh_keys::KeyGenerationParams { key_type: type_ }, + ); + + println!("{} {}", key.private_key.public_key(), key.comment); + + let keys = key.encrypt(KeyEncryptionParams::secure_or_none(passphrase))?; + + let mut pubkey_path = path.to_path_buf().into_os_string(); + pubkey_path.push(".pub"); + std::fs::write( + &pubkey_path, + format!("{} {}\n", key.private_key.public_key(), key.comment), + ) + .wrap_err_with(|| format!("writing to {:?}", pubkey_path))?; + + let privkey = keys.to_bytes_armored(); + + std::fs::write(path, privkey).wrap_err_with(|| format!("writing to {}", path.display()))?; Ok(()) } diff --git a/lib/ssh-agent-client/src/lib.rs b/lib/ssh-agent-client/src/lib.rs index 7d85dcd..d20757a 100644 --- a/lib/ssh-agent-client/src/lib.rs +++ b/lib/ssh-agent-client/src/lib.rs @@ -45,7 +45,7 @@ impl Request { } => { p.u8(numbers::SSH_AGENTC_ADD_IDENTITY); p.string(key_type.as_bytes()); - p.write(&key_contents); + p.raw(&key_contents); p.string(key_comment.as_bytes()); } Self::RemoveAllIdentities => p.u8(numbers::SSH_AGENTC_REMOVE_ALL_IDENTITIES), diff --git a/lib/ssh-keys/Cargo.toml b/lib/ssh-keys/Cargo.toml index 69814ef..7e32308 100644 --- a/lib/ssh-keys/Cargo.toml +++ b/lib/ssh-keys/Cargo.toml @@ -7,5 +7,7 @@ edition = "2021" aes = "0.8.4" bcrypt-pbkdf = "0.10.0" ctr = "0.9.2" +ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } pem = "3.0.4" +rand = "0.8.5" ssh-transport = { path = "../ssh-transport" } diff --git a/lib/ssh-keys/src/crypto.rs b/lib/ssh-keys/src/crypto.rs index 8b54051..5b11ce0 100644 --- a/lib/ssh-keys/src/crypto.rs +++ b/lib/ssh-keys/src/crypto.rs @@ -1,7 +1,9 @@ use std::str::FromStr; use aes::cipher::{KeySizeUser, StreamCipher}; -use ssh_transport::parse::{self, Parser}; +use ssh_transport::parse::{self, Parser, Writer}; + +use crate::PrivateKeyType; #[derive(Debug, Clone, PartialEq, Eq)] pub enum Cipher { @@ -27,6 +29,13 @@ impl FromStr for Cipher { } impl Cipher { + pub fn name(&self) -> &'static str { + match self { + Self::None => "none", + Self::Aes256Ctr => "aes256-ctr", + } + } + pub(crate) fn key_iv_size(&self) -> (usize, usize) { match self { Cipher::None => (0, 0), @@ -34,7 +43,8 @@ impl Cipher { } } - pub(crate) fn decrypt_in_place(&self, data: &mut [u8], key: &[u8], iv: &[u8]) { + /// Decrypt or encrypt a buffer in place (the same operation due to stream ciphers). + pub(crate) fn crypt_in_place(&self, data: &mut [u8], key: &[u8], iv: &[u8]) { match self { Cipher::None => unreachable!("cannot decrypt none cipher"), Cipher::Aes256Ctr => { @@ -84,6 +94,25 @@ impl Kdf { Ok(kdf) } + pub fn name(&self) -> &'static str { + match self { + Self::None => "none", + Self::BCrypt { .. } => "bcrypt", + } + } + + pub fn options(&self) -> Vec { + match self { + Self::None => Vec::new(), + Self::BCrypt { salt, rounds } => { + let mut opts = Writer::new(); + opts.string(salt); + opts.u32(*rounds); + opts.finish() + } + } + } + pub(crate) fn derive(&self, passphrase: &str, output: &mut [u8]) -> parse::Result<()> { match self { Self::None => unreachable!("should not attempt to derive passphrase from none"), @@ -95,3 +124,24 @@ impl Kdf { } } } + +pub enum KeyType { + Ed25519, +} + +pub struct KeyGenerationParams { + pub key_type: KeyType, +} + +pub(crate) fn generate_private_key(params: KeyGenerationParams) -> PrivateKeyType { + match params.key_type { + KeyType::Ed25519 => { + let private_key = ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng); + + PrivateKeyType::Ed25519 { + public_key: private_key.verifying_key().to_bytes(), + private_key: private_key.to_bytes(), + } + } + } +} diff --git a/lib/ssh-keys/src/lib.rs b/lib/ssh-keys/src/lib.rs index 3a295f0..03dc872 100644 --- a/lib/ssh-keys/src/lib.rs +++ b/lib/ssh-keys/src/lib.rs @@ -3,9 +3,13 @@ mod crypto; use crypto::{Cipher, Kdf}; use ssh_transport::{ key::PublicKey, - parse::{self, Parser}, + parse::{self, Parser, Writer}, }; +// TODO: good typed error messages so the user knows what's going on + +pub use crypto::{KeyGenerationParams, KeyType}; + pub struct EncryptedPrivateKeys { pub public_keys: Vec, pub cipher: Cipher, @@ -16,6 +20,7 @@ pub struct EncryptedPrivateKeys { pub struct PlaintextPrivateKey { pub private_key: PrivateKeyType, pub comment: String, + checkint: u32, } pub enum PrivateKeyType { @@ -29,7 +34,7 @@ const MAGIC: &[u8; 15] = b"openssh-key-v1\0"; impl EncryptedPrivateKeys { /// Parse OpenSSH private keys, either armored or not. - pub fn parse_unencrypted(content: &[u8]) -> parse::Result { + pub fn parse(content: &[u8]) -> parse::Result { // https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.key let pem: pem::Pem; // lifetime extension let content = if content.starts_with(b"openssh-key-v1") { @@ -76,14 +81,35 @@ impl EncryptedPrivateKeys { }) } + pub fn to_bytes_armored(&self) -> String { + let content = self.to_bytes(); + let pem = pem::Pem::new("OPENSSH PRIVATE KEY", content); + pem::encode(&pem) + } + + pub fn to_bytes(&self) -> Vec { + let mut p = Writer::new(); + p.array(*MAGIC); + p.string(self.cipher.name().as_bytes()); + p.string(self.kdf.name().as_bytes()); + p.string(&self.kdf.options()); + + p.u32(self.public_keys.len() as u32); + + for pubkey in &self.public_keys { + p.string(&pubkey.to_wire_encoding()); + } + + p.string(&self.encrypted_private_keys); + + p.finish() + } + pub fn requires_passphrase(&self) -> bool { (!matches!(self.kdf, Kdf::None)) && (!matches!(self.cipher, Cipher::None)) } - pub fn parse_private( - &self, - passphrase: Option<&str>, - ) -> parse::Result> { + pub fn decrypt_encrypted_part(&self, passphrase: Option<&str>) -> parse::Result> { let mut data = self.encrypted_private_keys.clone(); if self.requires_passphrase() { let Some(passphrase) = passphrase else { @@ -98,16 +124,22 @@ impl EncryptedPrivateKeys { let mut output = vec![0; key_size + iv_size]; self.kdf.derive(passphrase, &mut output)?; let (key, iv) = output.split_at(key_size); - self.cipher.decrypt_in_place(&mut data, &key, &iv); + self.cipher.crypt_in_place(&mut data, &key, &iv); } + Ok(data) + } + + pub fn parse_private( + &self, + passphrase: Option<&str>, + ) -> parse::Result> { + let data = self.decrypt_encrypted_part(passphrase)?; let mut p = Parser::new(&data); let checkint1 = p.u32()?; let checkint2 = p.u32()?; if checkint1 != checkint2 { - return Err(parse::ParseError(format!( - "failed sanity check, invalid key or password ({checkint1}!={checkint2})" - ))); + return Err(parse::ParseError(format!("invalid key or password"))); } let mut result_keys = Vec::new(); @@ -115,6 +147,7 @@ impl EncryptedPrivateKeys { for pubkey in &self.public_keys { let keytype = match pubkey { PublicKey::Ed25519 { public_key } => { + // let alg = p.utf8_string()?; if alg != "ssh-ed25519" { return Err(parse::ParseError(format!( @@ -150,6 +183,7 @@ impl EncryptedPrivateKeys { result_keys.push(PlaintextPrivateKey { private_key: keytype, comment: comment.to_owned(), + checkint: checkint1, }); } @@ -169,6 +203,98 @@ impl EncryptedPrivateKeys { } } +pub struct KeyEncryptionParams { + pub cipher: Cipher, + pub kdf: Kdf, + pub passphrase: Option, +} + +impl KeyEncryptionParams { + pub fn secure_or_none(passphrase: String) -> Self { + if passphrase.is_empty() { + Self { + cipher: Cipher::None, + kdf: Kdf::None, + passphrase: None, + } + } else { + Self { + cipher: Cipher::Aes256Ctr, + kdf: Kdf::BCrypt { + salt: rand::random(), + rounds: 24, + }, + passphrase: Some(passphrase), + } + } + } +} + +impl PlaintextPrivateKey { + pub fn generate(comment: String, params: KeyGenerationParams) -> Self { + let keytype = crypto::generate_private_key(params); + Self { + comment, + private_key: keytype, + checkint: rand::random(), + } + } + + pub fn encrypt(&self, params: KeyEncryptionParams) -> parse::Result { + let public_keys = vec![self.private_key.public_key()]; + + let mut enc = Writer::new(); + enc.u32(self.checkint); + enc.u32(self.checkint); + + match self.private_key { + PrivateKeyType::Ed25519 { + public_key, + private_key, + } => { + // + enc.string(b"ssh-ed25519"); + enc.string(&public_key); + let combined = private_key.len() + public_key.len(); + enc.u32(combined as u32); + enc.raw(&private_key); + enc.raw(&public_key); + enc.string(&self.comment.as_bytes()); + } + } + + // uh..., i don't really now how much i need to pad so YOLO this here + // TODO: pad properly. + enc.u8(1); + enc.u8(2); + + let mut encrypted_private_keys = enc.finish(); + + match params.cipher { + Cipher::None => {} + Cipher::Aes256Ctr => { + let (key_size, iv_size) = params.cipher.key_iv_size(); + + let mut output = vec![0; key_size + iv_size]; + params + .kdf + .derive(¶ms.passphrase.unwrap(), &mut output)?; + let (key, iv) = output.split_at(key_size); + params + .cipher + .crypt_in_place(&mut encrypted_private_keys, &key, &iv); + } + } + + Ok(EncryptedPrivateKeys { + public_keys, + cipher: params.cipher, + kdf: params.kdf, + encrypted_private_keys, + }) + } +} + impl PrivateKeyType { pub fn public_key(&self) -> PublicKey { match *self { @@ -179,7 +305,7 @@ impl PrivateKeyType { #[cfg(test)] mod tests { - use crate::{Cipher, EncryptedPrivateKeys, Kdf, PrivateKeyType}; + use crate::{Cipher, EncryptedPrivateKeys, Kdf, KeyEncryptionParams, PrivateKeyType}; // ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHPaiIO6MePXM/QCJWVge1k4dsiefPr4taP9VJbCtXdx uwu // Password: 'test' @@ -206,7 +332,7 @@ zukcSwhnKrg+wzw7/JZQAAAAA3V3dQEC #[test] fn ed25519_none() { - let keys = EncryptedPrivateKeys::parse_unencrypted(TEST_ED25519_NONE).unwrap(); + let keys = EncryptedPrivateKeys::parse(TEST_ED25519_NONE).unwrap(); assert_eq!(keys.public_keys.len(), 1); assert_eq!(keys.cipher, Cipher::None); assert_eq!(keys.kdf, Kdf::None); @@ -218,9 +344,22 @@ zukcSwhnKrg+wzw7/JZQAAAAA3V3dQEC assert!(matches!(key.private_key, PrivateKeyType::Ed25519 { .. })); } + #[test] + fn roundtrip_ed25519_none() { + let keys = EncryptedPrivateKeys::parse(TEST_ED25519_NONE).unwrap(); + let decrypted = keys.parse_private(None).unwrap(); + + let encrypted = decrypted[0] + .encrypt(KeyEncryptionParams::secure_or_none("".to_owned())) + .unwrap(); + + let bytes = encrypted.to_bytes(); + assert_eq!(pem::parse(TEST_ED25519_NONE).unwrap().contents(), bytes); + } + #[test] fn ed25519_aes256ctr() { - let keys = EncryptedPrivateKeys::parse_unencrypted(TEST_ED25519_AES256_CTR).unwrap(); + let keys = EncryptedPrivateKeys::parse(TEST_ED25519_AES256_CTR).unwrap(); assert_eq!(keys.public_keys.len(), 1); assert_eq!(keys.cipher, Cipher::Aes256Ctr); assert!(matches!(keys.kdf, Kdf::BCrypt { .. })); @@ -231,4 +370,17 @@ zukcSwhnKrg+wzw7/JZQAAAAA3V3dQEC assert_eq!(key.comment, "uwu"); assert!(matches!(key.private_key, PrivateKeyType::Ed25519 { .. })); } + + #[test] + fn roundtrip_aes256ctr() { + let keys = EncryptedPrivateKeys::parse(TEST_ED25519_NONE).unwrap(); + let decrypted = keys.parse_private(None).unwrap(); + + let encrypted = decrypted[0] + .encrypt(KeyEncryptionParams::secure_or_none("".to_owned())) + .unwrap(); + + let bytes = encrypted.to_bytes(); + assert_eq!(pem::parse(TEST_ED25519_NONE).unwrap().contents(), bytes); + } } diff --git a/lib/ssh-transport/src/parse.rs b/lib/ssh-transport/src/parse.rs index d049609..b65aaad 100644 --- a/lib/ssh-transport/src/parse.rs +++ b/lib/ssh-transport/src/parse.rs @@ -116,19 +116,19 @@ impl Writer { } pub fn u8(&mut self, v: u8) { - self.write(&[v]); + self.raw(&[v]); } pub fn u32(&mut self, v: u32) { - self.write(&u32::to_be_bytes(v)); + self.raw(&u32::to_be_bytes(v)); } - pub fn write(&mut self, v: &[u8]) { + pub fn raw(&mut self, v: &[u8]) { self.0.extend_from_slice(v); } pub fn array(&mut self, arr: [u8; N]) { - self.write(&arr); + self.raw(&arr); } pub fn name_list(&mut self, list: NameList<'_>) { @@ -146,12 +146,12 @@ impl Writer { if pad_zero { self.u8(0); } - self.write(bytes); + self.raw(bytes); } pub fn string(&mut self, data: &[u8]) { self.u32(data.len() as u32); - self.write(data); + self.raw(data); } pub fn bool(&mut self, v: bool) {