diff --git a/Cargo.lock b/Cargo.lock index c11ca60..649df34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -149,6 +149,17 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bcrypt-pbkdf" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2" +dependencies = [ + "blowfish", + "pbkdf2", + "sha2", +] + [[package]] name = "bitflags" version = "2.6.0" @@ -164,6 +175,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -796,6 +817,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + [[package]] name = "pem" version = "3.0.4" @@ -1220,6 +1250,29 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "ssh-key" +version = "0.1.0" +dependencies = [ + "base64", + "clap", + "eyre", + "rpassword", + "ssh-keys", + "tracing", +] + +[[package]] +name = "ssh-keys" +version = "0.1.0" +dependencies = [ + "aes", + "bcrypt-pbkdf", + "ctr", + "pem", + "ssh-transport", +] + [[package]] name = "ssh-protocol" version = "0.1.0" diff --git a/bin/ssh-agentctl/src/main.rs b/bin/ssh-agentctl/src/main.rs index 68be4f6..b6f7c38 100644 --- a/bin/ssh-agentctl/src/main.rs +++ b/bin/ssh-agentctl/src/main.rs @@ -3,7 +3,7 @@ use std::{io::Write, path::PathBuf}; use clap::Parser; use eyre::{bail, Context}; use ssh_agent_client::{IdentityAnswer, SocketAgentConnection}; -use ssh_transport::key::SshPubkey; +use ssh_transport::key::PublicKey; #[derive(clap::Parser, Debug)] struct Args { @@ -109,6 +109,7 @@ async fn main() -> eyre::Result<()> { let signature = agent.sign(&key.key_blob, &file, 0).await?; + // TODO: https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.sshsig let signature = pem::encode(&pem::Pem::new("SSH SIGNATURE", signature)); std::io::stdout().write_all(signature.as_bytes())?; } @@ -148,7 +149,7 @@ async fn list_ids(agent: &mut SocketAgentConnection, print_key_id: bool) -> eyre } fn print_key(id: IdentityAnswer, show_key_id: bool) { - let key = SshPubkey::from_wire_encoding(&id.key_blob); + let key = PublicKey::from_wire_encoding(&id.key_blob); match key { Ok(key) => { if show_key_id { diff --git a/bin/ssh-key/Cargo.toml b/bin/ssh-key/Cargo.toml new file mode 100644 index 0000000..9866962 --- /dev/null +++ b/bin/ssh-key/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "ssh-key" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.5.16", features = ["derive"] } +eyre = "0.6.12" +tracing.workspace = true + +ssh-keys = { path = "../../lib/ssh-keys" } +base64 = "0.22.1" +rpassword = "7.3.1" diff --git a/bin/ssh-key/src/main.rs b/bin/ssh-key/src/main.rs new file mode 100644 index 0000000..1b4fb4f --- /dev/null +++ b/bin/ssh-key/src/main.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +use base64::Engine; +use clap::Parser; +use eyre::{bail, Context}; +use ssh_keys::PrivateKeyType; + +#[derive(clap::Parser)] +struct Args { + #[command(subcommand)] + command: Subcommand, +} + +#[derive(clap::Subcommand)] +enum Subcommand { + Info { + /// Decrypt the key to get more information. Will not display private information unless --show-private is used + #[arg(short, long)] + decrypt: bool, + /// Show the private key. WARNING: This will display the private key + #[arg(long)] + show_private: bool, + id_file: PathBuf, + }, +} + +fn main() -> eyre::Result<()> { + let args = Args::parse(); + + match args.command { + Subcommand::Info { + id_file, + decrypt, + show_private, + } => { + if show_private && !decrypt { + bail!("cannot --show-private without --decrypt"); + } + + let file = std::fs::read(&id_file) + .wrap_err_with(|| format!("reading file {}", id_file.display()))?; + + let keys = ssh_keys::EncryptedPrivateKeys::parse_unencrypted(&file)?; + + 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}"); + } + } + } + } + + Ok(()) +} diff --git a/lib/ssh-keys/Cargo.toml b/lib/ssh-keys/Cargo.toml new file mode 100644 index 0000000..69814ef --- /dev/null +++ b/lib/ssh-keys/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ssh-keys" +version = "0.1.0" +edition = "2021" + +[dependencies] +aes = "0.8.4" +bcrypt-pbkdf = "0.10.0" +ctr = "0.9.2" +pem = "3.0.4" +ssh-transport = { path = "../ssh-transport" } diff --git a/lib/ssh-keys/README.md b/lib/ssh-keys/README.md new file mode 100644 index 0000000..5cdbe9c --- /dev/null +++ b/lib/ssh-keys/README.md @@ -0,0 +1,3 @@ +# ssh-keys + +Library for processing OpenSSH keys according to https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.key. diff --git a/lib/ssh-keys/src/crypto.rs b/lib/ssh-keys/src/crypto.rs new file mode 100644 index 0000000..8b54051 --- /dev/null +++ b/lib/ssh-keys/src/crypto.rs @@ -0,0 +1,97 @@ +use std::str::FromStr; + +use aes::cipher::{KeySizeUser, StreamCipher}; +use ssh_transport::parse::{self, Parser}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Cipher { + None, + Aes256Ctr, +} + +impl FromStr for Cipher { + type Err = parse::ParseError; + + fn from_str(ciphername: &str) -> Result { + let cipher = match ciphername { + "none" => Cipher::None, + "aes256-ctr" => Cipher::Aes256Ctr, + _ => { + return Err(parse::ParseError(format!( + "unsupported cipher: {ciphername}" + ))); + } + }; + Ok(cipher) + } +} + +impl Cipher { + pub(crate) fn key_iv_size(&self) -> (usize, usize) { + match self { + Cipher::None => (0, 0), + Cipher::Aes256Ctr => (aes::Aes256::key_size(), 16), + } + } + + pub(crate) fn decrypt_in_place(&self, data: &mut [u8], key: &[u8], iv: &[u8]) { + match self { + Cipher::None => unreachable!("cannot decrypt none cipher"), + Cipher::Aes256Ctr => { + type Aes256Ctr = ctr::Ctr128BE; + let mut cipher = + ::new_from_slices(key, iv).unwrap(); + cipher.apply_keystream(data); + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Kdf { + None, + BCrypt { salt: [u8; 16], rounds: u32 }, +} +impl Kdf { + pub(crate) fn from_str_and_options( + kdfname: &str, + kdfoptions: &[u8], + ) -> Result { + let kdf = match kdfname { + "none" => { + if !kdfoptions.is_empty() { + return Err(parse::ParseError(format!( + "KDF options must be empty for none KDF" + ))); + } + Kdf::None + } + "bcrypt" => { + let mut opts = Parser::new(kdfoptions); + let salt = opts.string()?; + let rounds = opts.u32()?; + Kdf::BCrypt { + salt: salt + .try_into() + .map_err(|_| parse::ParseError(format!("incorrect bcrypt salt len")))?, + rounds, + } + } + _ => { + return Err(parse::ParseError(format!("unsupported KDF: {kdfname}"))); + } + }; + Ok(kdf) + } + + 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"), + Self::BCrypt { salt, rounds } => { + bcrypt_pbkdf::bcrypt_pbkdf(passphrase, salt, *rounds, output).map_err(|err| { + parse::ParseError(format!("error when performing key derivation: {err}")) + }) + } + } + } +} diff --git a/lib/ssh-keys/src/lib.rs b/lib/ssh-keys/src/lib.rs new file mode 100644 index 0000000..14f7d03 --- /dev/null +++ b/lib/ssh-keys/src/lib.rs @@ -0,0 +1,220 @@ +mod crypto; + +use crypto::{Cipher, Kdf}; +use ssh_transport::{ + key::PublicKey, + parse::{self, Parser}, +}; + +pub struct EncryptedPrivateKeys { + pub public_keys: Vec, + pub cipher: Cipher, + pub kdf: Kdf, + pub encrypted_private_keys: Vec, +} + +pub struct PlaintextPrivateKey { + pub private_key: PrivateKeyType, + pub comment: String, +} + +pub enum PrivateKeyType { + Ed25519 { + public_key: [u8; 32], + private_key: [u8; 32], + }, +} + +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 { + // 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") { + content + } else if content.starts_with(b"-----BEGIN OPENSSH PRIVATE KEY-----") { + pem = pem::parse(content) + .map_err(|err| parse::ParseError(format!("invalid PEM format: {err}")))?; + pem.contents() + } else { + return Err(parse::ParseError("invalid SSH key".to_owned())); + }; + + let mut p = Parser::new(content); + + let magic = p.array::<{ MAGIC.len() }>()?; + if magic != *MAGIC { + return Err(parse::ParseError( + "invalid magic, not an SSH key?".to_owned(), + )); + } + + let ciphername = p.utf8_string()?; + let cipher = ciphername.parse::()?; + let kdfname = p.utf8_string()?; + let kdfoptions = p.string()?; + let kdf = Kdf::from_str_and_options(kdfname, kdfoptions)?; + let keynum = p.u32()?; + + let mut public_keys = Vec::new(); + + for _ in 0..keynum { + let pubkey = p.string()?; + let pubkey = PublicKey::from_wire_encoding(pubkey)?; + public_keys.push(pubkey); + } + + let priv_keys = p.string()?; + + Ok(EncryptedPrivateKeys { + public_keys, + cipher, + kdf, + encrypted_private_keys: priv_keys.to_owned(), + }) + } + + 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> { + let mut data = self.encrypted_private_keys.clone(); + if self.requires_passphrase() { + let Some(passphrase) = passphrase else { + panic!("missing passphrase for encrypted key"); + }; + if passphrase.is_empty() { + return Err(parse::ParseError(format!("empty passphrase"))); + } + + let (key_size, iv_size) = self.cipher.key_iv_size(); + + 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); + } + + let mut p = Parser::new(&self.encrypted_private_keys); + 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})" + ))); + } + + let mut result_keys = Vec::new(); + + 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!( + "algorithm mismatch. pubkey: ssh-ed25519, privkey: {alg}" + ))); + } + let enc_a = p.string()?; // ENC(A) + if enc_a != public_key { + return Err(parse::ParseError(format!("public key mismatch"))); + } + let k_enc_a = p.string()?; // k || ENC(A) + if k_enc_a.len() != 64 { + return Err(parse::ParseError(format!( + "invalid len for ed25519 keypair: {}, expected 64", + k_enc_a.len() + ))); + } + let (k, enc_a) = k_enc_a.split_at(32); + if enc_a != public_key { + // Yes, ed25519 SSH keys seriously store the public key THREE TIMES. + return Err(parse::ParseError(format!("public key mismatch"))); + } + let private_key = k.try_into().unwrap(); + PrivateKeyType::Ed25519 { + public_key: *public_key, + private_key, + } + } + }; + + let comment = p.utf8_string()?; + + result_keys.push(PlaintextPrivateKey { + private_key: keytype, + comment: comment.to_owned(), + }); + } + + // verify padding + for i in 1_u8..=255 { + if p.has_data() { + let b = p.u8()?; + if b != i { + return Err(parse::ParseError(format!( + "private key padding is incorrect: {b} != {i}" + ))); + } + } + } + + Ok(result_keys) + } +} + +impl PrivateKeyType { + pub fn public_key(&self) -> PublicKey { + match *self { + Self::Ed25519 { public_key, .. } => PublicKey::Ed25519 { public_key }, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{Cipher, EncryptedPrivateKeys, Kdf, PrivateKeyType}; + + // ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHPaiIO6MePXM/QCJWVge1k4dsiefPr4taP9VJbCtXdx uwu + // Password: 'test' + const _TEST_ED25519_AES256_CTR: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABA5S8LoGs +SYFE1uIAlgK4I/AAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIHPaiIO6MePXM/QC +JWVge1k4dsiefPr4taP9VJbCtXdxAAAAkB9StlI/JgwhtvDGx7v08RAa76W6aXSgbDJTU/ +KNPzv0yXhCRleYltud2W2R3G6lElGKBgLfC6U944U8ZFHQQevQIHeSGPkbLGklTXrrrLl7 +ZdWF8er/J/gA0H1T0QE/NYiHxY4NdBzYc4GKCBItOmIT8K/4bsMmh7VXtO0WmkmhoumnLX +rsOKyxcDiMs2J8cg== +-----END OPENSSH PRIVATE KEY----- +"; + + // ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIP60Q8iOyatiPeJbpQ8JVoZazukcSwhnKrg+wzw7/JZQ uwu + // no password + const TEST_ED25519_NONE: &[u8] = b"-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACD+tEPIjsmrYj3iW6UPCVaGWs7pHEsIZyq4PsM8O/yWUAAAAIj6bZmH+m2Z +hwAAAAtzc2gtZWQyNTUxOQAAACD+tEPIjsmrYj3iW6UPCVaGWs7pHEsIZyq4PsM8O/yWUA +AAAEAdSh0yeEtOyIa0mzMH36U77BNkiuQkERT8TVTrOOgPyP60Q8iOyatiPeJbpQ8JVoZa +zukcSwhnKrg+wzw7/JZQAAAAA3V3dQEC +-----END OPENSSH PRIVATE KEY----- +"; + + #[test] + fn unencrypted_ed25519() { + let keys = EncryptedPrivateKeys::parse_unencrypted(TEST_ED25519_NONE).unwrap(); + assert_eq!(keys.public_keys.len(), 1); + assert_eq!(keys.cipher, Cipher::None); + assert_eq!(keys.kdf, Kdf::None); + + let decrypted = keys.parse_private(None).unwrap(); + assert_eq!(decrypted.len(), 1); + let key = decrypted.first().unwrap(); + assert_eq!(key.comment, "uwu"); + assert!(matches!(key.private_key, PrivateKeyType::Ed25519 { .. })); + } +} diff --git a/lib/ssh-transport/src/key.rs b/lib/ssh-transport/src/key.rs index 9bf31f3..6b0761c 100644 --- a/lib/ssh-transport/src/key.rs +++ b/lib/ssh-transport/src/key.rs @@ -1,16 +1,19 @@ //! Operations on SSH keys. +// exists but is kinda weird + use std::fmt::Display; use base64::Engine; use crate::parse::{self, ParseError, Parser, Writer}; -pub enum SshPubkey { +#[derive(Debug, Clone)] +pub enum PublicKey { Ed25519 { public_key: [u8; 32] }, } -impl SshPubkey { +impl PublicKey { /// Parses an SSH public key from its wire encoding as specified in /// RFC4253, RFC5656, and RFC8709. pub fn from_wire_encoding(bytes: &[u8]) -> parse::Result { @@ -43,7 +46,7 @@ impl SshPubkey { } } -impl Display for SshPubkey { +impl Display for PublicKey { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Ed25519 { .. } => { diff --git a/lib/ssh-transport/src/parse.rs b/lib/ssh-transport/src/parse.rs index 649a28e..d049609 100644 --- a/lib/ssh-transport/src/parse.rs +++ b/lib/ssh-transport/src/parse.rs @@ -28,6 +28,10 @@ impl<'a> Parser<'a> { Self(data) } + pub fn remaining(&self) -> &[u8] { + &self.0 + } + pub fn has_data(&self) -> bool { !self.0.is_empty() } @@ -45,7 +49,10 @@ impl<'a> Parser<'a> { pub fn array(&mut self) -> Result<[u8; N]> { assert!(N < 100_000); if self.0.len() < N { - return Err(ParseError(format!("packet too short"))); + return Err(ParseError(format!( + "packet too short, expected {N} but found {}", + self.0.len() + ))); } let result = self.0[..N].try_into().unwrap(); self.0 = &self.0[N..]; @@ -54,7 +61,10 @@ impl<'a> Parser<'a> { pub fn slice(&mut self, len: usize) -> Result<&'a [u8]> { if self.0.len() < len { - return Err(ParseError(format!("packet too short"))); + return Err(ParseError(format!( + "packet too short, expected {len} but found {}", + self.0.len() + ))); } if len > 100_000 { return Err(ParseError(format!("bytes too long: {len}")));