diff --git a/Cargo.lock b/Cargo.lock index 491b02f..ccc5e72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,41 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -153,9 +188,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -325,6 +370,16 @@ dependencies = [ "wasi", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "gimli" version = "0.29.0" @@ -565,6 +620,18 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -859,6 +926,7 @@ dependencies = [ name = "ssh-transport" version = "0.1.0" dependencies = [ + "aes-gcm", "chacha20", "ed25519-dalek", "eyre", diff --git a/ssh-transport/Cargo.toml b/ssh-transport/Cargo.toml index 26bbbd7..3d8de75 100644 --- a/ssh-transport/Cargo.toml +++ b/ssh-transport/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +aes-gcm = "0.10.3" chacha20 = "0.9.1" ed25519-dalek = { version = "2.1.1" } eyre = "0.6.12" diff --git a/ssh-transport/src/keys.rs b/ssh-transport/src/crypto.rs similarity index 67% rename from ssh-transport/src/keys.rs rename to ssh-transport/src/crypto.rs index 2227bad..3d6d791 100644 --- a/ssh-transport/src/keys.rs +++ b/ssh-transport/src/crypto.rs @@ -1,3 +1,4 @@ +use aes_gcm::aead::{Aead, AeadMutInPlace}; use chacha20::cipher::{KeyInit, StreamCipher, StreamCipherSeek}; use sha2::Digest; use subtle::ConstantTimeEq; @@ -81,9 +82,11 @@ pub const KEX_ECDH_SHA2_NISTP256: KexAlgorithm = KexAlgorithm { #[derive(Clone, Copy)] pub struct EncryptionAlgorithm { name: &'static str, - decrypt_len: fn(keys: &[u8], bytes: &mut [u8], packet_number: u64), - decrypt_packet: fn(keys: &[u8], bytes: RawPacket, packet_number: u64) -> Result, - encrypt_packet: fn(keys: &[u8], packet: Packet, packet_number: u64) -> EncryptedPacket, + iv_size: usize, + key_size: usize, + decrypt_len: fn(state: &mut [u8], bytes: &mut [u8], packet_number: u64), + decrypt_packet: fn(state: &mut [u8], bytes: RawPacket, packet_number: u64) -> Result, + encrypt_packet: fn(state: &mut [u8], packet: Packet, packet_number: u64) -> EncryptedPacket, } impl AlgorithmName for EncryptionAlgorithm { fn name(&self) -> &'static str { @@ -92,16 +95,35 @@ impl AlgorithmName for EncryptionAlgorithm { } pub const ENC_CHACHA20POLY1305: EncryptionAlgorithm = EncryptionAlgorithm { name: "chacha20-poly1305@openssh.com", - decrypt_len: |keys, bytes, packet_number| { - let alg = SshChaCha20Poly1305::from_keys(keys); + iv_size: 0, + key_size: 64, // 32 for header, 32 for main + decrypt_len: |state, bytes, packet_number| { + let alg = ChaCha20Poly1305OpenSsh::from_state(state); alg.decrypt_len(bytes, packet_number) }, - decrypt_packet: |keys, bytes, packet_number| { - let alg = SshChaCha20Poly1305::from_keys(keys); + decrypt_packet: |state, bytes, packet_number| { + let alg = ChaCha20Poly1305OpenSsh::from_state(state); alg.decrypt_packet(bytes, packet_number) }, - encrypt_packet: |keys, packet, packet_number| { - let alg = SshChaCha20Poly1305::from_keys(keys); + encrypt_packet: |state, packet, packet_number| { + let alg = ChaCha20Poly1305OpenSsh::from_state(state); + alg.encrypt_packet(packet, packet_number) + }, +}; +pub const ENC_AES256_GCM: EncryptionAlgorithm = EncryptionAlgorithm { + name: "aes256-gcm@openssh.com", + iv_size: 12, + key_size: 32, + decrypt_len: |state, bytes, packet_number| { + let mut alg = Aes256GcmOpenSsh::from_state(state); + alg.decrypt_len(bytes, packet_number) + }, + decrypt_packet: |state, bytes, packet_number| { + let mut alg = Aes256GcmOpenSsh::from_state(state); + alg.decrypt_packet(bytes, packet_number) + }, + encrypt_packet: |state, packet, packet_number| { + let mut alg = Aes256GcmOpenSsh::from_state(state); alg.encrypt_packet(packet, packet_number) }, }; @@ -130,10 +152,14 @@ impl AlgorithmNegotiation { pub(crate) struct Session { session_id: [u8; 32], - encryption_key_client_to_server: [u8; 64], - encryption_client_to_server: EncryptionAlgorithm, - encryption_key_server_to_client: [u8; 64], - encryption_server_to_client: EncryptionAlgorithm, + client_to_server: Tunnel, + server_to_client: Tunnel, +} + +struct Tunnel { + /// `key || IV` + state: Vec, + algorithm: EncryptionAlgorithm, } pub(crate) trait Keys: Send + Sync + 'static { @@ -196,20 +222,27 @@ impl Session { session_id: [u8; 32], h: [u8; 32], k: &[u8], - encryption_client_to_server: EncryptionAlgorithm, - encryption_server_to_client: EncryptionAlgorithm, + alg_c2s: EncryptionAlgorithm, + alg_s2c: EncryptionAlgorithm, ) -> Self { - let encryption_key_client_to_server = derive_key(k, h, "C", session_id); - let encryption_key_server_to_client = derive_key(k, h, "D", session_id); - Self { session_id, - // client_to_server_iv: derive("A").into(), - // server_to_client_iv: derive("B").into(), - encryption_key_client_to_server, - encryption_client_to_server, - encryption_key_server_to_client, - encryption_server_to_client, + client_to_server: Tunnel { + algorithm: alg_c2s, + state: { + let mut state = derive_key(k, h, "C", session_id, alg_c2s.key_size); + state.extend_from_slice(&derive_key(k, h, "A", session_id, alg_c2s.iv_size)); + state + }, + }, + server_to_client: Tunnel { + algorithm: alg_s2c, + state: { + let mut state = derive_key(k, h, "D", session_id, alg_s2c.key_size); + state.extend_from_slice(&derive_key(k, h, "B", session_id, alg_s2c.iv_size)); + state + }, + }, // integrity_key_client_to_server: derive("E").into(), // integrity_key_server_to_client: derive("F").into(), } @@ -218,24 +251,24 @@ impl Session { impl Keys for Session { fn decrypt_len(&mut self, bytes: &mut [u8; 4], packet_number: u64) { - (self.encryption_client_to_server.decrypt_len)( - &self.encryption_key_client_to_server, + (self.client_to_server.algorithm.decrypt_len)( + &mut self.client_to_server.state, bytes, packet_number, ); } fn decrypt_packet(&mut self, bytes: RawPacket, packet_number: u64) -> Result { - (self.encryption_client_to_server.decrypt_packet)( - &self.encryption_key_client_to_server, + (self.client_to_server.algorithm.decrypt_packet)( + &mut self.client_to_server.state, bytes, packet_number, ) } fn encrypt_packet_to_msg(&mut self, packet: Packet, packet_number: u64) -> Msg { - let packet = (self.encryption_server_to_client.encrypt_packet)( - &self.encryption_key_server_to_client, + let packet = (self.server_to_client.algorithm.encrypt_packet)( + &mut self.server_to_client.state, packet, packet_number, ); @@ -266,9 +299,15 @@ impl Keys for Session { /// Derive a key from the shared secret K and exchange hash H. /// -fn derive_key(k: &[u8], h: [u8; 32], letter: &str, session_id: [u8; 32]) -> [u8; 64] { +fn derive_key( + k: &[u8], + h: [u8; 32], + letter: &str, + session_id: [u8; 32], + key_size: usize, +) -> Vec { let sha2len = sha2::Sha256::output_size(); - let mut output = [0; 64]; + let mut output = vec![0; key_size]; //let mut hash = sha2::Sha256::new(); //encode_mpint_for_hash(&k, |data| hash.update(data)); @@ -277,7 +316,7 @@ fn derive_key(k: &[u8], h: [u8; 32], letter: &str, session_id: [u8; 32]) -> [u8; //hash.update(session_id); //output[..sha2len].copy_from_slice(&hash.finalize()); - for i in 0..(64 / sha2len) { + for i in 0..(key_size / sha2len) { let mut hash = ::new(); encode_mpint_for_hash(&k, |data| hash.update(data)); hash.update(h); @@ -311,13 +350,14 @@ pub(crate) fn encode_mpint_for_hash(mut key: &[u8], mut add_to_hash: impl FnMut( /// `chacha20-poly1305@openssh.com` uses a 64-bit nonce, not the 96-bit one in the IETF version. type SshChaCha20 = chacha20::ChaCha20Legacy; -struct SshChaCha20Poly1305 { +/// +struct ChaCha20Poly1305OpenSsh { header_key: chacha20::Key, main_key: chacha20::Key, } -impl SshChaCha20Poly1305 { - fn from_keys(keys: &[u8]) -> Self { +impl ChaCha20Poly1305OpenSsh { + fn from_state(keys: &[u8]) -> Self { assert_eq!(keys.len(), 64); Self { main_key: <[u8; 32]>::try_from(&keys[..32]).unwrap().into(), @@ -370,7 +410,7 @@ impl SshChaCha20Poly1305 { } fn encrypt_packet(&self, packet: Packet, packet_number: u64) -> EncryptedPacket { - let mut bytes = packet.to_bytes(false); + let mut bytes = packet.to_bytes(false, Packet::DEFAULT_BLOCK_SIZE); // Prepare the main cipher. let mut main_cipher = ::new( @@ -404,3 +444,81 @@ impl SshChaCha20Poly1305 { EncryptedPacket::from_encrypted_full_bytes(bytes) } } + +/// +/// +struct Aes256GcmOpenSsh<'a> { + key: aes_gcm::Key, + nonce: &'a mut [u8; 12], +} + +impl<'a> Aes256GcmOpenSsh<'a> { + fn from_state(keys: &'a mut [u8]) -> Self { + assert_eq!(keys.len(), 44); + Self { + key: <[u8; 32]>::try_from(&keys[..32]).unwrap().into(), + nonce: <&mut [u8; 12]>::try_from(&mut keys[32..]).unwrap(), + } + } + + fn decrypt_len(&mut self, _: &mut [u8], _: u64) { + // AES-GCM does not encrypt the length. + // + } + + fn decrypt_packet(&mut self, mut bytes: RawPacket, _packet_number: u64) -> Result { + let mut cipher = aes_gcm::Aes256Gcm::new(&self.key); + + let mut len = [0; 4]; + len.copy_from_slice(&bytes.full_packet()[..4]); + + let tag_offset = bytes.full_packet().len() - 16; + let mut tag = [0; 16]; + tag.copy_from_slice(&bytes.full_packet()[tag_offset..]); + + let encrypted_packet_content = bytes.content_mut(); + + cipher + .decrypt_in_place_detached( + (&*self.nonce).into(), + &len, + encrypted_packet_content, + (&tag).into(), + ) + .map_err(|_| crate::client_error!("failed to decrypt: invalid GCM MAC"))?; + self.inc_nonce(); + + Packet::from_full(encrypted_packet_content) + } + + fn encrypt_packet(&mut self, packet: Packet, _packet_number: u64) -> EncryptedPacket { + let bytes = packet.to_bytes( + false, + ::block_size() as u8, + ); + + let cipher = aes_gcm::Aes256Gcm::new(&self.key); + + let bytes = cipher + .encrypt( + (&*self.nonce).into(), + aes_gcm::aead::Payload { + aad: &bytes[..4], + msg: &bytes[4..], + }, + ) + .unwrap(); + self.inc_nonce(); + + EncryptedPacket::from_encrypted_full_bytes(bytes) + } + + fn inc_nonce(&mut self) { + let mut carry = 1; + for i in (0..self.nonce.len()).rev() { + let n = self.nonce[i] as u16 + carry; + self.nonce[i] = n as u8; + carry = n >> 8; + } + } +} diff --git a/ssh-transport/src/lib.rs b/ssh-transport/src/lib.rs index a1b7ab1..2b3d73c 100644 --- a/ssh-transport/src/lib.rs +++ b/ssh-transport/src/lib.rs @@ -1,4 +1,4 @@ -mod keys; +mod crypto; pub mod packet; pub mod parse; @@ -6,7 +6,7 @@ use core::str; use std::{collections::VecDeque, mem::take}; use ed25519_dalek::ed25519::signature::Signer; -use keys::{AlgorithmName, AlgorithmNegotiation, EncryptionAlgorithm}; +use crypto::{AlgorithmName, AlgorithmNegotiation, EncryptionAlgorithm}; use packet::{ DhKeyExchangeInitReplyPacket, KeyExchangeEcDhInitPacket, KeyExchangeInitPacket, Packet, PacketTransport, SshPublicKey, SshSignature, @@ -63,7 +63,7 @@ enum ServerState { client_identification: Vec, client_kexinit: Vec, server_kexinit: Vec, - kex_algorithm: keys::KexAlgorithm, + kex_algorithm: crypto::KexAlgorithm, encryption_client_to_server: EncryptionAlgorithm, encryption_server_to_client: EncryptionAlgorithm, }, @@ -179,7 +179,7 @@ impl ServerConnection { }; let kex_algorithms = AlgorithmNegotiation { - supported: vec![keys::KEX_CURVE_25519_SHA256, keys::KEX_ECDH_SHA2_NISTP256], + supported: vec![crypto::KEX_CURVE_25519_SHA256, crypto::KEX_ECDH_SHA2_NISTP256], }; let kex_algorithm = kex_algorithms.find(kex.kex_algorithms.0)?; @@ -187,13 +187,12 @@ impl ServerConnection { require_algorithm("ssh-ed25519", kex.server_host_key_algorithms)?; let encryption_algorithms_client_to_server = AlgorithmNegotiation { - supported: vec![keys::ENC_CHACHA20POLY1305], + supported: vec![crypto::ENC_CHACHA20POLY1305, crypto::ENC_AES256_GCM], }; let encryption_algorithms_server_to_client = AlgorithmNegotiation { - supported: vec![keys::ENC_CHACHA20POLY1305], + supported: vec![crypto::ENC_CHACHA20POLY1305, crypto::ENC_AES256_GCM], }; - // TODO: support aes256-gcm@openssh.com let encryption_client_to_server = encryption_algorithms_client_to_server .find(kex.encryption_algorithms_client_to_server.0)?; let encryption_server_to_client = encryption_algorithms_server_to_client @@ -270,7 +269,7 @@ impl ServerConnection { let client_public_key = dh.qc; - let keys::KexAlgorithmOutput { + let crypto::KexAlgorithmOutput { server_public_key, shared_secret, } = (kex_algorithm.exchange)(client_public_key, &mut *self.rng)?; @@ -289,7 +288,7 @@ impl ServerConnection { add_hash(hash, bytes); }; let hash_mpint = |hash: &mut sha2::Sha256, bytes: &[u8]| { - keys::encode_mpint_for_hash(bytes, |data| add_hash(hash, data)); + crypto::encode_mpint_for_hash(bytes, |data| add_hash(hash, data)); }; hash_string( diff --git a/ssh-transport/src/packet.rs b/ssh-transport/src/packet.rs index 47ad7b5..f1304fb 100644 --- a/ssh-transport/src/packet.rs +++ b/ssh-transport/src/packet.rs @@ -3,7 +3,7 @@ mod ctors; use std::collections::VecDeque; use crate::client_error; -use crate::keys::{EncryptionAlgorithm, Keys, Plaintext, Session}; +use crate::crypto::{EncryptionAlgorithm, Keys, Plaintext, Session}; use crate::parse::{NameList, Parser, Writer}; use crate::Result; @@ -33,7 +33,7 @@ impl Msg { pub fn to_bytes(self) -> Vec { match self.0 { MsgKind::ServerProtocolInfo => crate::SERVER_IDENTIFICATION.to_vec(), - MsgKind::PlaintextPacket(v) => v.to_bytes(true), + MsgKind::PlaintextPacket(v) => v.to_bytes(true, Packet::DEFAULT_BLOCK_SIZE), MsgKind::EncryptedPacket(v) => v.into_bytes(), } } @@ -199,6 +199,8 @@ impl Packet { pub const SSH_MSG_CHANNEL_SUCCESS: u8 = 99; pub const SSH_MSG_CHANNEL_FAILURE: u8 = 100; + pub const DEFAULT_BLOCK_SIZE: u8 = 8; + pub(crate) fn from_full(bytes: &[u8]) -> Result { let Some(padding_length) = bytes.first() else { return Err(client_error!("empty packet")); @@ -219,17 +221,20 @@ impl Packet { }) } - pub(crate) fn to_bytes(&self, respect_len_for_padding: bool) -> Vec { + pub(crate) fn to_bytes(&self, respect_len_for_padding: bool, block_size: u8) -> Vec { + assert!(block_size.is_power_of_two()); + let let_bytes = if respect_len_for_padding { 4 } else { 0 }; // let min_full_length = self.payload.len() + let_bytes + 1; - // The padding must give a factor of 8. - let min_padding_len = (min_full_length.next_multiple_of(8) - min_full_length) as u8; + // The padding must give a factor of block_size. + let min_padding_len = + (min_full_length.next_multiple_of(block_size as usize) - min_full_length) as u8; // > There MUST be at least four bytes of padding. let padding_len = if min_padding_len < 4 { - min_padding_len + 8 + min_padding_len + block_size } else { min_padding_len };