diff --git a/.github/workflows/ssh-key.yml b/.github/workflows/ssh-key.yml index b7c531aa..358e97b5 100644 --- a/.github/workflows/ssh-key.yml +++ b/.github/workflows/ssh-key.yml @@ -84,6 +84,9 @@ jobs: with: toolchain: ${{ matrix.rust }} - uses: RustCrypto/actions/cargo-hack-install@master - - run: cargo hack test --feature-powerset --exclude-features default,std - - run: cargo test + - run: cargo hack test --feature-powerset --exclude-features aes-gcm,default,encryption,getrandom,std + - run: cargo test --features aes-gcm + - run: cargo test --features encryption + - run: cargo test --features getrandom + - run: cargo test --features std - run: cargo test --all-features diff --git a/Cargo.lock b/Cargo.lock index eb7c39b5..45717503 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aead" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c192eb8f11fc081b0fe4259ba5af04217d4e0faddd02417310a927911abd7c8" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.2" @@ -13,6 +23,20 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "aes-gcm" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82e1366e0c69c9f927b1fa5ce2c7bf9eafc8f9268c0b9800729e8b267612447c" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "autocfg" version = "1.1.0" @@ -326,6 +350,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d930750de5717d2dd0b8c0d42c076c0e884c81a73e6cab859bbd2339c71e3e40" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "group" version = "0.13.0" @@ -461,6 +495,12 @@ dependencies = [ "libm 0.2.6", ] +[[package]] +name = "opaque-debug" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" + [[package]] name = "p256" version = "0.13.0" @@ -551,6 +591,18 @@ version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d7ddaed09e0eb771a79ab0fd64609ba0afb0a8366421957936ad14cbd13630" +[[package]] +name = "polyval" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef234e08c11dfcb2e56f79fd70f6f2eb7f025c0ce2333e82f4f0518ecad30c6" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -747,6 +799,7 @@ name = "ssh-key" version = "0.6.0-pre.0" dependencies = [ "aes", + "aes-gcm", "bcrypt-pbkdf", "ctr", "dsa", @@ -794,6 +847,16 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" +[[package]] +name = "universal-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d3160b73c9a19f7e2939a2fdad446c57c1bbbbf4d919d3213ff1267a580d8b5" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "version_check" version = "0.9.4" diff --git a/ssh-key/Cargo.toml b/ssh-key/Cargo.toml index 5368b0ff..25713b19 100644 --- a/ssh-key/Cargo.toml +++ b/ssh-key/Cargo.toml @@ -25,6 +25,7 @@ zeroize = { version = "1", default-features = false } # optional dependencies aes = { version = "0.8", optional = true, default-features = false } +aes-gcm = { version = "0.10", optional = true, default-features = false, features = ["aes"] } ctr = { version = "0.9", optional = true, default-features = false } bcrypt-pbkdf = { version = "0.10", optional = true, default-features = false, features = ["alloc"] } bigint = { package = "num-bigint-dig", version = "0.8", optional = true, default-features = false } @@ -61,6 +62,7 @@ std = [ "signature/std" ] +aes-gcm = ["dep:aes-gcm", "encryption"] dsa = ["dep:bigint", "dep:dsa", "dep:sha1", "alloc", "signature/rand_core"] ecdsa = ["dep:sec1"] ed25519 = ["dep:ed25519-dalek", "rand_core"] diff --git a/ssh-key/src/cipher.rs b/ssh-key/src/cipher.rs index 90975515..291f7023 100644 --- a/ssh-key/src/cipher.rs +++ b/ssh-key/src/cipher.rs @@ -12,9 +12,24 @@ use aes::{ Aes256, }; +#[cfg(feature = "aes-gcm")] +use aes_gcm::{aead::AeadInPlace, Aes256Gcm}; + /// AES-256 in counter (CTR) mode const AES256_CTR: &str = "aes256-ctr"; +/// AES-256 in Galois/Counter Mode (GCM). +const AES256_GCM: &str = "aes256-gcm@openssh.com"; + +/// Nonces for AEAD modes. +#[cfg(feature = "aes-gcm")] +type AeadNonce = [u8; 12]; + +/// Authentication tag for ciphertext data. +/// +/// This is used by e.g. `aes256-gcm@openssh.com` +pub(crate) type Tag = [u8; 16]; + /// Counter mode with a 32-bit big endian counter. #[cfg(feature = "encryption")] type Ctr128BE = ctr::CtrCore; @@ -28,6 +43,9 @@ pub enum Cipher { /// AES-256 in counter (CTR) mode. Aes256Ctr, + + /// AES-256 in Galois/Counter Mode (GCM). + Aes256Gcm, } impl Cipher { @@ -39,6 +57,7 @@ impl Cipher { match ciphername { "none" => Ok(Self::None), AES256_CTR => Ok(Self::Aes256Ctr), + AES256_GCM => Ok(Self::Aes256Gcm), _ => Err(Error::Algorithm), } } @@ -48,6 +67,7 @@ impl Cipher { match self { Self::None => "none", Self::Aes256Ctr => AES256_CTR, + Self::Aes256Gcm => AES256_GCM, } } @@ -56,6 +76,7 @@ impl Cipher { match self { Self::None => None, Self::Aes256Ctr => Some((32, 16)), + Self::Aes256Gcm => Some((32, 12)), } } @@ -63,7 +84,7 @@ impl Cipher { pub fn block_size(self) -> usize { match self { Self::None => 8, - Self::Aes256Ctr => 16, + Self::Aes256Ctr | Self::Aes256Gcm => 16, } } @@ -77,6 +98,11 @@ impl Cipher { } } + /// Does this cipher have an authentication tag? (i.e. is it an AEAD mode?) + pub fn has_tag(self) -> bool { + matches!(self, Self::Aes256Gcm) + } + /// Is this cipher `none`? pub fn is_none(self) -> bool { self == Self::None @@ -89,21 +115,36 @@ impl Cipher { /// Decrypt the ciphertext in the `buffer` in-place using this cipher. #[cfg(feature = "encryption")] - pub fn decrypt(self, key: &[u8], iv: &[u8], buffer: &mut [u8]) -> Result<()> { + pub fn decrypt(self, key: &[u8], iv: &[u8], buffer: &mut [u8], tag: Option) -> Result<()> { match self { - Self::None => return Err(Error::Crypto), - // Counter mode encryption and decryption are the same operation - Self::Aes256Ctr => self.encrypt(key, iv, buffer)?, - } + Self::Aes256Ctr => { + if tag.is_some() { + return Err(Error::Crypto); + } + + // Counter mode encryption and decryption are the same operation + self.encrypt(key, iv, buffer)?; + Ok(()) + } + #[cfg(feature = "aes-gcm")] + Self::Aes256Gcm => { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| Error::Crypto)?; + let nonce = AeadNonce::try_from(iv).map_err(|_| Error::Crypto)?; + let tag = tag.ok_or(Error::Crypto)?; + cipher + .decrypt_in_place_detached(&nonce.into(), &[], buffer, &tag.into()) + .map_err(|_| Error::Crypto)?; - Ok(()) + Ok(()) + } + _ => Err(Error::Crypto), + } } /// Encrypt the ciphertext in the `buffer` in-place using this cipher. #[cfg(feature = "encryption")] - pub fn encrypt(self, key: &[u8], iv: &[u8], buffer: &mut [u8]) -> Result<()> { + pub fn encrypt(self, key: &[u8], iv: &[u8], buffer: &mut [u8]) -> Result> { match self { - Self::None => return Err(Error::Crypto), Self::Aes256Ctr => { let cipher = Aes256::new_from_slice(key) .and_then(|aes| Ctr128BE::inner_iv_slice_init(aes, iv)) @@ -112,10 +153,21 @@ impl Cipher { cipher .try_apply_keystream_partial(buffer.into()) .map_err(|_| Error::Crypto)?; + + Ok(None) } - } + #[cfg(feature = "aes-gcm")] + Self::Aes256Gcm => { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|_| Error::Crypto)?; + let nonce = AeadNonce::try_from(iv).map_err(|_| Error::Crypto)?; + let tag = cipher + .encrypt_in_place_detached(&nonce.into(), &[], buffer) + .map_err(|_| Error::Crypto)?; - Ok(()) + Ok(Some(tag.into())) + } + _ => Err(Error::Crypto), + } } } diff --git a/ssh-key/src/private.rs b/ssh-key/src/private.rs index 25526229..bc8d36d6 100644 --- a/ssh-key/src/private.rs +++ b/ssh-key/src/private.rs @@ -136,7 +136,9 @@ pub use self::ecdsa::{EcdsaKeypair, EcdsaPrivateKey}; #[cfg(all(feature = "alloc", feature = "ecdsa"))] pub use self::sk::SkEcdsaSha2NistP256; -use crate::{public, Algorithm, Cipher, Error, Fingerprint, HashAlg, Kdf, PublicKey, Result}; +use crate::{ + cipher::Tag, public, Algorithm, Cipher, Error, Fingerprint, HashAlg, Kdf, PublicKey, Result, +}; use core::str; use encoding::{ pem::{LineEnding, PemLabel}, @@ -195,6 +197,9 @@ pub struct PrivateKey { /// Private keypair data. key_data: KeypairData, + + /// Authentication tag for authenticated encryption modes. + auth_tag: Option, } impl PrivateKey { @@ -307,11 +312,11 @@ impl PrivateKey { /// Returns [`Error::Decrypted`] if the private key is already decrypted. #[cfg(feature = "encryption")] pub fn decrypt(&self, password: impl AsRef<[u8]>) -> Result { - let (key_bytes, iv_bytes) = self.kdf.derive_key_and_iv(self.cipher, password)?; + let (key, iv) = self.kdf.derive_key_and_iv(self.cipher, password)?; let ciphertext = self.key_data.encrypted().ok_or(Error::Decrypted)?; let mut buffer = Zeroizing::new(ciphertext.to_vec()); - self.cipher.decrypt(&key_bytes, &iv_bytes, &mut buffer)?; + self.cipher.decrypt(&key, &iv, &mut buffer, self.auth_tag)?; Self::decode_privatekey_comment_pair( &mut &**buffer, @@ -366,7 +371,7 @@ impl PrivateKey { // Encode and encrypt private key self.encode_privatekey_comment_pair(&mut out, cipher, checkint)?; - cipher.encrypt(&key_bytes, &iv_bytes, out.as_mut_slice())?; + let auth_tag = cipher.encrypt(&key_bytes, &iv_bytes, out.as_mut_slice())?; Ok(Self { cipher, @@ -374,6 +379,7 @@ impl PrivateKey { checkint: None, public_key: self.public_key.key_data.clone().into(), key_data: KeypairData::Encrypted(out), + auth_tag, }) } @@ -452,6 +458,7 @@ impl PrivateKey { checkint: Some(checkint), public_key: public_key.into(), key_data, + auth_tag: None, }) } @@ -543,6 +550,7 @@ impl PrivateKey { checkint: Some(checkint1), public_key, key_data, + auth_tag: None, }) } @@ -647,6 +655,14 @@ impl Decode for PrivateKey { return Err(Error::Crypto); } + let auth_tag = if cipher.has_tag() { + let mut tag = Tag::default(); + reader.read(&mut tag)?; + Some(tag) + } else { + None + }; + if !reader.is_finished() { return Err(Error::TrailingData { remaining: reader.remaining_len(), @@ -659,6 +675,7 @@ impl Decode for PrivateKey { checkint: None, public_key: public_key.into(), key_data: KeypairData::Encrypted(ciphertext), + auth_tag, }); } @@ -690,6 +707,7 @@ impl Encode for PrivateKey { 4, // number of keys (uint32) self.public_key.key_data().encoded_len_prefixed()?, private_key_len, + self.auth_tag.map(|tag| tag.len()).unwrap_or(0), ] .checked_sum()?) } @@ -708,6 +726,10 @@ impl Encode for PrivateKey { // Encode private key if self.is_encrypted() { self.key_data.encode_prefixed(writer)?; + + if let Some(tag) = &self.auth_tag { + writer.write(tag)?; + } } else { self.encoded_privatekey_comment_pair_len(Cipher::None)? .encode(writer)?; @@ -809,6 +831,7 @@ impl TryFrom for PrivateKey { checkint: None, public_key: public_key.into(), key_data, + auth_tag: None, }) } } diff --git a/ssh-key/tests/encrypted_private_key.rs b/ssh-key/tests/encrypted_private_key.rs index 02d306d9..abf52547 100644 --- a/ssh-key/tests/encrypted_private_key.rs +++ b/ssh-key/tests/encrypted_private_key.rs @@ -9,18 +9,23 @@ use ssh_key::{Algorithm, Cipher, Kdf, KdfAlg, PrivateKey}; #[cfg(all(feature = "encryption"))] const OPENSSH_ED25519_EXAMPLE: &str = include_str!("examples/id_ed25519"); -/// Encrypted Ed25519 OpenSSH-formatted private key. +/// AES-CTR encrypted Ed25519 OpenSSH-formatted private key. /// -/// This is the encrypted form of `OPENSSH_ED25519_EXAMPLE`. -const OPENSSH_ED25519_ENC_EXAMPLE: &str = include_str!("examples/id_ed25519.enc"); +/// Plaintext is `OPENSSH_ED25519_EXAMPLE`. +const OPENSSH_AES_CTR_ED25519_EXAMPLE: &str = include_str!("examples/id_ed25519.aes-ctr.enc"); + +/// AES-GCM encrypted Ed25519 OpenSSH-formatted private key. +/// +/// Plaintext is `OPENSSH_ED25519_EXAMPLE`. +const OPENSSH_AES_GCM_ED25519_EXAMPLE: &str = include_str!("examples/id_ed25519.aes-gcm.enc"); /// Bad password; don't actually use outside tests! #[cfg(all(feature = "encryption"))] const PASSWORD: &[u8] = b"hunter42"; #[test] -fn decode_ed25519_enc_openssh() { - let key = PrivateKey::from_openssh(OPENSSH_ED25519_ENC_EXAMPLE).unwrap(); +fn decode_openssh_aes_ctr() { + let key = PrivateKey::from_openssh(OPENSSH_AES_CTR_ED25519_EXAMPLE).unwrap(); assert_eq!(Algorithm::Ed25519, key.algorithm()); assert_eq!(Cipher::Aes256Ctr, key.cipher()); assert_eq!(KdfAlg::Bcrypt, key.kdf().algorithm()); @@ -39,10 +44,42 @@ fn decode_ed25519_enc_openssh() { ); } +#[test] +fn decode_openssh_aes_gcm() { + let key = PrivateKey::from_openssh(OPENSSH_AES_GCM_ED25519_EXAMPLE).unwrap(); + assert_eq!(Algorithm::Ed25519, key.algorithm()); + assert_eq!(Cipher::Aes256Gcm, key.cipher()); + assert_eq!(KdfAlg::Bcrypt, key.kdf().algorithm()); + + match key.kdf() { + Kdf::Bcrypt { salt, rounds } => { + assert_eq!(salt, &hex!("11bdc133ef64644115b176917e47cbaf")); + assert_eq!(*rounds, 16); + } + other => panic!("unexpected KDF algorithm: {:?}", other), + } + + assert_eq!( + &hex!("b33eaef37ea2df7caa010defdea34e241f65f1b529a4f43ed14327f5c54aab62"), + key.public_key().key_data().ed25519().unwrap().as_ref(), + ); +} + #[cfg(all(feature = "encryption"))] #[test] -fn decrypt_ed25519_enc_openssh() { - let key_enc = PrivateKey::from_openssh(OPENSSH_ED25519_ENC_EXAMPLE).unwrap(); +fn decrypt_openssh_aes_ctr() { + let key_enc = PrivateKey::from_openssh(OPENSSH_AES_CTR_ED25519_EXAMPLE).unwrap(); + let key_dec = key_enc.decrypt(PASSWORD).unwrap(); + assert_eq!( + PrivateKey::from_openssh(OPENSSH_ED25519_EXAMPLE).unwrap(), + key_dec + ); +} + +#[cfg(all(feature = "aes-gcm"))] +#[test] +fn decrypt_openssh_aes_gcm() { + let key_enc = PrivateKey::from_openssh(OPENSSH_AES_GCM_ED25519_EXAMPLE).unwrap(); let key_dec = key_enc.decrypt(PASSWORD).unwrap(); assert_eq!( PrivateKey::from_openssh(OPENSSH_ED25519_EXAMPLE).unwrap(), @@ -51,17 +88,26 @@ fn decrypt_ed25519_enc_openssh() { } #[test] -fn encode_ed25519_enc_openssh() { - let key = PrivateKey::from_openssh(OPENSSH_ED25519_ENC_EXAMPLE).unwrap(); +fn encode_openssh_aes_ctr() { + let key = PrivateKey::from_openssh(OPENSSH_AES_CTR_ED25519_EXAMPLE).unwrap(); + assert_eq!( + OPENSSH_AES_CTR_ED25519_EXAMPLE.trim_end(), + key.to_openssh(Default::default()).unwrap().trim_end() + ); +} + +#[test] +fn encode_openssh_aes_gcm() { + let key = PrivateKey::from_openssh(OPENSSH_AES_GCM_ED25519_EXAMPLE).unwrap(); assert_eq!( - OPENSSH_ED25519_ENC_EXAMPLE.trim_end(), + OPENSSH_AES_GCM_ED25519_EXAMPLE.trim_end(), key.to_openssh(Default::default()).unwrap().trim_end() ); } #[cfg(all(feature = "encryption", feature = "getrandom"))] #[test] -fn encrypt_ed25519_openssh() { +fn encrypt_openssh_aes_ctr() { use rand_core::OsRng; let key_dec = PrivateKey::from_openssh(OPENSSH_ED25519_EXAMPLE).unwrap(); @@ -76,3 +122,30 @@ fn encrypt_ed25519_openssh() { let key_dec2 = key_enc.decrypt(PASSWORD).unwrap(); assert_eq!(key_dec, key_dec2); } + +#[cfg(all(feature = "aes-gcm", feature = "getrandom"))] +#[test] +fn encrypt_openssh_aes_gcm() { + use rand_core::{OsRng, RngCore}; + + let key_dec = PrivateKey::from_openssh(OPENSSH_ED25519_EXAMPLE).unwrap(); + + let checkint = RngCore::next_u32(&mut OsRng); + let key_enc = key_dec + .encrypt_with( + Cipher::Aes256Gcm, + Kdf::new(Default::default(), &mut OsRng).unwrap(), + checkint, + PASSWORD, + ) + .unwrap(); + + // Ensure encrypted key round trips through encoder/decoder + let key_enc_str = key_enc.to_openssh(Default::default()).unwrap(); + let key_enc2 = PrivateKey::from_openssh(&*key_enc_str).unwrap(); + assert_eq!(key_enc, key_enc2); + + // Ensure decrypted key matches the original + let key_dec2 = key_enc.decrypt(PASSWORD).unwrap(); + assert_eq!(key_dec, key_dec2); +} diff --git a/ssh-key/tests/examples/id_ed25519.enc b/ssh-key/tests/examples/id_ed25519.aes-ctr.enc similarity index 100% rename from ssh-key/tests/examples/id_ed25519.enc rename to ssh-key/tests/examples/id_ed25519.aes-ctr.enc diff --git a/ssh-key/tests/examples/id_ed25519.aes-gcm.enc b/ssh-key/tests/examples/id_ed25519.aes-gcm.enc new file mode 100644 index 00000000..4e0299fc --- /dev/null +++ b/ssh-key/tests/examples/id_ed25519.aes-gcm.enc @@ -0,0 +1,9 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAAFmFlczI1Ni1nY21Ab3BlbnNzaC5jb20AAAAGYmNyeXB0AA +AAGAAAABARvcEz72RkQRWxdpF+R8uvAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAA +ILM+rvN+ot98qgEN796jTiQfZfG1KaT0PtFDJ/XFSqtiAAAAoIJQm81qpEdHOG7cGK5d27 +FAelmbS6xxp7YaqYnD+9agVk6KsbAM8SMDF6AEiVaxoVPX/+HRV1HwA5BRpWijXmC6meyV +604UAY1ubJKemubnSrNSa4slV/r6wLut1vqFD8ro6nobT+wCgUrwDsL7ZI/9i6nQYXFdDS +vKbSu+2Nwh3B78JQoZXyetXQy3fOZKqrvy/6BFRDsOTKckfRCiAaTcNzfq+DH3OG5x+brH +Yl4J +-----END OPENSSH PRIVATE KEY-----