use aes::{Aes128, Aes192, Aes256};
use age_core::secrecy::{ExposeSecret, SecretString};
use bcrypt_pbkdf::bcrypt_pbkdf;
use sha2::{Digest, Sha256};
use crate::error::DecryptError;
pub(crate) mod identity;
pub(crate) mod recipient;
pub use identity::{Identity, UnsupportedKey};
pub use recipient::{ParseRecipientKeyError, Recipient};
pub(crate) const SSH_RSA_KEY_PREFIX: &str = "ssh-rsa";
pub(crate) const SSH_ED25519_KEY_PREFIX: &str = "ssh-ed25519";
pub(super) const SSH_RSA_RECIPIENT_TAG: &str = "ssh-rsa";
const SSH_RSA_OAEP_LABEL: &str = "age-encryption.org/v1/ssh-rsa";
pub(super) const SSH_ED25519_RECIPIENT_TAG: &str = "ssh-ed25519";
const SSH_ED25519_RECIPIENT_KEY_LABEL: &[u8] = b"age-encryption.org/v1/ssh-ed25519";
const TAG_LEN_BYTES: usize = 4;
type Aes256CbcDec = cbc::Decryptor<Aes256>;
type Aes128Ctr = ctr::Ctr64BE<Aes128>;
type Aes192Ctr = ctr::Ctr64BE<Aes192>;
type Aes256Ctr = ctr::Ctr64BE<Aes256>;
fn ssh_tag(pubkey: &[u8]) -> [u8; TAG_LEN_BYTES] {
let tag_bytes = Sha256::digest(pubkey);
let mut tag = [0; TAG_LEN_BYTES];
tag.copy_from_slice(&tag_bytes[..TAG_LEN_BYTES]);
tag
}
#[allow(clippy::enum_variant_names)]
#[derive(Clone, Copy, Debug)]
enum OpenSshCipher {
Aes256Cbc,
Aes128Ctr,
Aes192Ctr,
Aes256Ctr,
}
impl OpenSshCipher {
fn decrypt(
self,
kdf: &OpenSshKdf,
p: SecretString,
ct: &[u8],
) -> Result<Vec<u8>, DecryptError> {
match self {
OpenSshCipher::Aes256Cbc => decrypt::aes_cbc::<Aes256CbcDec>(kdf, p, ct, 32),
OpenSshCipher::Aes128Ctr => Ok(decrypt::aes_ctr::<Aes128Ctr>(kdf, p, ct, 16)),
OpenSshCipher::Aes192Ctr => Ok(decrypt::aes_ctr::<Aes192Ctr>(kdf, p, ct, 24)),
OpenSshCipher::Aes256Ctr => Ok(decrypt::aes_ctr::<Aes256Ctr>(kdf, p, ct, 32)),
}
}
}
#[derive(Clone, Debug)]
enum OpenSshKdf {
Bcrypt { salt: Vec<u8>, rounds: u32 },
}
impl OpenSshKdf {
fn derive(&self, passphrase: SecretString, out_len: usize) -> Vec<u8> {
match self {
OpenSshKdf::Bcrypt { salt, rounds } => {
let mut output = vec![0; out_len];
bcrypt_pbkdf(passphrase.expose_secret(), salt, *rounds, &mut output)
.expect("parameters are valid");
output
}
}
}
}
#[derive(Clone)]
pub struct EncryptedKey {
ssh_key: Vec<u8>,
cipher: OpenSshCipher,
kdf: OpenSshKdf,
encrypted: Vec<u8>,
filename: Option<String>,
}
impl EncryptedKey {
pub fn decrypt(
&self,
passphrase: SecretString,
) -> Result<identity::UnencryptedKey, DecryptError> {
let decrypted = self
.cipher
.decrypt(&self.kdf, passphrase, &self.encrypted)?;
let mut parser = read_ssh::openssh_unencrypted_privkey(&self.ssh_key);
match parser(&decrypted)
.map(|(_, sk)| sk)
.map_err(|_| DecryptError::KeyDecryptionFailed)?
{
Identity::Unencrypted(key) => Ok(key),
Identity::Unsupported(_) => Err(DecryptError::KeyDecryptionFailed),
Identity::Encrypted(_) => unreachable!(),
}
}
}
mod decrypt {
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit, StreamCipher};
use age_core::secrecy::SecretString;
use super::OpenSshKdf;
use crate::error::DecryptError;
pub(super) fn aes_cbc<C: BlockDecryptMut + KeyIvInit>(
kdf: &OpenSshKdf,
passphrase: SecretString,
ciphertext: &[u8],
key_len: usize,
) -> Result<Vec<u8>, DecryptError> {
let kdf_output = kdf.derive(passphrase, key_len + 16);
let (key, iv) = kdf_output.split_at(key_len);
let cipher = C::new_from_slices(key, iv).expect("key and IV are correct length");
cipher
.decrypt_padded_vec_mut::<NoPadding>(ciphertext)
.map_err(|_| DecryptError::KeyDecryptionFailed)
}
pub(super) fn aes_ctr<C: StreamCipher + KeyIvInit>(
kdf: &OpenSshKdf,
passphrase: SecretString,
ciphertext: &[u8],
key_len: usize,
) -> Vec<u8> {
let kdf_output = kdf.derive(passphrase, key_len + 16);
let (key, nonce) = kdf_output.split_at(key_len);
let mut cipher = C::new_from_slices(key, nonce).expect("key and nonce are correct length");
let mut plaintext = ciphertext.to_vec();
cipher.apply_keystream(&mut plaintext);
plaintext
}
}
mod read_ssh {
use age_core::secrecy::Secret;
use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint};
use nom::{
branch::alt,
bytes::complete::{tag, take},
combinator::{map, map_opt, map_parser, map_res, rest, verify},
multi::{length_data, length_value},
number::complete::be_u32,
sequence::{delimited, pair, preceded, terminated, tuple},
IResult,
};
use num_traits::Zero;
use rsa::BigUint;
use super::{
identity::{UnencryptedKey, UnsupportedKey},
EncryptedKey, Identity, OpenSshCipher, OpenSshKdf, SSH_ED25519_KEY_PREFIX,
SSH_RSA_KEY_PREFIX,
};
pub(crate) fn string(input: &[u8]) -> IResult<&[u8], &[u8]> {
length_data(be_u32)(input)
}
#[allow(clippy::needless_lifetimes)] pub fn string_tag<'a>(value: &'a str) -> impl Fn(&'a [u8]) -> IResult<&'a [u8], &'a [u8]> {
move |input: &[u8]| length_value(be_u32, tag(value))(input)
}
fn mpint(input: &[u8]) -> IResult<&[u8], BigUint> {
map_opt(string, |bytes| {
if bytes.is_empty() {
Some(BigUint::zero())
} else {
let mut non_zero_bytes = bytes;
while non_zero_bytes[0] == 0 {
non_zero_bytes = &non_zero_bytes[1..];
}
if non_zero_bytes.is_empty() {
return None;
}
if non_zero_bytes.len() + (non_zero_bytes[0] >> 7) as usize != bytes.len() {
return None;
}
Some(BigUint::from_bytes_be(bytes))
}
})(input)
}
enum CipherResult {
Supported(OpenSshCipher),
Unsupported(String),
}
fn encryption_header(input: &[u8]) -> IResult<&[u8], Option<(CipherResult, OpenSshKdf)>> {
alt((
map(
tuple((string_tag("none"), string_tag("none"), string_tag(""))),
|_| None,
),
map(
tuple((
alt((
map(string_tag("aes256-cbc"), |_| {
CipherResult::Supported(OpenSshCipher::Aes256Cbc)
}),
map(string_tag("aes128-ctr"), |_| {
CipherResult::Supported(OpenSshCipher::Aes128Ctr)
}),
map(string_tag("aes192-ctr"), |_| {
CipherResult::Supported(OpenSshCipher::Aes192Ctr)
}),
map(string_tag("aes256-ctr"), |_| {
CipherResult::Supported(OpenSshCipher::Aes256Ctr)
}),
map(string, |s| {
CipherResult::Unsupported(String::from_utf8_lossy(s).into_owned())
}),
)),
map_opt(
preceded(
string_tag("bcrypt"),
map_parser(string, tuple((string, be_u32))),
),
|(salt, rounds)| {
if salt.is_empty() || rounds == 0 {
None
} else {
Some(OpenSshKdf::Bcrypt {
salt: salt.into(),
rounds,
})
}
},
),
)),
Some,
),
))(input)
}
fn comment_and_padding(input: &[u8]) -> IResult<&[u8], &[u8]> {
terminated(
string,
verify(rest, |padding: &[u8]| {
padding.iter().enumerate().all(|(i, b)| *b == (i + 1) as u8)
}),
)(input)
}
fn openssh_rsa_privkey(input: &[u8]) -> IResult<&[u8], rsa::RsaPrivateKey> {
delimited(
string_tag(SSH_RSA_KEY_PREFIX),
map_res(
tuple((mpint, mpint, mpint, mpint, mpint, mpint)),
|(n, e, d, _iqmp, p, q)| rsa::RsaPrivateKey::from_components(n, e, d, vec![p, q]),
),
comment_and_padding,
)(input)
}
fn openssh_ed25519_privkey(input: &[u8]) -> IResult<&[u8], Secret<[u8; 64]>> {
delimited(
string_tag(SSH_ED25519_KEY_PREFIX),
map_opt(tuple((string, string)), |(pubkey_bytes, privkey_bytes)| {
if privkey_bytes.len() == 64 && pubkey_bytes == &privkey_bytes[32..64] {
let mut privkey = [0; 64];
privkey.copy_from_slice(privkey_bytes);
Some(Secret::new(privkey))
} else {
None
}
}),
comment_and_padding,
)(input)
}
#[allow(clippy::needless_lifetimes)]
pub(super) fn openssh_unencrypted_privkey<'a>(
ssh_key: &[u8],
) -> impl FnMut(&'a [u8]) -> IResult<&'a [u8], Identity> {
let ssh_key_rsa = ssh_key.to_vec();
let ssh_key_ed25519 = ssh_key.to_vec();
preceded(
map_opt(pair(take(4usize), take(4usize)), |(c1, c2)| {
if c1 == c2 {
Some(c1)
} else {
None
}
}),
alt((
map(openssh_rsa_privkey, move |sk| {
UnencryptedKey::SshRsa(ssh_key_rsa.clone(), Box::new(sk)).into()
}),
map(openssh_ed25519_privkey, move |privkey| {
UnencryptedKey::SshEd25519(ssh_key_ed25519.clone(), privkey).into()
}),
map(string, |key_type| {
UnsupportedKey::Type(String::from_utf8_lossy(key_type).to_string()).into()
}),
)),
)
}
pub(super) fn openssh_privkey(input: &[u8]) -> IResult<&[u8], Identity> {
map_opt(
pair(
preceded(tag(b"openssh-key-v1\x00"), encryption_header),
pair(
preceded(
tag(b"\x00\x00\x00\x01"),
string, ),
string, ),
),
|(encryption, (ssh_key, private))| match &encryption {
None => {
let (_, privkey) = openssh_unencrypted_privkey(ssh_key)(private).ok()?;
Some(privkey)
}
Some((CipherResult::Supported(cipher), kdf)) => Some(
EncryptedKey {
ssh_key: ssh_key.to_vec(),
cipher: *cipher,
kdf: kdf.clone(),
encrypted: private.to_vec(),
filename: None,
}
.into(),
),
Some((CipherResult::Unsupported(cipher), _)) => {
Some(UnsupportedKey::EncryptedSsh(cipher.clone()).into())
}
},
)(input)
}
pub(super) fn rsa_pubkey(input: &[u8]) -> IResult<&[u8], rsa::RsaPublicKey> {
preceded(
string_tag(SSH_RSA_KEY_PREFIX),
map_res(tuple((mpint, mpint)), |(exponent, modulus)| {
rsa::RsaPublicKey::new(modulus, exponent)
}),
)(input)
}
pub(super) fn ed25519_pubkey(input: &[u8]) -> IResult<&[u8], EdwardsPoint> {
preceded(
string_tag(SSH_ED25519_KEY_PREFIX),
map_opt(string, |buf| {
if buf.len() == 32 {
CompressedEdwardsY::from_slice(buf).decompress()
} else {
None
}
}),
)(input)
}
}
mod write_ssh {
use cookie_factory::{bytes::be_u32, combinator::slice, sequence::tuple, SerializeFn};
use num_traits::identities::Zero;
use rsa::{BigUint, PublicKeyParts};
use std::io::Write;
use super::SSH_RSA_KEY_PREFIX;
fn string<S: AsRef<[u8]>, W: Write>(value: S) -> impl SerializeFn<W> {
tuple((be_u32(value.as_ref().len() as u32), slice(value)))
}
fn mpint<W: Write>(value: &BigUint) -> impl SerializeFn<W> {
let mut bytes = value.to_bytes_be();
if value.is_zero() {
bytes = vec![];
} else if bytes[0] >> 7 != 0 {
bytes.insert(0, 0);
}
string(bytes)
}
pub(super) fn rsa_pubkey<W: Write>(pubkey: &rsa::RsaPublicKey) -> impl SerializeFn<W> {
tuple((
string(SSH_RSA_KEY_PREFIX),
mpint(pubkey.e()),
mpint(pubkey.n()),
))
}
}