Skip to content

Commit

Permalink
Decode private keys in OpenSSH format
Browse files Browse the repository at this point in the history
  • Loading branch information
honzasp committed Jul 10, 2022
1 parent 63a915b commit 651c81c
Show file tree
Hide file tree
Showing 26 changed files with 1,070 additions and 203 deletions.
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,14 @@ name = "compat"
path = "tests/compat/main.rs"
harness = false

[[test]]
name = "keys"
path = "tests/keys/main.rs"

[dependencies]
aes = "0.8"
aes-gcm = "^0.10.0-pre"
bcrypt-pbkdf = "0.9"
bytes = "1.1"
cbc = "0.1"
chacha20 = "0.9"
Expand All @@ -38,6 +43,7 @@ num-bigint-dig = {version = "0.8", features = ["rand"]}
p256 = "0.11"
p384 = "0.11"
parking_lot = "0.12"
pem = "1.0"
pin-project = "1.0"
poly1305 = "0.7"
rand = {version = "0.8", features = ["getrandom"]}
Expand All @@ -61,3 +67,6 @@ env_logger = "0.9"
futures = "0.3"
regex = "1.5"
tokio = {version = "1", features = ["full"]}

[features]
debug_less_secure = []
12 changes: 12 additions & 0 deletions src/cipher/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ mod chacha_poly;
mod none;
mod stream;

static ALGOS: &[&CipherAlgo] = &[
&AES128_GCM, &AES256_GCM,
&AES128_CBC, &AES192_CBC, &AES256_CBC,
&CHACHA20_POLY1305,
&NONE,
&AES128_CTR, &AES192_CTR, &AES256_CTR,
];

/// Algorithm for encrypting and decrypting messages.
///
/// See the [module documentation][self] for details.
Expand Down Expand Up @@ -112,3 +120,7 @@ impl CipherAlgoVariant {
matches!(self, CipherAlgoVariant::Aead(_))
}
}

pub(crate) fn algo_by_name(name: &str) -> Option<&'static CipherAlgo> {
ALGOS.iter().copied().find(|algo| algo.name == name)
}
24 changes: 23 additions & 1 deletion src/codec/packet_decode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,17 +69,39 @@ impl PacketDecode {
Ok(list.split(|x| x == ',').map(|x| x.into()).collect())
}

/// Decode a `mpint` as [`BigUint`]
/// Decode a `mpint` as [`BigUint`].
pub fn get_biguint(&mut self) -> Result<BigUint> {
self.get_bytes().map(|x| BigUint::from_bytes_be(&x))
}

/// Decode a `mpint` as a scalar in unsigned big endian with given length.
pub fn get_scalar(&mut self, len: usize) -> Result<Vec<u8>> {
let mut bytes = self.get_bytes()?;
while bytes.get(0) == Some(&0) {
bytes.advance(1);
}

if bytes.len() > len {
return Err(Error::Decode("decoded number is too long"));
}

let mut digits_be = vec![0; len];
digits_be[len - bytes.len()..].copy_from_slice(&bytes);
Ok(digits_be)
}

/// Skip `len` bytes.
pub fn skip(&mut self, len: usize) -> Result<()> {
self.ensure(len)?;
Ok(self.buf.advance(len))
}

/// Read `len` bytes directly from the buffer.
pub fn get_raw(&mut self, len: usize) -> Result<Bytes> {
self.ensure(len)?;
Ok(self.buf.split_to(len))
}

fn ensure(&self, min_remaining: usize) -> Result<()> {
if min_remaining <= self.buf.remaining() {
Ok(())
Expand Down
6 changes: 6 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ pub enum Error {
ClientClosed,
#[error("client has already disconnected")]
ClientDisconnected,
#[error("could not parse PEM file")]
Pem(pem::PemError),
#[error("unexpected PEM tag {0:?}, expected {1:?}")]
BadPemTag(String, String),
#[error("bad passphrase when decoding key")]
BadKeyPassphrase,
}

/// Error that occured because we could not negotiate an algorithm.
Expand Down
19 changes: 19 additions & 0 deletions src/keys/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Encoding and decoding of keys.

use crate::error::{Result, Error};
pub use self::openssh::{
OpensshKeypair, OpensshKeypairNopass,
decode_openssh_pem_keypair, decode_openssh_binary_keypair,
decode_openssh_pem_keypair_nopass, decode_openssh_binary_keypair_nopass,
};

mod openssh;

fn decode_pem(pem_data: &[u8], expected_tag: &'static str) -> Result<Vec<u8>> {
let pem = pem::parse(pem_data).map_err(Error::Pem)?;
if pem.tag != expected_tag {
return Err(Error::BadPemTag(pem.tag, expected_tag.into()))
}
Ok(pem.contents)
}

217 changes: 217 additions & 0 deletions src/keys/openssh.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
//! Encoding and decoding keys.
use bytes::Bytes;
use crate::cipher::{self, CipherAlgoVariant};
use crate::codec::PacketDecode;
use crate::error::{Result, Error};
use crate::pubkey::{Pubkey, Privkey};

/// Keypair (public and private key) in OpenSSH format.
///
/// Note that we do not check that the public key and private key form a valid keypair.
#[derive(Clone, PartialEq, Eq)]
pub struct OpensshKeypair {
/// Public key, always unencrypted.
pub pubkey: Pubkey,
/// Private key, may be encrypted in the key file.
pub privkey: Privkey,
/// Comment, encrypted if and only if the private key is encrypted.
pub comment: String,
}

/// Keypair in OpenSSH format, decoded without a password.
///
/// We can always decode the public key, which is stored without encryption. The private key will
/// be decoded only if the file was not encrypted.
#[derive(Clone, PartialEq, Eq)]
pub struct OpensshKeypairNopass {
/// Public key, available even without password.
pub pubkey: Pubkey,
/// Private key, available only if the key file was not encrypted.
pub privkey: Option<Privkey>,
/// Comment, available only if the key file was not encrypted.
pub comment: Option<String>,
}

static PEM_TAG: &str = "OPENSSH PRIVATE KEY";

/// Decode a private key from OpenSSH PEM format.
///
/// Files in this format start with `-----BEGIN OPENSSH PRIVATE KEY-----`, followed by
/// base64-encoded binary data (see [`decode_openssh_binary_keypair()`]).
///
/// If the key is encrypted, we will try to decrypt it using the provided `passphrase`. If the
/// passphrase is not correct, this function returns [`Error::BadKeyPassphrase`]. You can pass an
/// empty passphrase if the key is not encrypted.
///
/// If the key might be encrypted and you need to prompt the user for a password, consider using
/// [`decode_openssh_pem_keypair_nopass()`] to detect whether the password is necessary.
pub fn decode_openssh_pem_keypair(pem_data: &[u8], passphrase: &[u8]) -> Result<OpensshKeypair> {
let data = super::decode_pem(pem_data, PEM_TAG)?;
decode_openssh_binary_keypair(data.into(), passphrase)
}

/// Decode a private key from OpenSSH PEM format without decryption.
///
/// Files in this format start with `-----BEGIN OPENSSH PRIVATE KEY-----`, followed by
/// base64-encoded binary data (see [`decode_openssh_binary_keypair()`]).
///
/// If the key is encrypted, the resulting [`OpensshKeypairNopass`] will contain only the public
/// key, which is stored without encryption. The private key is decoded only if it is not
/// encrypted.
///
/// For example, you can use this method together with [`Client::check_pubkey()`] to test whether
/// the public key can be used for authentication before prompting the user for password.
pub fn decode_openssh_pem_keypair_nopass(pem_data: &[u8]) -> Result<OpensshKeypairNopass> {
let data = super::decode_pem(pem_data, PEM_TAG)?;
decode_openssh_binary_keypair_nopass(data.into())
}

/// Decode a private key from OpenSSH binary format.
///
/// The binary format is described in file `PROTOCOL.key` in the OpenSSH sources, it starts with
/// bytes `"openssh-key-v1\0". In real world, key files are usually in textual PEM format (see
/// [`decode_openssh_pem_keypair()`].
pub fn decode_openssh_binary_keypair(data: Bytes, passphrase: &[u8]) -> Result<OpensshKeypair> {
let raw = decode_raw(data)?;
let plaintext = decrypt(&raw, passphrase)?;
let (privkey, comment) = decode_plaintext(plaintext)?;
Ok(OpensshKeypair { pubkey: raw.pubkey, privkey, comment })
}

/// Decode a private key from OpenSSH binary format without decryption.
///
/// The binary format is described in file `PROTOCOL.key` in the OpenSSH sources, it starts with
/// bytes `"openssh-key-v1\0". In real world, key files are usually in textual PEM format (see
/// [`decode_openssh_pem_keypair_nopass()`].
///
/// If the key is encrypted, the resulting [`OpensshKeypairNopass`] will contain only the public
/// key, which is stored without encryption. The private key is decoded only if it is not
/// encrypted.
///
/// For example, you can use this method together with [`Client::check_pubkey()`] to test whether
/// the public key can be used for authentication before prompting the user for password.
pub fn decode_openssh_binary_keypair_nopass(data: Bytes) -> Result<OpensshKeypairNopass> {
let raw = decode_raw(data)?;
let (privkey, comment) =
if let Ok(plaintext) = decrypt(&raw, &[]) {
let (privkey, comment) = decode_plaintext(plaintext)?;
(Some(privkey), Some(comment))
} else {
(None, None)
};
Ok(OpensshKeypairNopass { pubkey: raw.pubkey, privkey, comment })
}

#[derive(Debug)]
struct RawKeypair {
cipher_name: String,
kdf_name: String,
kdf_options: Bytes,
pubkey: Pubkey,
ciphertext: Bytes,
}

fn decode_raw(data: Bytes) -> Result<RawKeypair> {
let mut data = PacketDecode::new(data);

let auth_magic = b"openssh-key-v1\0";
let magic = data.get_raw(auth_magic.len())?;
if magic.as_ref() != auth_magic.as_ref() {
return Err(Error::Decode("this does not seem to be an OpenSSH keypair (bad magic bytes)"))
}

let cipher_name = data.get_string()?;
let kdf_name = data.get_string()?;
let kdf_options = data.get_bytes()?;

let key_count = data.get_u32()?;
if key_count != 1 {
return Err(Error::Decode("this OpenSSH file does not contain exactly one keypair"))
}

let pubkey_blob = data.get_bytes()?;
let pubkey = Pubkey::decode(pubkey_blob)?;

let ciphertext = data.get_bytes()?;
Ok(RawKeypair { cipher_name, kdf_name, kdf_options, pubkey, ciphertext })
}

fn decode_plaintext(plaintext: Vec<u8>) -> Result<(Privkey, String)> {
let mut plaintext = PacketDecode::new(plaintext.into());
let check_1 = plaintext.get_u32()?;
let check_2 = plaintext.get_u32()?;
if check_1 != check_2 {
return Err(Error::BadKeyPassphrase)
}
let privkey = Privkey::decode(&mut plaintext)?;
let comment = plaintext.get_string()?;

let padding = plaintext.remaining();
for (idx, &padding_byte) in padding.iter().enumerate() {
if padding_byte != (idx + 1) as u8 {
return Err(Error::Decode("bad padding of OpenSSH keypair"))
}
}

Ok((privkey, comment))
}

fn decrypt(raw: &RawKeypair, passphrase: &[u8]) -> Result<Vec<u8>> {
let cipher_algo = match cipher::algo_by_name(&raw.cipher_name) {
Some(algo) => algo,
None => return Err(Error::Decode("OpenSSH keypair encrypted with an unknown cipher")),
};

let mut key_material = vec![0; cipher_algo.key_len + cipher_algo.iv_len];
if !key_material.is_empty() {
derive_keys(&raw.kdf_name, &raw.kdf_options, passphrase, &mut key_material)?;
}
let key = &key_material[..cipher_algo.key_len];
let iv = &key_material[cipher_algo.key_len..];

if raw.ciphertext.len() % cipher_algo.block_len != 0 {
return Err(Error::Decode("OpenSSH keypair ciphertext is not aligned to cipher block"))
}

match &cipher_algo.variant {
CipherAlgoVariant::Standard(algo) => {
let mut decrypt = (algo.make_decrypt)(key, iv);
let mut data = raw.ciphertext.to_vec();
decrypt.decrypt(&mut data);
Ok(data)
},
CipherAlgoVariant::Aead(algo) => {
let mut decrypt = (algo.make_decrypt)(key, iv);
if raw.ciphertext.len() < algo.tag_len {
return Err(Error::Decode("OpenSSH keypair ciphertext is too short"))
}
let plaintext_len = raw.ciphertext.len() - algo.tag_len;

// the AEAD algos assume that the first four bytes are packet length, which is handled
// in a special way, so we add dummy zeros at the beginning...
let mut data = vec![0; 4 + plaintext_len];
data[4..].copy_from_slice(&raw.ciphertext[..plaintext_len]);
let tag = &raw.ciphertext[plaintext_len..];
decrypt.decrypt_and_verify(0, &mut data, tag)?;

// ...and now we must remove the dummy length
Ok(data[4..].to_vec())
},
}
}

fn derive_keys(kdf_name: &str, kdf_options: &[u8], passphrase: &[u8], output: &mut [u8]) -> Result<()> {
if kdf_name != "bcrypt" {
return Err(Error::Decode("OpenSSH keypair encrypted with an unknown key derivation function"))
}

if passphrase.is_empty() {
return Err(Error::BadKeyPassphrase)
}

let mut kdf_options = PacketDecode::new(Bytes::copy_from_slice(kdf_options));
let salt = kdf_options.get_bytes()?;
let rounds = kdf_options.get_u32()?;
bcrypt_pbkdf::bcrypt_pbkdf(passphrase, &salt, rounds, output)
.map_err(|_| Error::Crypto("invalid parameters for bcrypt_pbkdf key derivation function"))
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ mod codec;
pub mod codes;
mod error;
pub mod kex;
pub mod keys;
pub mod mac;
pub mod pubkey;
mod util;
Loading

0 comments on commit 651c81c

Please sign in to comment.