mirror of
https://github.com/Noratrieb/cluelessh.git
synced 2026-01-14 16:35:06 +01:00
encryption
This commit is contained in:
parent
de8f5dde21
commit
e35ff86a12
10 changed files with 494 additions and 7 deletions
11
lib/ssh-keys/Cargo.toml
Normal file
11
lib/ssh-keys/Cargo.toml
Normal 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
3
lib/ssh-keys/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# ssh-keys
|
||||
|
||||
Library for processing OpenSSH keys according to https://github.com/openssh/openssh-portable/blob/a76a6b85108e3032c8175611ecc5746e7131f876/PROTOCOL.key.
|
||||
97
lib/ssh-keys/src/crypto.rs
Normal file
97
lib/ssh-keys/src/crypto.rs
Normal 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
220
lib/ssh-keys/src/lib.rs
Normal 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 { .. }));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue