Implement noise "h"

This commit is contained in:
Adam Ierymenko 2023-03-08 14:22:47 -05:00
parent 757cc88abc
commit 1c5de7473d
5 changed files with 90 additions and 44 deletions

View file

@ -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)

View file

@ -31,9 +31,13 @@ impl<Fragment, const MAX_FRAGMENTS: usize> Drop for Assembled<Fragment, MAX_FRAG
impl<Fragment, const MAX_FRAGMENTS: usize> Fragged<Fragment, MAX_FRAGMENTS> {
#[inline(always)]
pub fn new() -> Self {
debug_assert!(MAX_FRAGMENTS <= 64);
debug_assert_eq!(size_of::<MaybeUninit<Fragment>>(), size_of::<Fragment>());
debug_assert_eq!(
// These assertions should be optimized out at compile time and check to make sure
// that the array of MaybeUninit<Fragment> 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::<MaybeUninit<Fragment>>(), size_of::<Fragment>());
assert_eq!(
size_of::<[MaybeUninit<Fragment>; MAX_FRAGMENTS]>(),
size_of::<[Fragment; MAX_FRAGMENTS]>()
);
@ -47,10 +51,9 @@ impl<Fragment, const MAX_FRAGMENTS: usize> Fragged<Fragment, MAX_FRAGMENTS> {
#[inline(always)]
pub fn assemble(&mut self, counter: u64, fragment: Fragment, fragment_no: u8, fragment_count: u8) -> Option<Assembled<Fragment, MAX_FRAGMENTS>> {
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::<Fragment>() {

View file

@ -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,

View file

@ -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;

View file

@ -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<Application: ApplicationLayer> {
active: HashMap<SessionId, Weak<Session<Application>>>,
// Incomplete sessions in the middle of three-phase Noise_XK negotiation, expired after timeout.
incoming: HashMap<SessionId, Arc<IncomingIncompleteSession>>,
incoming: HashMap<SessionId, Arc<BobIncomingIncompleteSessionState>>,
}
/// 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<BASE_KEY_SIZE>,
hk: Secret<KYBER_SSBYTES>,
header_protection_key: Secret<AES_HEADER_PROTECTION_KEY_SIZE>,
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<P384_ECDH_SHARED_SECRET_SIZE>,
alice_noise_e_secret: P384KeyPair,
alice_hk_secret: Secret<KYBER_SECRETKEYBYTES>,
metadata: Option<Vec<u8>>,
init_packet: [u8; AliceNoiseXKInit::SIZE],
@ -124,7 +126,7 @@ struct OutgoingSessionAck {
enum Offer {
None,
NoiseXKInit(Box<OutgoingSessionInit>),
NoiseXKInit(Box<AliceOutgoingIncompleteSessionState>),
NoiseXKAck(Box<OutgoingSessionAck>),
RekeyInit(P384KeyPair, i64),
}
@ -267,6 +269,7 @@ impl<Application: ApplicationLayer> Context<Application> {
/// * `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<Application: ApplicationLayer> Context<Application> {
app: &Application,
mut send: SendFunction,
mtu: usize,
remote_s_public_blob: &[u8],
remote_s_public_p384: &P384PublicKey,
psk: Secret<BASE_KEY_SIZE>,
metadata: Option<Vec<u8>>,
@ -315,10 +319,11 @@ impl<Application: ApplicationLayer> Context<Application> {
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<Application: ApplicationLayer> Context<Application> {
{
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<Application: ApplicationLayer> Context<Application> {
init.header_protection_key = header_protection_key.0;
}
// Encrypt and add authentication tag.
let mut gcm = AesGcm::new(
kbkdf::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<Application: ApplicationLayer> Context<Application> {
fragments: &[Application::IncomingPacketBuffer],
packet_type: u8,
session: Option<Arc<Session<Application>>>,
incoming: Option<Arc<IncomingIncompleteSession>>,
incoming: Option<Arc<BobIncomingIncompleteSessionState>>,
key_index: usize,
mtu: usize,
current_time: i64,
@ -715,12 +727,16 @@ impl<Application: ApplicationLayer> Context<Application> {
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::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<Application: ApplicationLayer> Context<Application> {
}
}
// 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::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<Application: ApplicationLayer> Context<Application> {
// 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<Application: ApplicationLayer> Context<Application> {
);
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::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<Application: ApplicationLayer> Context<Application> {
.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::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<Application: ApplicationLayer> Context<Application> {
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<Application: ApplicationLayer> Context<Application> {
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<Application: ApplicationLayer> Context<Application> {
incoming.noise_es_ee.as_bytes(),
incoming.hk.as_bytes(),
)),
&incoming.noise_h,
&incoming_message_nonce,
)?;
@ -1038,6 +1064,7 @@ impl<Application: ApplicationLayer> Context<Application> {
let alice_meta_data = r.read_decrypt_auth(
alice_meta_data_size,
kbkdf::<AES_256_KEY_SIZE, KBKDF_KEY_USAGE_LABEL_INIT_ENCRYPTION>(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<AES_256_KEY_SIZE>, nonce: &[u8]) -> Result<&'b [u8], Error> {
fn read_decrypt_auth<'b>(&'b mut self, l: usize, k: Secret<AES_256_KEY_SIZE>, 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<const OUTPUT_BYTES: usize, const LABEL: u8>(key: &[u8]) -> Secret<OUTPUT_BYTES> {