encryption

This commit is contained in:
nora 2024-08-23 01:02:55 +02:00
parent de8f5dde21
commit e35ff86a12
10 changed files with 494 additions and 7 deletions

53
Cargo.lock generated
View file

@ -149,6 +149,17 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" 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]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.6.0" version = "2.6.0"
@ -164,6 +175,16 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
@ -796,6 +817,15 @@ dependencies = [
"windows-targets 0.52.6", "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]] [[package]]
name = "pem" name = "pem"
version = "3.0.4" version = "3.0.4"
@ -1220,6 +1250,29 @@ dependencies = [
"tracing-subscriber", "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]] [[package]]
name = "ssh-protocol" name = "ssh-protocol"
version = "0.1.0" version = "0.1.0"

View file

@ -3,7 +3,7 @@ use std::{io::Write, path::PathBuf};
use clap::Parser; use clap::Parser;
use eyre::{bail, Context}; use eyre::{bail, Context};
use ssh_agent_client::{IdentityAnswer, SocketAgentConnection}; use ssh_agent_client::{IdentityAnswer, SocketAgentConnection};
use ssh_transport::key::SshPubkey; use ssh_transport::key::PublicKey;
#[derive(clap::Parser, Debug)] #[derive(clap::Parser, Debug)]
struct Args { struct Args {
@ -109,6 +109,7 @@ async fn main() -> eyre::Result<()> {
let signature = agent.sign(&key.key_blob, &file, 0).await?; 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)); let signature = pem::encode(&pem::Pem::new("SSH SIGNATURE", signature));
std::io::stdout().write_all(signature.as_bytes())?; 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) { 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 { match key {
Ok(key) => { Ok(key) => {
if show_key_id { if show_key_id {

13
bin/ssh-key/Cargo.toml Normal file
View file

@ -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"

76
bin/ssh-key/src/main.rs Normal file
View file

@ -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(())
}

11
lib/ssh-keys/Cargo.toml Normal file
View file

@ -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" }

3
lib/ssh-keys/README.md Normal file
View file

@ -0,0 +1,3 @@
# ssh-keys
Library for processing OpenSSH keys according to https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.key.

View file

@ -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<Self, Self::Err> {
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<aes::Aes256>;
let mut cipher =
<Aes256Ctr as aes::cipher::KeyIvInit>::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<Self, parse::ParseError> {
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}"))
})
}
}
}
}

220
lib/ssh-keys/src/lib.rs Normal file
View file

@ -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<PublicKey>,
pub cipher: Cipher,
pub kdf: Kdf,
pub encrypted_private_keys: Vec<u8>,
}
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<Self> {
// 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::<Cipher>()?;
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<Vec<PlaintextPrivateKey>> {
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 { .. }));
}
}

View file

@ -1,16 +1,19 @@
//! Operations on SSH keys. //! Operations on SSH keys.
// <https://datatracker.ietf.org/doc/html/rfc4716> exists but is kinda weird
use std::fmt::Display; use std::fmt::Display;
use base64::Engine; use base64::Engine;
use crate::parse::{self, ParseError, Parser, Writer}; use crate::parse::{self, ParseError, Parser, Writer};
pub enum SshPubkey { #[derive(Debug, Clone)]
pub enum PublicKey {
Ed25519 { public_key: [u8; 32] }, Ed25519 { public_key: [u8; 32] },
} }
impl SshPubkey { impl PublicKey {
/// Parses an SSH public key from its wire encoding as specified in /// Parses an SSH public key from its wire encoding as specified in
/// RFC4253, RFC5656, and RFC8709. /// RFC4253, RFC5656, and RFC8709.
pub fn from_wire_encoding(bytes: &[u8]) -> parse::Result<Self> { pub fn from_wire_encoding(bytes: &[u8]) -> parse::Result<Self> {
@ -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 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Ed25519 { .. } => { Self::Ed25519 { .. } => {

View file

@ -28,6 +28,10 @@ impl<'a> Parser<'a> {
Self(data) Self(data)
} }
pub fn remaining(&self) -> &[u8] {
&self.0
}
pub fn has_data(&self) -> bool { pub fn has_data(&self) -> bool {
!self.0.is_empty() !self.0.is_empty()
} }
@ -45,7 +49,10 @@ impl<'a> Parser<'a> {
pub fn array<const N: usize>(&mut self) -> Result<[u8; N]> { pub fn array<const N: usize>(&mut self) -> Result<[u8; N]> {
assert!(N < 100_000); assert!(N < 100_000);
if self.0.len() < N { 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(); let result = self.0[..N].try_into().unwrap();
self.0 = &self.0[N..]; self.0 = &self.0[N..];
@ -54,7 +61,10 @@ impl<'a> Parser<'a> {
pub fn slice(&mut self, len: usize) -> Result<&'a [u8]> { pub fn slice(&mut self, len: usize) -> Result<&'a [u8]> {
if self.0.len() < len { 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 { if len > 100_000 {
return Err(ParseError(format!("bytes too long: {len}"))); return Err(ParseError(format!("bytes too long: {len}")));