From e66477c168785e9df7d073a760765251a9f63e76 Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Mon, 6 Mar 2023 10:53:41 -0500 Subject: [PATCH 1/5] docs --- zssp/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/zssp/README.md b/zssp/README.md index a533cd104..e76c92b76 100644 --- a/zssp/README.md +++ b/zssp/README.md @@ -1,2 +1,22 @@ ZeroTier Secure Socket Protocol ====== + +# Introduction + +ZeroTier Secure Socket Protocol (ZSSP) is a [Noise](http://noiseprotocol.org) protocol implementation using NIST/FIPS/CfSC compliant cryptographic primitives plus post-quantum forward secrecy via [Kyber1024](https://pq-crystals.org/kyber/). It also includes built-in support for fragmentation and defragmentation of large messages with strong resistance against denial of service attacks targeted against the fragmentation protocol. + +Specifically ZSSP implements the [Noise XK](http://noiseprotocol.org/noise.html#interactive-handshake-patterns-fundamental) interactive handshake pattern which provides strong forward secrecy not only for data but for the identities of the two participants in the sesssion. The XK pattern was chosen instead of the more popular IK pattern used in popular Noise implementations like Wireguard due to ZeroTier identities being long lived and potentially tied to the real world identity of the user. As a result a Noise pattern providing identity forward secrecy was considered preferable as it offers some level of deniability for recorded traffic even after secrec key compromise. + +Hybrid post-quantum forward secrecy using Kyber1024 is performed alongside Noise with the result being mixed in alongside an optional pre-shared key at the end of session negotiation. + +ZSSP is designed for use in ZeroTier 2 but is payload-agnostic and could easily be adapted for use in other projects. + +## Cryptographic Primitives Used + + - AES-256-GCM: Authenticated encryption of data in transit. + - AES-256-CTR: Encryption of initial handshake data to be protected such as identities. + - HMAC-SHA384: Key mixing, key derivation, message authentication during initial session handshake. + - NIST P-384 ECDH: Elliptic curve key exchange during initial handshake and for periodic re-keying during the session. + - Kyber1024: Quantum attack resistant lattice-based key exchange during initial handshake. + - AES-256-ECB: Single 128-bit block encryption of header information to harden the fragmentation protocol against denial of service attack (see section on header protection). + From 757cc88abc7436da050c782c6ac8c38f7f0ca3ff Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Tue, 7 Mar 2023 13:03:48 -0500 Subject: [PATCH 2/5] Make ZSSP use just GCM to simplify, and change final ACK to auth and encrypt public blob separately from meta-data. --- zssp/src/main.rs | 8 +- zssp/src/proto.rs | 35 +++-- zssp/src/zssp.rs | 329 ++++++++++++++++++++-------------------------- 3 files changed, 165 insertions(+), 207 deletions(-) diff --git a/zssp/src/main.rs b/zssp/src/main.rs index f9db5a977..e6d11a2fc 100644 --- a/zssp/src/main.rs +++ b/zssp/src/main.rs @@ -80,7 +80,7 @@ fn alice_main( match context.receive( alice_app, || true, - |s_public, _| Some((P384PublicKey::from_bytes(s_public).unwrap(), Secret::default(), ())), + |s_public| Some((P384PublicKey::from_bytes(s_public).unwrap(), Secret::default(), ())), |_, b| { let _ = alice_out.send(b.to_vec()); }, @@ -96,7 +96,7 @@ fn alice_main( Ok(zssp::ReceiveResult::OkData(_, _)) => { //println!("[alice] received {}", data.len()); } - Ok(zssp::ReceiveResult::OkNewSession(s)) => { + Ok(zssp::ReceiveResult::OkNewSession(s, _)) => { println!("[alice] new session {}", s.id.to_string()); } Ok(zssp::ReceiveResult::Rejected) => {} @@ -178,7 +178,7 @@ fn bob_main( match context.receive( bob_app, || true, - |s_public, _| Some((P384PublicKey::from_bytes(s_public).unwrap(), Secret::default(), ())), + |s_public| Some((P384PublicKey::from_bytes(s_public).unwrap(), Secret::default(), ())), |_, b| { let _ = bob_out.send(b.to_vec()); }, @@ -204,7 +204,7 @@ fn bob_main( .is_ok()); transferred += data.len() as u64 * 2; // *2 because we are also sending this many bytes back } - Ok(zssp::ReceiveResult::OkNewSession(s)) => { + Ok(zssp::ReceiveResult::OkNewSession(s, _)) => { println!("[bob] new session {}", s.id.to_string()); let _ = bob_session.replace(s); } diff --git a/zssp/src/proto.rs b/zssp/src/proto.rs index ec8ce9991..3aed80907 100644 --- a/zssp/src/proto.rs +++ b/zssp/src/proto.rs @@ -9,7 +9,7 @@ use std::mem::size_of; use pqc_kyber::{KYBER_CIPHERTEXTBYTES, KYBER_PUBLICKEYBYTES}; -use zerotier_crypto::hash::{HMAC_SHA384_SIZE, SHA384_HASH_SIZE}; +use zerotier_crypto::hash::SHA384_HASH_SIZE; use zerotier_crypto::p384::P384_PUBLIC_KEY_SIZE; use crate::error::Error; @@ -45,8 +45,7 @@ pub(crate) const HEADER_SIZE: usize = 16; pub(crate) const HEADER_PROTECT_ENCRYPT_START: usize = 6; pub(crate) const HEADER_PROTECT_ENCRYPT_END: usize = 22; -pub(crate) const KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION: u8 = b'x'; // AES-CTR encryption during initial setup -pub(crate) const KBKDF_KEY_USAGE_LABEL_INIT_AUTHENTICATION: u8 = b'X'; // HMAC-SHA384 during initial setup +pub(crate) const KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION: u8 = b'x'; // AES-GCM encryption during initial setup pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_ALICE_TO_BOB: u8 = b'A'; // AES-GCM in A->B direction pub(crate) const KBKDF_KEY_USAGE_LABEL_AES_GCM_BOB_TO_ALICE: u8 = b'B'; // AES-GCM in B->A direction pub(crate) const KBKDF_KEY_USAGE_LABEL_RATCHET: u8 = b'R'; // Key used in derivatin of next session key @@ -71,18 +70,18 @@ pub(crate) struct AliceNoiseXKInit { pub header: [u8; HEADER_SIZE], pub session_protocol_version: u8, pub alice_noise_e: [u8; P384_PUBLIC_KEY_SIZE], - // -- start AES-CTR(es) encrypted section + // -- start AES-GCM(es) encrypted section pub alice_session_id: [u8; SessionId::SIZE], pub alice_hk_public: [u8; KYBER_PUBLICKEYBYTES], pub header_protection_key: [u8; AES_HEADER_PROTECTION_KEY_SIZE], // -- end encrypted section - pub hmac_es: [u8; HMAC_SHA384_SIZE], + pub gcm_tag: [u8; AES_GCM_TAG_SIZE], } impl AliceNoiseXKInit { pub const ENC_START: usize = HEADER_SIZE + 1 + P384_PUBLIC_KEY_SIZE; pub const AUTH_START: usize = Self::ENC_START + SessionId::SIZE + KYBER_PUBLICKEYBYTES + AES_HEADER_PROTECTION_KEY_SIZE; - pub const SIZE: usize = Self::AUTH_START + HMAC_SHA384_SIZE; + pub const SIZE: usize = Self::AUTH_START + AES_GCM_TAG_SIZE; } /// The response to AliceNoiceXKInit containing Bob's ephemeral keys. @@ -92,17 +91,17 @@ pub(crate) struct BobNoiseXKAck { pub header: [u8; HEADER_SIZE], pub session_protocol_version: u8, pub bob_noise_e: [u8; P384_PUBLIC_KEY_SIZE], - // -- start AES-CTR(es_ee) encrypted section + // -- start AES-GCM(es_ee) encrypted section pub bob_session_id: [u8; SessionId::SIZE], pub bob_hk_ciphertext: [u8; KYBER_CIPHERTEXTBYTES], // -- end encrypted sectiion - pub hmac_es_ee: [u8; HMAC_SHA384_SIZE], + pub gcm_tag: [u8; AES_GCM_TAG_SIZE], } impl BobNoiseXKAck { pub const ENC_START: usize = HEADER_SIZE + 1 + P384_PUBLIC_KEY_SIZE; pub const AUTH_START: usize = Self::ENC_START + SessionId::SIZE + KYBER_CIPHERTEXTBYTES; - pub const SIZE: usize = Self::AUTH_START + HMAC_SHA384_SIZE; + pub const SIZE: usize = Self::AUTH_START + AES_GCM_TAG_SIZE; } /// Alice's final response containing her identity (she already knows Bob's) and meta-data. @@ -112,20 +111,20 @@ impl BobNoiseXKAck { pub(crate) struct AliceNoiseXKAck { pub header: [u8; HEADER_SIZE], pub session_protocol_version: u8, - // -- start AES-CTR(es_ee_hk) encrypted section pub alice_static_blob_length: [u8; 2], + // -- start AES-GCM(es_ee_hk) encrypted section pub alice_static_blob: [u8; ???], + // -- end encrypted section + pub gcm_tag_0: [u8; AES_GCM_TAG_SIZE], pub alice_metadata_length: [u8; 2], + // -- start AES-GCM(es_ee_se_hk_psk) encrypted section pub alice_metadata: [u8; ???], // -- end encrypted section - pub hmac_es_ee: [u8; HMAC_SHA384_SIZE], - pub hmac_es_ee_se_hk_psk: [u8; HMAC_SHA384_SIZE], + pub gcm_tag_1: [u8; AES_GCM_TAG_SIZE], } */ -pub(crate) const ALICE_NOISE_XK_ACK_ENC_START: usize = HEADER_SIZE + 1; -pub(crate) const ALICE_NOISE_XK_ACK_AUTH_SIZE: usize = HMAC_SHA384_SIZE + HMAC_SHA384_SIZE; -pub(crate) const ALICE_NOISE_XK_ACK_MIN_SIZE: usize = ALICE_NOISE_XK_ACK_ENC_START + 2 + 2 + ALICE_NOISE_XK_ACK_AUTH_SIZE; +pub(crate) const ALICE_NOISE_XK_ACK_MIN_SIZE: usize = HEADER_SIZE + 1 + 2 + AES_GCM_TAG_SIZE + 2 + AES_GCM_TAG_SIZE; #[allow(unused)] #[repr(C, packed)] @@ -135,7 +134,7 @@ pub(crate) struct RekeyInit { // -- start AES-GCM encrypted portion (using current key) pub alice_e: [u8; P384_PUBLIC_KEY_SIZE], // -- end AES-GCM encrypted portion - pub gcm_mac: [u8; AES_GCM_TAG_SIZE], + pub gcm_tag: [u8; AES_GCM_TAG_SIZE], } impl RekeyInit { @@ -151,9 +150,9 @@ pub(crate) struct RekeyAck { pub session_protocol_version: u8, // -- start AES-GCM encrypted portion (using current key) pub bob_e: [u8; P384_PUBLIC_KEY_SIZE], - pub next_key_fingerprint: [u8; SHA384_HASH_SIZE], + pub next_key_fingerprint: [u8; SHA384_HASH_SIZE], // SHA384(next secret) // -- end AES-GCM encrypted portion - pub gcm_mac: [u8; AES_GCM_TAG_SIZE], + pub gcm_tag: [u8; AES_GCM_TAG_SIZE], } impl RekeyAck { diff --git a/zssp/src/zssp.rs b/zssp/src/zssp.rs index a8a8f76b0..b2d73a7f1 100644 --- a/zssp/src/zssp.rs +++ b/zssp/src/zssp.rs @@ -10,12 +10,13 @@ // FIPS compliant Noise_XK with Jedi powers (Kyber1024) and built-in attack-resistant large payload (fragmentation) support. use std::collections::{HashMap, HashSet}; +use std::io::Write; use std::num::NonZeroU64; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; -use zerotier_crypto::aes::{Aes, AesCtr, AesGcm}; -use zerotier_crypto::hash::{hmac_sha512, HMACSHA384, HMAC_SHA384_SIZE, SHA384}; +use zerotier_crypto::aes::{Aes, AesGcm}; +use zerotier_crypto::hash::{hmac_sha512, SHA384}; use zerotier_crypto::p384::{P384KeyPair, P384PublicKey, P384_ECDH_SHARED_SECRET_SIZE}; use zerotier_crypto::secret::Secret; use zerotier_crypto::{random, secure_eq}; @@ -63,8 +64,8 @@ pub enum ReceiveResult<'b, Application: ApplicationLayer> { /// Packet was valid and a data payload was decoded and authenticated. OkData(Arc>, &'b mut [u8]), - /// Packet was valid and a new session was created. - OkNewSession(Arc>), + /// Packet was valid and a new session was created, with optional attached meta-data. + OkNewSession(Arc>, Option<&'b mut [u8]>), /// Packet appears valid but was rejected by the application layer, e.g. a rejected new session attempt. Rejected, @@ -339,24 +340,22 @@ impl Context { panic!(); // should be impossible }; - let init: &mut AliceNoiseXKInit = byte_array_as_proto_buffer_mut(init_packet).unwrap(); - init.session_protocol_version = SESSION_PROTOCOL_VERSION; - init.alice_noise_e = alice_noise_e; - init.alice_session_id = *local_session_id.as_bytes(); - init.alice_hk_public = alice_hk_secret.public; - init.header_protection_key = header_protection_key.0; + { + let init: &mut AliceNoiseXKInit = byte_array_as_proto_buffer_mut(init_packet).unwrap(); + init.session_protocol_version = SESSION_PROTOCOL_VERSION; + init.alice_noise_e = alice_noise_e; + init.alice_session_id = *local_session_id.as_bytes(); + init.alice_hk_public = alice_hk_secret.public; + init.header_protection_key = header_protection_key.0; + } - aes_ctr_crypt_one_time_use_key( + let mut gcm = AesGcm::new( kbkdf::(noise_es.as_bytes()).as_bytes(), - &mut init_packet[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START], + true, ); - - let hmac = hmac_sha384_2( - kbkdf::(noise_es.as_bytes()).as_bytes(), - &create_message_nonce(PACKET_TYPE_ALICE_NOISE_XK_INIT, 1), - &init_packet[HEADER_SIZE..AliceNoiseXKInit::AUTH_START], - ); - init_packet[AliceNoiseXKInit::AUTH_START..AliceNoiseXKInit::AUTH_START + HMAC_SHA384_SIZE].copy_from_slice(&hmac); + gcm.reset_init_gcm(&create_message_nonce(PACKET_TYPE_ALICE_NOISE_XK_INIT, 1)); + gcm.crypt_in_place(&mut init_packet[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START]); + init_packet[AliceNoiseXKInit::AUTH_START..AliceNoiseXKInit::AUTH_START + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); send_with_fragmentation( &mut send, @@ -385,9 +384,9 @@ impl Context { /// with negotiation. False drops the packet. /// /// The check_accept_session function is called at the end of negotiation for an incoming session - /// with the caller's static public blob and meta-data if any. It must return the P-384 static public - /// key extracted from the supplied blob, a PSK (or all zeroes if none), and application data to - /// associate with the new session. A return of None abandons the session. + /// with the caller's static public blob. It must return the P-384 static public key extracted from + /// the supplied blob, a PSK (or all zeroes if none), and application data to associate with the new + /// session. A return of None rejects and abandons the session. /// /// Note that if check_accept_session accepts and returns Some() the session could still fail with /// receive() returning an error. A Some() return from check_accept_sesion doesn't guarantee @@ -409,7 +408,7 @@ impl Context { 'b, SendFunction: FnMut(Option<&Arc>>, &mut [u8]), CheckAllowIncomingSession: FnMut() -> bool, - CheckAcceptSession: FnMut(&[u8], Option<&[u8]>) -> Option<(P384PublicKey, Secret<64>, Application::Data)>, + CheckAcceptSession: FnMut(&[u8]) -> Option<(P384PublicKey, Secret<64>, Application::Data)>, >( &self, app: &Application, @@ -568,7 +567,7 @@ impl Context { 'b, SendFunction: FnMut(Option<&Arc>>, &mut [u8]), CheckAllowIncomingSession: FnMut() -> bool, - CheckAcceptSession: FnMut(&[u8], Option<&[u8]>) -> Option<(P384PublicKey, Secret<64>, Application::Data)>, + CheckAcceptSession: FnMut(&[u8]) -> Option<(P384PublicKey, Secret<64>, Application::Data)>, >( &self, app: &Application, @@ -716,15 +715,14 @@ impl Context { let alice_noise_e = P384PublicKey::from_bytes(&pkt.alice_noise_e).ok_or(Error::FailedAuthentication)?; let noise_es = app.get_local_s_keypair().agree(&alice_noise_e).ok_or(Error::FailedAuthentication)?; - // Authenticate packet and also prove that Alice knows our static public key. - if !secure_eq( - &pkt.hmac_es, - &hmac_sha384_2( - kbkdf::(noise_es.as_bytes()).as_bytes(), - &incoming_message_nonce, - &pkt_assembled[HEADER_SIZE..AliceNoiseXKInit::AUTH_START], - ), - ) { + // Decrypt and authenticate init packet, also proving that caller knows our static identity. + let mut gcm = AesGcm::new( + kbkdf::(noise_es.as_bytes()).as_bytes(), + false, + ); + gcm.reset_init_gcm(&incoming_message_nonce); + gcm.crypt_in_place(&mut pkt_assembled[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START]); + if !gcm.finish_decrypt(&pkt_assembled[AliceNoiseXKInit::AUTH_START..AliceNoiseXKInit::AUTH_START + AES_GCM_TAG_SIZE]) { return Err(Error::FailedAuthentication); } @@ -733,12 +731,6 @@ impl Context { return Ok(ReceiveResult::Rejected); } - // Decrypt encrypted part of payload. - aes_ctr_crypt_one_time_use_key( - kbkdf::(noise_es.as_bytes()).as_bytes(), - &mut pkt_assembled[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START], - ); - let pkt: &AliceNoiseXKInit = byte_array_as_proto_buffer(pkt_assembled)?; let alice_session_id = SessionId::new_from_bytes(&pkt.alice_session_id).ok_or(Error::InvalidPacket)?; let header_protection_key = Secret(pkt.header_protection_key); @@ -809,19 +801,14 @@ impl Context { ack.bob_session_id = *bob_session_id.as_bytes(); ack.bob_hk_ciphertext = bob_hk_ciphertext; - // Encrypt main section of reply. - aes_ctr_crypt_one_time_use_key( + // Encrypt main section of reply and attach tag. + let mut gcm = AesGcm::new( kbkdf::(noise_es_ee.as_bytes()).as_bytes(), - &mut ack_packet[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START], + true, ); - - // Add HMAC-SHA384 to reply packet. - let reply_hmac = hmac_sha384_2( - kbkdf::(noise_es_ee.as_bytes()).as_bytes(), - &create_message_nonce(PACKET_TYPE_BOB_NOISE_XK_ACK, 1), - &ack_packet[HEADER_SIZE..BobNoiseXKAck::AUTH_START], - ); - ack_packet[BobNoiseXKAck::AUTH_START..].copy_from_slice(&reply_hmac); + gcm.reset_init_gcm(&create_message_nonce(PACKET_TYPE_BOB_NOISE_XK_ACK, 1)); + gcm.crypt_in_place(&mut ack_packet[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START]); + ack_packet[BobNoiseXKAck::AUTH_START..BobNoiseXKAck::AUTH_START + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); send_with_fragmentation( |b| send(None, b), @@ -875,26 +862,17 @@ impl Context { .as_bytes(), )); - let noise_es_ee_kex_hmac_key = - kbkdf::(noise_es_ee.as_bytes()); - - // Authenticate Bob's reply and the validity of bob_noise_e. - if !secure_eq( - &pkt.hmac_es_ee, - &hmac_sha384_2( - noise_es_ee_kex_hmac_key.as_bytes(), - &incoming_message_nonce, - &pkt_assembled[HEADER_SIZE..BobNoiseXKAck::AUTH_START], - ), - ) { + // Decrypt and authenticate Bob's reply. + let mut gcm = AesGcm::new( + kbkdf::(noise_es_ee.as_bytes()).as_bytes(), + false, + ); + gcm.reset_init_gcm(&incoming_message_nonce); + gcm.crypt_in_place(&mut pkt_assembled[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START]); + if !gcm.finish_decrypt(&pkt_assembled[BobNoiseXKAck::AUTH_START..BobNoiseXKAck::AUTH_START + AES_GCM_TAG_SIZE]) { return Err(Error::FailedAuthentication); } - // Decrypt encrypted portion of message. - aes_ctr_crypt_one_time_use_key( - kbkdf::(noise_es_ee.as_bytes()).as_bytes(), - &mut pkt_assembled[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START], - ); let pkt: &BobNoiseXKAck = byte_array_as_proto_buffer(pkt_assembled)?; if let Some(bob_session_id) = SessionId::new_from_bytes(&pkt.bob_session_id) { @@ -922,51 +900,47 @@ impl Context { // up forward secrecy. Also return Bob's opaque note. let mut reply_buffer = [0u8; MAX_NOISE_HANDSHAKE_SIZE]; reply_buffer[HEADER_SIZE] = SESSION_PROTOCOL_VERSION; - let mut reply_len = HEADER_SIZE + 1; - let mut reply_buffer_append = |b: &[u8]| { - let reply_len_new = reply_len + b.len(); - debug_assert!(reply_len_new <= MAX_NOISE_HANDSHAKE_SIZE); - reply_buffer[reply_len..reply_len_new].copy_from_slice(b); - reply_len = reply_len_new; - }; + let mut rw = &mut reply_buffer[HEADER_SIZE + 1..]; + let alice_s_public_blob = app.get_local_s_public_blob(); assert!(alice_s_public_blob.len() <= (u16::MAX as usize)); - reply_buffer_append(&(alice_s_public_blob.len() as u16).to_le_bytes()); - reply_buffer_append(alice_s_public_blob); + rw.write_all(&(alice_s_public_blob.len() as u16).to_le_bytes())?; + let mut enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + rw.write_all(alice_s_public_blob)?; + + let mut reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + let mut gcm = AesGcm::new( + kbkdf::(&hmac_sha512( + noise_es_ee.as_bytes(), + hk.as_bytes(), + )) + .as_bytes(), + true, + ); + gcm.reset_init_gcm(&reply_message_nonce); + gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); + let mut rw = &mut reply_buffer[reply_len..]; + rw.write_all(&gcm.finish_encrypt())?; + if let Some(md) = outgoing_offer.metadata.as_ref() { - reply_buffer_append(&(md.len() as u16).to_le_bytes()); - reply_buffer_append(md.as_ref()); + assert!(md.len() <= (u16::MAX as usize)); + rw.write_all(&(md.len() as u16).to_le_bytes())?; + enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + rw.write_all(md.as_ref())?; } else { - reply_buffer_append(&[0u8, 0u8]); // no meta-data + rw.write_all(&[0u8, 0u8])?; // no meta-data + enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); } - // Encrypt Alice's static identity and other inner payload items. The key used here is - // mixed with 'hk' to make identity secrecy PQ forward secure. - aes_ctr_crypt_one_time_use_key( - &hmac_sha512(noise_es_ee.as_bytes(), hk.as_bytes())[..AES_256_KEY_SIZE], - &mut reply_buffer[HEADER_SIZE + 1..reply_len], + reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + let mut gcm = AesGcm::new( + kbkdf::(noise_es_ee_se_hk_psk.as_bytes()).as_bytes(), + true, ); - - // First attach HMAC allowing Bob to verify that this is from the same Alice and to - // verify the authenticity of encrypted data. - let hmac_es_ee = hmac_sha384_2( - noise_es_ee_kex_hmac_key.as_bytes(), - &reply_message_nonce, - &reply_buffer[HEADER_SIZE..reply_len], - ); - reply_buffer[reply_len..reply_len + HMAC_SHA384_SIZE].copy_from_slice(&hmac_es_ee); - reply_len += HMAC_SHA384_SIZE; - - // Then attach the final HMAC permitting Bob to verify the authenticity of the whole - // key exchange. Bob won't be able to do this until he decrypts and parses Alice's - // identity, so the first HMAC is to let him authenticate that first. - let hmac_es_ee_se_hk_psk = hmac_sha384_2( - kbkdf::(noise_es_ee_se_hk_psk.as_bytes()).as_bytes(), - &reply_message_nonce, - &reply_buffer[HEADER_SIZE..reply_len], - ); - reply_buffer[reply_len..reply_len + HMAC_SHA384_SIZE].copy_from_slice(&hmac_es_ee_se_hk_psk); - reply_len += HMAC_SHA384_SIZE; + gcm.reset_init_gcm(&reply_message_nonce); + gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); + reply_buffer[reply_len..reply_len + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); + reply_len += AES_GCM_TAG_SIZE; drop(state); { @@ -1025,62 +999,20 @@ impl Context { } if let Some(incoming) = incoming { - // Check the first HMAC to verify against the currently known noise_es_ee key, which verifies - // that this reply is part of this session. - let auth_start = pkt_assembled.len() - ALICE_NOISE_XK_ACK_AUTH_SIZE; - if !secure_eq( - &pkt_assembled[auth_start..pkt_assembled.len() - HMAC_SHA384_SIZE], - &hmac_sha384_2( - kbkdf::(incoming.noise_es_ee.as_bytes()).as_bytes(), - &incoming_message_nonce, - &pkt_assembled[HEADER_SIZE..auth_start], - ), - ) { - return Err(Error::FailedAuthentication); - } + let mut r = PktReader(pkt_assembled, HEADER_SIZE + 1); - // Make a copy of pkt_assembled so we can check the second HMAC against original ciphertext later. - let mut pkt_assembly_buffer_copy = [0u8; MAX_NOISE_HANDSHAKE_SIZE]; - pkt_assembly_buffer_copy[..pkt_assembled.len()].copy_from_slice(pkt_assembled); + let alice_static_public_blob_size = r.read_u16()? as usize; + let alice_static_public_blob = r.read_decrypt_auth( + alice_static_public_blob_size, + kbkdf::(&hmac_sha512( + incoming.noise_es_ee.as_bytes(), + incoming.hk.as_bytes(), + )), + &incoming_message_nonce, + )?; - // Decrypt encrypted section so we can finally learn Alice's static identity. - aes_ctr_crypt_one_time_use_key( - &hmac_sha512(incoming.noise_es_ee.as_bytes(), incoming.hk.as_bytes())[..AES_256_KEY_SIZE], - &mut pkt_assembled[ALICE_NOISE_XK_ACK_ENC_START..auth_start], - ); - - // Read the static public blob and optional meta-data. - let mut pkt_assembled_ptr = HEADER_SIZE + 1; - let mut pkt_assembled_field_end = pkt_assembled_ptr + 2; - if pkt_assembled_field_end >= pkt_assembled.len() { - return Err(Error::InvalidPacket); - } - let alice_static_public_blob_size = - u16::from_le_bytes(pkt_assembled[pkt_assembled_ptr..pkt_assembled_field_end].try_into().unwrap()) as usize; - pkt_assembled_ptr = pkt_assembled_field_end; - pkt_assembled_field_end = pkt_assembled_ptr + alice_static_public_blob_size; - if pkt_assembled_field_end >= pkt_assembled.len() { - return Err(Error::InvalidPacket); - } - let alice_static_public_blob = &pkt_assembled[pkt_assembled_ptr..pkt_assembled_field_end]; - pkt_assembled_ptr = pkt_assembled_field_end; - pkt_assembled_field_end = pkt_assembled_ptr + 2; - if pkt_assembled_field_end >= pkt_assembled.len() { - return Err(Error::InvalidPacket); - } - let alice_meta_data_size = - u16::from_le_bytes(pkt_assembled[pkt_assembled_ptr..pkt_assembled_field_end].try_into().unwrap()) as usize; - pkt_assembled_ptr = pkt_assembled_field_end; - pkt_assembled_field_end = pkt_assembled_ptr + alice_meta_data_size; - let alice_meta_data = if alice_meta_data_size > 0 { - Some(&pkt_assembled[pkt_assembled_ptr..pkt_assembled_field_end]) - } else { - None - }; - - // Check session acceptance and fish Alice's NIST P-384 static public key out of - // her static public blob. - let check_result = check_accept_session(alice_static_public_blob, alice_meta_data); + // Check session acceptance and fish Alice's NIST P-384 static public key out of her static public blob. + let check_result = check_accept_session(alice_static_public_blob); if check_result.is_none() { self.sessions.write().unwrap().incoming.remove(&incoming.bob_session_id); return Ok(ReceiveResult::Rejected); @@ -1100,17 +1032,18 @@ impl Context { &hmac_sha512(psk.as_bytes(), incoming.hk.as_bytes()), )); - // Verify the packet using the final key to verify the whole key exchange. - if !secure_eq( - &pkt_assembly_buffer_copy[auth_start + HMAC_SHA384_SIZE..pkt_assembled.len()], - &hmac_sha384_2( - kbkdf::(noise_es_ee_se_hk_psk.as_bytes()).as_bytes(), - &incoming_message_nonce, - &pkt_assembly_buffer_copy[HEADER_SIZE..auth_start + HMAC_SHA384_SIZE], - ), - ) { - return Err(Error::FailedAuthentication); + // Decrypt meta-data and verify the final key in the process. Copy meta-data + // into the temporary data buffer to return. + let alice_meta_data_size = r.read_u16()? as usize; + let alice_meta_data = r.read_decrypt_auth( + alice_meta_data_size, + kbkdf::(noise_es_ee_se_hk_psk.as_bytes()), + &incoming_message_nonce, + )?; + if alice_meta_data.len() > data_buf.len() { + return Err(Error::DataTooLarge); } + data_buf[..alice_meta_data.len()].copy_from_slice(alice_meta_data); let session = Arc::new(Session { id: incoming.bob_session_id, @@ -1131,6 +1064,7 @@ impl Context { defrag: std::array::from_fn(|_| Mutex::new(Fragged::new())), }); + // Promote incoming session to active. { let mut sessions = self.sessions.write().unwrap(); sessions.incoming.remove(&incoming.bob_session_id); @@ -1139,7 +1073,14 @@ impl Context { let _ = session.send_nop(|b| send(Some(&session), b)); - return Ok(ReceiveResult::OkNewSession(session)); + return Ok(ReceiveResult::OkNewSession( + session, + if alice_meta_data.is_empty() { + None + } else { + Some(&mut data_buf[..alice_meta_data.len()]) + }, + )); } else { return Err(Error::UnknownLocalSessionId); } @@ -1650,22 +1591,40 @@ impl SessionKey { } } -/// Shortcut to HMAC data split into two slices. -fn hmac_sha384_2(key: &[u8], a: &[u8], b: &[u8]) -> [u8; 48] { - let mut hmac = HMACSHA384::new(key); - hmac.update(a); - hmac.update(b); - hmac.finish() -} +/// Helper for parsing variable length ALICE_NOISE_XK_ACK +struct PktReader<'a>(&'a mut [u8], usize); -/// Shortcut to AES-CTR encrypt or decrypt with a zero IV. -/// -/// This is used during Noise_XK handshaking. Each stage uses a different key to encrypt the -/// payload that is used only once per handshake and per session. -fn aes_ctr_crypt_one_time_use_key(key: &[u8], data: &mut [u8]) { - let mut ctr = AesCtr::new(key); - ctr.reset_set_iv(&[0u8; 12]); - ctr.crypt_in_place(data); +impl<'a> PktReader<'a> { + fn read_u16(&mut self) -> Result { + let tmp = self.1 + 2; + if tmp <= self.0.len() { + let n = u16::from_le_bytes(self.0[self.1..tmp].try_into().unwrap()); + self.1 = tmp; + Ok(n) + } else { + Err(Error::InvalidPacket) + } + } + + fn read_decrypt_auth<'b>(&'b mut self, l: usize, k: Secret, nonce: &[u8]) -> Result<&'b [u8], Error> { + let mut tmp = self.1 + l; + if (tmp + AES_GCM_TAG_SIZE) <= self.0.len() { + let mut gcm = AesGcm::new(k.as_bytes(), false); + gcm.reset_init_gcm(nonce); + gcm.crypt_in_place(&mut self.0[self.1..tmp]); + let s = &self.0[self.1..tmp]; + self.1 = tmp; + tmp += AES_GCM_TAG_SIZE; + if !gcm.finish_decrypt(&self.0[self.1..tmp]) { + Err(Error::FailedAuthentication) + } else { + self.1 = tmp; + Ok(s) + } + } else { + Err(Error::InvalidPacket) + } + } } /// HMAC-SHA512 key derivation based on: https://csrc.nist.gov/publications/detail/sp/800-108/final (page 7) From 1c5de7473d9c372b0c89b023f8758b701d5a19fd Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Wed, 8 Mar 2023 14:22:47 -0500 Subject: [PATCH 3/5] Implement noise "h" --- zssp/README.md | 11 +++-- zssp/src/fragged.rs | 15 ++++--- zssp/src/main.rs | 1 + zssp/src/proto.rs | 7 ++++ zssp/src/zssp.rs | 100 ++++++++++++++++++++++++++++++-------------- 5 files changed, 90 insertions(+), 44 deletions(-) diff --git a/zssp/README.md b/zssp/README.md index e76c92b76..bdb94cd38 100644 --- a/zssp/README.md +++ b/zssp/README.md @@ -13,10 +13,9 @@ ZSSP is designed for use in ZeroTier 2 but is payload-agnostic and could easily ## Cryptographic Primitives Used - - AES-256-GCM: Authenticated encryption of data in transit. - - AES-256-CTR: Encryption of initial handshake data to be protected such as identities. - - HMAC-SHA384: Key mixing, key derivation, message authentication during initial session handshake. - - NIST P-384 ECDH: Elliptic curve key exchange during initial handshake and for periodic re-keying during the session. - - Kyber1024: Quantum attack resistant lattice-based key exchange during initial handshake. - - AES-256-ECB: Single 128-bit block encryption of header information to harden the fragmentation protocol against denial of service attack (see section on header protection). + - AES-256-GCM: Authenticated encryption + - HMAC-SHA384: Key mixing, sub-key derivation in key-based KDF construction + - NIST P-384 ECDH: Elliptic curve key exchange during initial handshake and for periodic re-keying during the session + - Kyber1024: Quantum attack resistant lattice-based key exchange during initial handshake + - AES-256-ECB: Single 128-bit block encryption of header information to harden the fragmentation protocol against denial of service attack (see section on header protection) diff --git a/zssp/src/fragged.rs b/zssp/src/fragged.rs index 92ab6cb83..01a0c3b20 100644 --- a/zssp/src/fragged.rs +++ b/zssp/src/fragged.rs @@ -31,9 +31,13 @@ impl Drop for Assembled Fragged { #[inline(always)] pub fn new() -> Self { - debug_assert!(MAX_FRAGMENTS <= 64); - debug_assert_eq!(size_of::>(), size_of::()); - debug_assert_eq!( + // These assertions should be optimized out at compile time and check to make sure + // that the array of MaybeUninit can be freely cast into an array of + // Fragment. They also check that the maximum number of fragments is not too large + // for the fact that we use bits in a u64 to track which fragments are received. + assert!(MAX_FRAGMENTS <= 64); + assert_eq!(size_of::>(), size_of::()); + assert_eq!( size_of::<[MaybeUninit; MAX_FRAGMENTS]>(), size_of::<[Fragment; MAX_FRAGMENTS]>() ); @@ -47,10 +51,9 @@ impl Fragged { #[inline(always)] pub fn assemble(&mut self, counter: u64, fragment: Fragment, fragment_no: u8, fragment_count: u8) -> Option> { if fragment_no < fragment_count && (fragment_count as usize) <= MAX_FRAGMENTS { - debug_assert!((fragment_count as usize) <= MAX_FRAGMENTS); - debug_assert!((fragment_no as usize) < MAX_FRAGMENTS); - let mut have = self.have; + + // If the counter has changed, reset the structure to receive a new packet. if counter != self.counter { self.counter = counter; if needs_drop::() { diff --git a/zssp/src/main.rs b/zssp/src/main.rs index e6d11a2fc..402505e06 100644 --- a/zssp/src/main.rs +++ b/zssp/src/main.rs @@ -61,6 +61,7 @@ fn alice_main( let _ = alice_out.send(b.to_vec()); }, TEST_MTU, + bob_app.identity_key.public_key_bytes(), bob_app.identity_key.public_key(), Secret::default(), None, diff --git a/zssp/src/proto.rs b/zssp/src/proto.rs index 3aed80907..6dcc6176a 100644 --- a/zssp/src/proto.rs +++ b/zssp/src/proto.rs @@ -24,6 +24,13 @@ pub const MIN_TRANSPORT_MTU: usize = 128; /// Maximum combined size of static public blob and metadata. pub const MAX_INIT_PAYLOAD_SIZE: usize = MAX_NOISE_HANDSHAKE_SIZE - ALICE_NOISE_XK_ACK_MIN_SIZE; +/// Initial value of 'h' +/// echo -n 'Noise_XKpsk3_P384_AESGCM_SHA384_hybridKyber1024' | shasum -a 384 +pub(crate) const INITIAL_H: [u8; SHA384_HASH_SIZE] = [ + 0x35, 0x27, 0x16, 0x62, 0x58, 0x04, 0x0c, 0x7a, 0x99, 0xa8, 0x0b, 0x49, 0xb2, 0x6b, 0x25, 0xfb, 0xf5, 0x26, 0x2a, 0x26, 0xe7, 0xb3, 0x70, 0xcb, + 0x2c, 0x3c, 0xcb, 0x7f, 0xca, 0x20, 0x06, 0x91, 0x20, 0x55, 0x52, 0x8e, 0xd4, 0x3c, 0x97, 0xc3, 0xd5, 0x6c, 0xb4, 0x13, 0x02, 0x54, 0x83, 0x12, +]; + /// Version 0: Noise_XK with NIST P-384 plus Kyber1024 hybrid exchange on session init. pub(crate) const SESSION_PROTOCOL_VERSION: u8 = 0x00; diff --git a/zssp/src/zssp.rs b/zssp/src/zssp.rs index b2d73a7f1..57a01d55f 100644 --- a/zssp/src/zssp.rs +++ b/zssp/src/zssp.rs @@ -16,7 +16,7 @@ use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use zerotier_crypto::aes::{Aes, AesGcm}; -use zerotier_crypto::hash::{hmac_sha512, SHA384}; +use zerotier_crypto::hash::{hmac_sha512, SHA384, SHA384_HASH_SIZE}; use zerotier_crypto::p384::{P384KeyPair, P384PublicKey, P384_ECDH_SHARED_SECRET_SIZE}; use zerotier_crypto::secret::Secret; use zerotier_crypto::{random, secure_eq}; @@ -53,7 +53,7 @@ struct SessionsById { active: HashMap>>, // Incomplete sessions in the middle of three-phase Noise_XK negotiation, expired after timeout. - incoming: HashMap>, + incoming: HashMap>, } /// Result generated by the context packet receive function, with possible payloads. @@ -97,20 +97,22 @@ struct State { current_offer: Offer, } -struct IncomingIncompleteSession { +struct BobIncomingIncompleteSessionState { timestamp: i64, alice_session_id: SessionId, bob_session_id: SessionId, + noise_h: [u8; SHA384_HASH_SIZE], noise_es_ee: Secret, hk: Secret, header_protection_key: Secret, bob_noise_e_secret: P384KeyPair, } -struct OutgoingSessionInit { +struct AliceOutgoingIncompleteSessionState { last_retry_time: AtomicI64, - alice_noise_e_secret: P384KeyPair, + noise_h: [u8; SHA384_HASH_SIZE], noise_es: Secret, + alice_noise_e_secret: P384KeyPair, alice_hk_secret: Secret, metadata: Option>, init_packet: [u8; AliceNoiseXKInit::SIZE], @@ -124,7 +126,7 @@ struct OutgoingSessionAck { enum Offer { None, - NoiseXKInit(Box), + NoiseXKInit(Box), NoiseXKAck(Box), RekeyInit(P384KeyPair, i64), } @@ -267,6 +269,7 @@ impl Context { /// * `app` - Application layer instance /// * `send` - User-supplied packet sending function /// * `mtu` - Physical MTU for calls to send() + /// * `remote_s_public_blob` - Remote side's opaque static public blob (which must contain remote_s_public_p384) /// * `remote_s_public_p384` - Remote side's static public NIST P-384 key /// * `psk` - Pre-shared key (use all zero if none) /// * `metadata` - Optional metadata to be included in initial handshake @@ -277,6 +280,7 @@ impl Context { app: &Application, mut send: SendFunction, mtu: usize, + remote_s_public_blob: &[u8], remote_s_public_p384: &P384PublicKey, psk: Secret, metadata: Option>, @@ -315,10 +319,11 @@ impl Context { remote_session_id: None, keys: [None, None], current_key: 0, - current_offer: Offer::NoiseXKInit(Box::new(OutgoingSessionInit { + current_offer: Offer::NoiseXKInit(Box::new(AliceOutgoingIncompleteSessionState { last_retry_time: AtomicI64::new(current_time), - alice_noise_e_secret, + noise_h: mix_hash(&INITIAL_H, remote_s_public_blob), noise_es: noise_es.clone(), + alice_noise_e_secret, alice_hk_secret: Secret(alice_hk_secret.secret), metadata, init_packet: [0u8; AliceNoiseXKInit::SIZE], @@ -334,12 +339,14 @@ impl Context { { let mut state = session.state.write().unwrap(); - let init_packet = if let Offer::NoiseXKInit(offer) = &mut state.current_offer { - &mut offer.init_packet + let offer = if let Offer::NoiseXKInit(offer) = &mut state.current_offer { + offer } else { - panic!(); // should be impossible + panic!(); // should be impossible as this is what we initialized with }; + // Create Alice's initial outgoing state message. + let init_packet = &mut offer.init_packet; { let init: &mut AliceNoiseXKInit = byte_array_as_proto_buffer_mut(init_packet).unwrap(); init.session_protocol_version = SESSION_PROTOCOL_VERSION; @@ -349,14 +356,19 @@ impl Context { init.header_protection_key = header_protection_key.0; } + // Encrypt and add authentication tag. let mut gcm = AesGcm::new( kbkdf::(noise_es.as_bytes()).as_bytes(), true, ); gcm.reset_init_gcm(&create_message_nonce(PACKET_TYPE_ALICE_NOISE_XK_INIT, 1)); + gcm.aad(&offer.noise_h); gcm.crypt_in_place(&mut init_packet[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START]); init_packet[AliceNoiseXKInit::AUTH_START..AliceNoiseXKInit::AUTH_START + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); + // Update ongoing state hash with Alice's outgoing init ciphertext. + offer.noise_h = mix_hash(&offer.noise_h, &init_packet[HEADER_SIZE..]); + send_with_fragmentation( &mut send, &mut (init_packet.clone()), @@ -579,7 +591,7 @@ impl Context { fragments: &[Application::IncomingPacketBuffer], packet_type: u8, session: Option>>, - incoming: Option>, + incoming: Option>, key_index: usize, mtu: usize, current_time: i64, @@ -715,12 +727,16 @@ impl Context { let alice_noise_e = P384PublicKey::from_bytes(&pkt.alice_noise_e).ok_or(Error::FailedAuthentication)?; let noise_es = app.get_local_s_keypair().agree(&alice_noise_e).ok_or(Error::FailedAuthentication)?; + let noise_h = mix_hash(&INITIAL_H, app.get_local_s_public_blob()); + let noise_h_next = mix_hash(&noise_h, &pkt_assembled[HEADER_SIZE..]); + // Decrypt and authenticate init packet, also proving that caller knows our static identity. let mut gcm = AesGcm::new( kbkdf::(noise_es.as_bytes()).as_bytes(), false, ); gcm.reset_init_gcm(&incoming_message_nonce); + gcm.aad(&noise_h); gcm.crypt_in_place(&mut pkt_assembled[AliceNoiseXKInit::ENC_START..AliceNoiseXKInit::AUTH_START]); if !gcm.finish_decrypt(&pkt_assembled[AliceNoiseXKInit::AUTH_START..AliceNoiseXKInit::AUTH_START + AES_GCM_TAG_SIZE]) { return Err(Error::FailedAuthentication); @@ -757,6 +773,24 @@ impl Context { } } + // Create Bob's ephemeral counter-offer reply. + let mut ack_packet = [0u8; BobNoiseXKAck::SIZE]; + let ack: &mut BobNoiseXKAck = byte_array_as_proto_buffer_mut(&mut ack_packet)?; + ack.session_protocol_version = SESSION_PROTOCOL_VERSION; + ack.bob_noise_e = bob_noise_e; + ack.bob_session_id = *bob_session_id.as_bytes(); + ack.bob_hk_ciphertext = bob_hk_ciphertext; + + // Encrypt main section of reply and attach tag. + let mut gcm = AesGcm::new( + kbkdf::(noise_es_ee.as_bytes()).as_bytes(), + true, + ); + gcm.reset_init_gcm(&create_message_nonce(PACKET_TYPE_BOB_NOISE_XK_ACK, 1)); + gcm.aad(&noise_h_next); + gcm.crypt_in_place(&mut ack_packet[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START]); + ack_packet[BobNoiseXKAck::AUTH_START..BobNoiseXKAck::AUTH_START + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); + // If this queue is too big, we remove the latest entry and replace it. The latest // is used because under flood conditions this is most likely to be another bogus // entry. If we find one that is actually timed out, that one is replaced instead. @@ -779,10 +813,11 @@ impl Context { // Reserve session ID on this side and record incomplete session state. sessions.incoming.insert( bob_session_id, - Arc::new(IncomingIncompleteSession { + Arc::new(BobIncomingIncompleteSessionState { timestamp: current_time, alice_session_id, bob_session_id, + noise_h: mix_hash(&noise_h_next, &ack_packet[HEADER_SIZE..]), noise_es_ee: noise_es_ee.clone(), hk, bob_noise_e_secret, @@ -791,25 +826,9 @@ impl Context { ); debug_assert!(!sessions.active.contains_key(&bob_session_id)); + // Release lock drop(sessions); - // Create Bob's ephemeral counter-offer reply. - let mut ack_packet = [0u8; BobNoiseXKAck::SIZE]; - let ack: &mut BobNoiseXKAck = byte_array_as_proto_buffer_mut(&mut ack_packet)?; - ack.session_protocol_version = SESSION_PROTOCOL_VERSION; - ack.bob_noise_e = bob_noise_e; - ack.bob_session_id = *bob_session_id.as_bytes(); - ack.bob_hk_ciphertext = bob_hk_ciphertext; - - // Encrypt main section of reply and attach tag. - let mut gcm = AesGcm::new( - kbkdf::(noise_es_ee.as_bytes()).as_bytes(), - true, - ); - gcm.reset_init_gcm(&create_message_nonce(PACKET_TYPE_BOB_NOISE_XK_ACK, 1)); - gcm.crypt_in_place(&mut ack_packet[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START]); - ack_packet[BobNoiseXKAck::AUTH_START..BobNoiseXKAck::AUTH_START + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); - send_with_fragmentation( |b| send(None, b), &mut ack_packet, @@ -862,12 +881,16 @@ impl Context { .as_bytes(), )); + // Go ahead and compute the next 'h' state before we lose the ciphertext in decrypt. + let noise_h_next = mix_hash(&outgoing_offer.noise_h, &pkt_assembled[HEADER_SIZE..]); + // Decrypt and authenticate Bob's reply. let mut gcm = AesGcm::new( kbkdf::(noise_es_ee.as_bytes()).as_bytes(), false, ); gcm.reset_init_gcm(&incoming_message_nonce); + gcm.aad(&outgoing_offer.noise_h); gcm.crypt_in_place(&mut pkt_assembled[BobNoiseXKAck::ENC_START..BobNoiseXKAck::AUTH_START]); if !gcm.finish_decrypt(&pkt_assembled[BobNoiseXKAck::AUTH_START..BobNoiseXKAck::AUTH_START + AES_GCM_TAG_SIZE]) { return Err(Error::FailedAuthentication); @@ -918,6 +941,7 @@ impl Context { true, ); gcm.reset_init_gcm(&reply_message_nonce); + gcm.aad(&noise_h_next); gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); let mut rw = &mut reply_buffer[reply_len..]; rw.write_all(&gcm.finish_encrypt())?; @@ -938,6 +962,7 @@ impl Context { true, ); gcm.reset_init_gcm(&reply_message_nonce); + gcm.aad(&noise_h_next); gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); reply_buffer[reply_len..reply_len + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); reply_len += AES_GCM_TAG_SIZE; @@ -1008,6 +1033,7 @@ impl Context { incoming.noise_es_ee.as_bytes(), incoming.hk.as_bytes(), )), + &incoming.noise_h, &incoming_message_nonce, )?; @@ -1038,6 +1064,7 @@ impl Context { let alice_meta_data = r.read_decrypt_auth( alice_meta_data_size, kbkdf::(noise_es_ee_se_hk_psk.as_bytes()), + &incoming.noise_h, &incoming_message_nonce, )?; if alice_meta_data.len() > data_buf.len() { @@ -1591,7 +1618,7 @@ impl SessionKey { } } -/// Helper for parsing variable length ALICE_NOISE_XK_ACK +/// Helper code for parsing variable length ALICE_NOISE_XK_ACK during negotiation. struct PktReader<'a>(&'a mut [u8], usize); impl<'a> PktReader<'a> { @@ -1606,11 +1633,12 @@ impl<'a> PktReader<'a> { } } - fn read_decrypt_auth<'b>(&'b mut self, l: usize, k: Secret, nonce: &[u8]) -> Result<&'b [u8], Error> { + fn read_decrypt_auth<'b>(&'b mut self, l: usize, k: Secret, gcm_aad: &[u8], nonce: &[u8]) -> Result<&'b [u8], Error> { let mut tmp = self.1 + l; if (tmp + AES_GCM_TAG_SIZE) <= self.0.len() { let mut gcm = AesGcm::new(k.as_bytes(), false); gcm.reset_init_gcm(nonce); + gcm.aad(gcm_aad); gcm.crypt_in_place(&mut self.0[self.1..tmp]); let s = &self.0[self.1..tmp]; self.1 = tmp; @@ -1627,6 +1655,14 @@ impl<'a> PktReader<'a> { } } +/// MixHash to update 'h' during negotiation. +fn mix_hash(h: &[u8; SHA384_HASH_SIZE], m: &[u8]) -> [u8; SHA384_HASH_SIZE] { + let mut hasher = SHA384::new(); + hasher.update(h); + hasher.update(m); + hasher.finish() +} + /// HMAC-SHA512 key derivation based on: https://csrc.nist.gov/publications/detail/sp/800-108/final (page 7) /// Cryptographically this isn't meaningfully different from HMAC(key, [label]) but this is how NIST rolls. fn kbkdf(key: &[u8]) -> Secret { From 94b3e208e7d5809a2e7a77a2166c85a919712bbc Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Wed, 8 Mar 2023 14:57:32 -0500 Subject: [PATCH 4/5] Ensure that the 'h' mechanism from Noise authenticates part one of the final packet in part two's MAC. --- zssp/src/error.rs | 2 +- zssp/src/zssp.rs | 34 ++++++++++++++++++++++------------ 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/zssp/src/error.rs b/zssp/src/error.rs index 5fd35c9b4..a8448bc35 100644 --- a/zssp/src/error.rs +++ b/zssp/src/error.rs @@ -51,7 +51,7 @@ pub enum Error { impl From for Error { #[inline(always)] fn from(_: std::io::Error) -> Self { - Self::InvalidPacket + Self::UnexpectedBufferOverrun } } diff --git a/zssp/src/zssp.rs b/zssp/src/zssp.rs index 57a01d55f..89bdf490e 100644 --- a/zssp/src/zssp.rs +++ b/zssp/src/zssp.rs @@ -946,26 +946,29 @@ impl Context { let mut rw = &mut reply_buffer[reply_len..]; rw.write_all(&gcm.finish_encrypt())?; - if let Some(md) = outgoing_offer.metadata.as_ref() { - assert!(md.len() <= (u16::MAX as usize)); - rw.write_all(&(md.len() as u16).to_le_bytes())?; - enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); - rw.write_all(md.as_ref())?; - } else { - rw.write_all(&[0u8, 0u8])?; // no meta-data - enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); - } + let metadata = outgoing_offer.metadata.as_ref().map_or(&[][..0], |md| md.as_slice()); + + assert!(metadata.len() <= (u16::MAX as usize)); + rw.write_all(&(metadata.len() as u16).to_le_bytes())?; reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + let noise_h_next = mix_hash(&noise_h_next, &reply_buffer[HEADER_SIZE..reply_len]); + let mut rw = &mut reply_buffer[reply_len..]; + + enc_start = reply_len; + rw.write_all(metadata.as_ref())?; + let mut gcm = AesGcm::new( kbkdf::(noise_es_ee_se_hk_psk.as_bytes()).as_bytes(), true, ); gcm.reset_init_gcm(&reply_message_nonce); gcm.aad(&noise_h_next); + reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); - reply_buffer[reply_len..reply_len + AES_GCM_TAG_SIZE].copy_from_slice(&gcm.finish_encrypt()); - reply_len += AES_GCM_TAG_SIZE; + let mut rw = &mut reply_buffer[reply_len..]; + rw.write_all(&gcm.finish_encrypt())?; + reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); drop(state); { @@ -1027,6 +1030,13 @@ impl Context { let mut r = PktReader(pkt_assembled, HEADER_SIZE + 1); let alice_static_public_blob_size = r.read_u16()? as usize; + + let ciphertext_up_to_metadata_size = r.1 + alice_static_public_blob_size + AES_GCM_TAG_SIZE + 2; + if r.0.len() < ciphertext_up_to_metadata_size { + return Err(Error::InvalidPacket); + } + let noise_h_next = mix_hash(&incoming.noise_h, &r.0[HEADER_SIZE..ciphertext_up_to_metadata_size]); + let alice_static_public_blob = r.read_decrypt_auth( alice_static_public_blob_size, kbkdf::(&hmac_sha512( @@ -1064,7 +1074,7 @@ impl Context { let alice_meta_data = r.read_decrypt_auth( alice_meta_data_size, kbkdf::(noise_es_ee_se_hk_psk.as_bytes()), - &incoming.noise_h, + &noise_h_next, &incoming_message_nonce, )?; if alice_meta_data.len() > data_buf.len() { From cd6d8d36b08e4c01f14ec0ecd1d525473d888ab6 Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Wed, 8 Mar 2023 15:03:27 -0500 Subject: [PATCH 5/5] Simplify some packet building code. --- zssp/src/error.rs | 8 -------- zssp/src/zssp.rs | 35 +++++++++++++++++++---------------- 2 files changed, 19 insertions(+), 24 deletions(-) diff --git a/zssp/src/error.rs b/zssp/src/error.rs index a8448bc35..85f24f50c 100644 --- a/zssp/src/error.rs +++ b/zssp/src/error.rs @@ -47,14 +47,6 @@ pub enum Error { UnexpectedBufferOverrun, } -// An I/O error in the parser means an invalid packet. -impl From for Error { - #[inline(always)] - fn from(_: std::io::Error) -> Self { - Self::UnexpectedBufferOverrun - } -} - impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { diff --git a/zssp/src/zssp.rs b/zssp/src/zssp.rs index 89bdf490e..87f28563f 100644 --- a/zssp/src/zssp.rs +++ b/zssp/src/zssp.rs @@ -10,7 +10,6 @@ // FIPS compliant Noise_XK with Jedi powers (Kyber1024) and built-in attack-resistant large payload (fragmentation) support. use std::collections::{HashMap, HashSet}; -use std::io::Write; use std::num::NonZeroU64; use std::sync::atomic::{AtomicI64, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; @@ -923,15 +922,14 @@ impl Context { // up forward secrecy. Also return Bob's opaque note. let mut reply_buffer = [0u8; MAX_NOISE_HANDSHAKE_SIZE]; reply_buffer[HEADER_SIZE] = SESSION_PROTOCOL_VERSION; - let mut rw = &mut reply_buffer[HEADER_SIZE + 1..]; + let mut reply_len = HEADER_SIZE + 1; let alice_s_public_blob = app.get_local_s_public_blob(); assert!(alice_s_public_blob.len() <= (u16::MAX as usize)); - rw.write_all(&(alice_s_public_blob.len() as u16).to_le_bytes())?; - let mut enc_start = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); - rw.write_all(alice_s_public_blob)?; + reply_len = append_to_slice(&mut reply_buffer, reply_len, &(alice_s_public_blob.len() as u16).to_le_bytes())?; + let mut enc_start = reply_len; + reply_len = append_to_slice(&mut reply_buffer, reply_len, alice_s_public_blob)?; - let mut reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); let mut gcm = AesGcm::new( kbkdf::(&hmac_sha512( noise_es_ee.as_bytes(), @@ -943,20 +941,17 @@ impl Context { gcm.reset_init_gcm(&reply_message_nonce); gcm.aad(&noise_h_next); gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); - let mut rw = &mut reply_buffer[reply_len..]; - rw.write_all(&gcm.finish_encrypt())?; + reply_len = append_to_slice(&mut reply_buffer, reply_len, &gcm.finish_encrypt())?; let metadata = outgoing_offer.metadata.as_ref().map_or(&[][..0], |md| md.as_slice()); assert!(metadata.len() <= (u16::MAX as usize)); - rw.write_all(&(metadata.len() as u16).to_le_bytes())?; + reply_len = append_to_slice(&mut reply_buffer, reply_len, &(metadata.len() as u16).to_le_bytes())?; - reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); let noise_h_next = mix_hash(&noise_h_next, &reply_buffer[HEADER_SIZE..reply_len]); - let mut rw = &mut reply_buffer[reply_len..]; enc_start = reply_len; - rw.write_all(metadata.as_ref())?; + reply_len = append_to_slice(&mut reply_buffer, reply_len, metadata)?; let mut gcm = AesGcm::new( kbkdf::(noise_es_ee_se_hk_psk.as_bytes()).as_bytes(), @@ -964,11 +959,8 @@ impl Context { ); gcm.reset_init_gcm(&reply_message_nonce); gcm.aad(&noise_h_next); - reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); gcm.crypt_in_place(&mut reply_buffer[enc_start..reply_len]); - let mut rw = &mut reply_buffer[reply_len..]; - rw.write_all(&gcm.finish_encrypt())?; - reply_len = MAX_NOISE_HANDSHAKE_SIZE - rw.len(); + reply_len = append_to_slice(&mut reply_buffer, reply_len, &gcm.finish_encrypt())?; drop(state); { @@ -1665,6 +1657,17 @@ impl<'a> PktReader<'a> { } } +/// Helper function to append to a slice when we still want to be able to look back at it. +fn append_to_slice(s: &mut [u8], p: usize, d: &[u8]) -> Result { + let tmp = p + d.len(); + if tmp <= s.len() { + s[p..tmp].copy_from_slice(d); + Ok(tmp) + } else { + Err(Error::UnexpectedBufferOverrun) + } +} + /// MixHash to update 'h' during negotiation. fn mix_hash(h: &[u8; SHA384_HASH_SIZE], m: &[u8]) -> [u8; SHA384_HASH_SIZE] { let mut hasher = SHA384::new();