From 1c5de7473d9c372b0c89b023f8758b701d5a19fd Mon Sep 17 00:00:00 2001 From: Adam Ierymenko Date: Wed, 8 Mar 2023 14:22:47 -0500 Subject: [PATCH] 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 {