From 300531e87e84ec8a740c1f4fe5f5fad693ad94ee Mon Sep 17 00:00:00 2001 From: David Anderson Date: Wed, 13 May 2026 09:45:21 -0700 Subject: [PATCH] ts_noise: factor out Noise implementations Also switch to use aws-lc-rs. We already have to pull in aws-lc-rs for rustls, so avoid using rustcrypto as well for binary size. Signed-off-by: David Anderson Change-Id: I690c8c138843750e0486ff9b1cf36df26a6a6964 --- Cargo.lock | 38 +++++- Cargo.toml | 3 + ts_noise/Cargo.toml | 28 ++++ ts_noise/src/core.rs | 258 +++++++++++++++++++++++++++++++++++++ ts_noise/src/ik.rs | 151 ++++++++++++++++++++++ ts_noise/src/ikpsk2.rs | 169 ++++++++++++++++++++++++ ts_noise/src/lib.rs | 8 ++ ts_noise/src/messages.rs | 17 +++ ts_tunnel/src/handshake.rs | 4 +- 9 files changed, 668 insertions(+), 8 deletions(-) create mode 100644 ts_noise/Cargo.toml create mode 100644 ts_noise/src/core.rs create mode 100644 ts_noise/src/ik.rs create mode 100644 ts_noise/src/ikpsk2.rs create mode 100644 ts_noise/src/lib.rs create mode 100644 ts_noise/src/messages.rs diff --git a/Cargo.lock b/Cargo.lock index f4b3a4aa..a8355dc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,19 +172,20 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -2991,7 +2992,7 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] @@ -3134,7 +3135,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -4428,6 +4429,25 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ts_noise" +version = "0.2.0" +dependencies = [ + "aead", + "aws-lc-rs", + "blake2", + "chacha20poly1305", + "hkdf", + "itertools", + "rand 0.10.1", + "tracing", + "ts_keys", + "ts_packet", + "ts_time", + "x25519-dalek 3.0.0-pre.6", + "zerocopy", +] + [[package]] name = "ts_overlay_router" version = "0.2.0" @@ -4726,6 +4746,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index e57fde1c..342abd09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ members = [ "ts_netstack_smoltcp_core", "ts_netstack_smoltcp_socket", "ts_nodecapability", + "ts_noise", "ts_overlay_router", "ts_packet", "ts_packetfilter", @@ -59,6 +60,7 @@ rust-version = "1.91.0" [workspace.dependencies] aead = { version = "0.5", features = ["std"] } +aws-lc-rs = "1.17.0" base64 = "0.22" blake2 = "0.10" bytes = "1" @@ -125,6 +127,7 @@ ts_netstack_smoltcp = { path = "ts_netstack_smoltcp", version = "0.2.0" } ts_netstack_smoltcp_core = { path = "ts_netstack_smoltcp_core", version = "0.2.0" } ts_netstack_smoltcp_socket = { path = "ts_netstack_smoltcp_socket", version = "0.2.0" } ts_nodecapability = { path = "ts_nodecapability", version = "0.2.0" } +ts_noise = { path = "ts_noise", version = "0.2.0" } ts_overlay_router = { path = "ts_overlay_router", version = "0.2.0" } ts_packet = { path = "ts_packet", version = "0.2.0" } ts_packetfilter = { path = "ts_packetfilter", version = "0.2.0" } diff --git a/ts_noise/Cargo.toml b/ts_noise/Cargo.toml new file mode 100644 index 00000000..472f16ae --- /dev/null +++ b/ts_noise/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "ts_noise" +edition.workspace = true +license.workspace = true +publish.workspace = true +version.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +ts_keys.workspace = true +ts_packet.workspace = true +ts_time.workspace = true + +aws-lc-rs.workspace = true +aead.workspace = true +blake2.workspace = true +chacha20poly1305.workspace = true +hkdf.workspace = true +itertools.workspace = true +rand.workspace = true +tracing.workspace = true +x25519-dalek.workspace = true +zerocopy.workspace = true + + +[lints] +workspace = true diff --git a/ts_noise/src/core.rs b/ts_noise/src/core.rs new file mode 100644 index 00000000..9b7a9b8f --- /dev/null +++ b/ts_noise/src/core.rs @@ -0,0 +1,258 @@ +//! Supporting machinery for executing Noise handshakes. +//! +//! This is not a general-purpose Noise protocol library. The provided functionality is +//! sufficient to execute the two protocols that Tailscale cares about (IK and IKpsk2). +//! +//! This module only provides the primitive operations found in Noise handshakes. It is +//! the caller's responsibility to chain the primitives together correctly to produce the +//! desired handshake pattern. +//! +//! This module uses typestates to make some invalid sequences of operations compile-time +//! errors, in an effort to make protocol construction errors more obvious. This does not +//! cover all possible invalid sequences, it is still the caller's responsibility to ensure +//! that their sequence correctly reflects the desired handshake pattern. + +use aws_lc_rs::aead::{ + Aad, BoundKey, CHACHA20_POLY1305, LessSafeKey, Nonce, OpeningKey, SealingKey, UnboundKey, + nonce_sequence::{Counter64, Counter64Builder}, +}; +use blake2::{Blake2s256, Digest}; +use hkdf::SimpleHkdf; +use zerocopy::IntoBytes; + +/// Initialize a ChaCha20Poly1305 cipher with the given key. +fn must_cipher(key: [u8; 32]) -> LessSafeKey { + // Key construction only fails if the key is the wrong length for the algorithm. + let k = UnboundKey::new(&CHACHA20_POLY1305, &key).unwrap(); + LessSafeKey::new(k) +} + +/// Use HKDF to derive two 32-byte values. +fn must_hkdf2(chaining_key: &[u8; 32], key: &[u8]) -> ([u8; 32], [u8; 32]) { + let kdf = SimpleHkdf::::new(Some(chaining_key), key); + let mut expanded = [0; 64]; + // Expansion only fails if you request more bytes than the KDF can provide. This KDF can always + // provide 64 bytes. + kdf.expand(&[], &mut expanded).unwrap(); + ( + expanded[..32].try_into().unwrap(), + expanded[32..].try_into().unwrap(), + ) +} + +/// Use HKDF to derive three 32-byte values. +fn must_hkdf3(chaining_key: &[u8; 32], key: &[u8]) -> ([u8; 32], [u8; 32], [u8; 32]) { + let kdf = SimpleHkdf::::new(Some(chaining_key), key); + let mut expanded = [0; 96]; + // Expansion only fails if you request more bytes than the KDF can provide. This KDF can always + // provide 96 bytes. + kdf.expand(&[], &mut expanded).unwrap(); + ( + expanded[..32].try_into().unwrap(), + expanded[32..64].try_into().unwrap(), + expanded[64..].try_into().unwrap(), + ) +} + +/// A symmetric session. +/// +/// The sending key is eagerly bound to a nonce sequence, whereas the receiving key is a plain +/// UnboundKey due to incoming messages needing different nonce handling in different contexts. +pub struct Session { + /// The key to send data. + send: SealingKey, + /// The key to receive data. + recv: UnboundKey, + max_recv_nonce: u64, +} + +/// Base Noise handshake state. +#[derive(Clone)] +pub struct State { + hash: [u8; 32], + chaining_key: [u8; 32], +} + +impl State { + /// Initialize a new Noise handshake. + /// + /// `protocol_name` is the Noise protocol name as specified + /// in https://noiseprotocol.org/noise.html#protocol-names-and-modifiers. + pub fn new(protocol_name: &[u8]) -> State { + let init = Blake2s256::digest(protocol_name); + State { + hash: init.into(), + chaining_key: init.into(), + } + } + + /// Mix data into the handshake state. + /// + /// This is the MixHash() operation in the Noise spec. + #[inline] + pub fn mix_hash(mut self, data: &[u8]) -> Self { + self.mix_hash_gather(&[data]) + } + + /// Like mix_hash, but the data can be provided in multiple non-contiguous pieces. + pub fn mix_hash_gather(mut self, data: &[&[u8]]) -> Self { + let mut h = Blake2s256::new_with_prefix(self.hash); + for d in data { + h.update(d); + } + h.finalize_into(self.hash.as_mut_bytes().into()); + self + } + + /// Mix a public key into the handshake state. + /// + /// This should only be used to mix in the ephemeral public key in psk handshake variants, + /// in accordance with sections 9.2 and 9.3 of the Noise spec. Use [`State::mix_dh`] if + /// you're looking to MixKey the result of an X25519 operation. + /// + /// This is the MixKey() operation in the Noise spec. + pub fn mix_key(self, public: &x25519_dalek::PublicKey) -> State { + let (ck, _) = must_hkdf2(&self.chaining_key, public.as_ref()); + State { + hash: self.hash, + chaining_key: ck, + } + } + + /// Perform an X25519 operation, and mix the result into the handshake state. + /// + /// This is the MixKey(DH(private, public)) operation in the Noise spec. + pub fn mix_dh( + self, + private: &x25519_dalek::StaticSecret, + public: &x25519_dalek::PublicKey, + ) -> StateWithAEAD { + let shared = private.diffie_hellman(public); + let (ck, k) = must_hkdf2(&self.chaining_key, shared.as_ref()); + StateWithAEAD { + state: State { + hash: self.hash, + chaining_key: ck, + }, + aead: must_cipher(k), + } + } + + /// Finalize the handshake as the initiator role. + /// + /// This is the Split() operation in the Noise spec. + pub fn finish_as_initiator(self, max_nonce: u64) -> Session { + let (init_to_resp, resp_to_init) = must_hkdf2(&self.chaining_key, &[]); + + let recv = UnboundKey::new(&CHACHA20_POLY1305, &resp_to_init).unwrap(); + + let send = UnboundKey::new(&CHACHA20_POLY1305, &init_to_resp).unwrap(); + let send = SealingKey::new(send, Counter64Builder::new().limit(max_nonce).build()); + + Session { + send, + recv, + max_recv_nonce: max_nonce, + } + } + + /// Finalize the handshake as the responder role. + /// + /// This is the Split() operation in the Noise spec. + pub fn finish_as_responder(self, max_nonce: u64) -> Session { + let (init_to_resp, resp_to_init) = must_hkdf2(&self.chaining_key, &[]); + + let recv = UnboundKey::new(&CHACHA20_POLY1305, &init_to_resp).unwrap(); + + let send = UnboundKey::new(&CHACHA20_POLY1305, &resp_to_init).unwrap(); + let send = SealingKey::new(send, Counter64Builder::new().limit(max_nonce).build()); + + Session { + send, + recv, + max_recv_nonce: max_nonce, + } + } +} + +/// Noise handshake state when AEAD operations are available. +/// +/// For the handshake patterns we care about, when the handshake is in this state there are +/// only two valid ways to continue: +/// +/// - Perform an AEAD operation ([`StateWithAEAD::seal`] or [`StateWithAEAD::open`]), which +/// consumes the AEAD and returns a plain [`State`]. +/// - Mix additional key material into the handshake ([`StateWithAEAD::mix_key`] or +/// [`StateWithAEAD::mix_psk`], which returns an updated [`StateWithAEAD`]. +pub struct StateWithAEAD { + state: State, + aead: LessSafeKey, +} + +impl StateWithAEAD { + /// Perform an X25519 operation, and mix the result into the handshake state. + /// + /// This is the MixKey(DH(private, public)) operation in the Noise spec. + pub fn mix_dh( + self, + private: &x25519_dalek::StaticSecret, + public: &x25519_dalek::PublicKey, + ) -> StateWithAEAD { + self.state.mix_dh(private, public) + } + + /// Mix a pre-shared symmetric key into the handshake state. + /// + /// This is the MixKeyAndHash() operation in the Noise spec. + pub fn mix_psk(self, psk: &[u8; 32]) -> StateWithAEAD { + let (ck, h, k) = must_hkdf3(&self.state.chaining_key, psk); + StateWithAEAD { + state: State { + hash: self.state.hash, + chaining_key: ck, + } + .mix_hash(&h), + aead: must_cipher(k), + } + } + + /// Seal `cleartext` in place. + /// + /// `cleartext` overwritten with ciphertext, and the authentication tag is written to `tag`. + /// + /// # Panics + /// + /// If `tag` is not exactly 16 bytes. + pub fn seal(self, cleartext: &mut [u8], tag: &mut [u8]) -> State { + assert_eq!(tag.len(), 16, "tag must be exactly 16 bytes"); + let nonce = Nonce::assume_unique_for_key([0; 12]); + self.aead + .seal_in_place_scatter(nonce, Aad::from(&self.state.hash), cleartext, &[], tag) + .unwrap(); + self.state.mix_hash_gather(&[cleartext, tag]) + } + + /// Decrypt `ciphertext_and_tag` in place and return the cleartext portion of the slice. + /// + /// Returns None if decryption fails. + /// + /// This is the DecryptAndHash() operation in the Noise spec. + pub fn open(self, ciphertext: &mut [u8], tag: &[u8]) -> Option { + assert_eq!(tag.len(), 16, "tag must be exactly 16 bytes"); + + // On successful decryption, we have to mix the ciphertext into the handshake state. + // This pairs awkwardly with the in place crypto we're doing, where a successful decryption + // overwrites the ciphertext. + // Instead, update the handshake hash before the decryption attempt. This is okay because + // we discard the handshake state entirely on decryption failure. + let hash = self.state.hash.clone(); + let state = self.state.mix_hash_gather(&[ciphertext, tag]); + + let nonce = Nonce::assume_unique_for_key([0; 12]); + self.aead + .open_in_place_separate_tag(nonce, Aad::from(&hash), tag, ciphertext) + .ok()?; + + Some(state) + } +} diff --git a/ts_noise/src/ik.rs b/ts_noise/src/ik.rs new file mode 100644 index 00000000..57f11d4a --- /dev/null +++ b/ts_noise/src/ik.rs @@ -0,0 +1,151 @@ +use zerocopy::{FromBytes, IntoBytes}; + +use crate::{ + core::{Session, State}, + messages::{Init, Resp}, +}; + +/// A partially completed handshake, where the peer is the handshake's initiator. +pub struct ReceivedHandshake { + state: State, + peer_ephemeral_pub: x25519_dalek::PublicKey, + /// The peer's static identity. + pub peer_static_pub: [u8; 32], +} + +impl ReceivedHandshake { + /// Size of the packet expected by [`ReceivedHandshake::respond`]. + pub const RESPONSE_SIZE: usize = size_of::(); + + /// Process an incoming handshake initiation packet. + /// + /// The `prologue` and `private_key` must match those used by the initiator. + /// + /// Returns a [`ReceivedHandshake`] with information about the peer on success, or + /// None if the handshake message is invalid in some way. + pub fn new( + packet: &mut [u8], + prologue: &[u8], + private_key: impl Into<[u8; 32]>, + ) -> Option { + let packet = Init::mut_from_bytes(packet).ok()?; + + let peer_ephemeral_pub = x25519_dalek::PublicKey::from(packet.ephemeral_pub); + let my_static = x25519_dalek::StaticSecret::from(private_key.into()); + let my_static_pub = x25519_dalek::PublicKey::from(&my_static); + + let handshake = State::new(b"Noise_IK_25519_ChaChaPoly_BLAKE2s") + .mix_hash(prologue) // prologue + .mix_hash(my_static_pub.as_ref()) // <- s ... + .mix_hash(&packet.ephemeral_pub) // -> e + .mix_dh(&my_static, &peer_ephemeral_pub) // es + .open(&mut packet.static_pub, &packet.static_pub_tag)? // s + .mix_dh(&my_static, &packet.static_pub.into()) // ss + .open(&mut [], &packet.auth_tag)?; // payload + + Some(ReceivedHandshake { + state: handshake, + peer_ephemeral_pub, + peer_static_pub: packet.static_pub, + }) + } + + /// Finalize the handshake and generate a response. + /// + /// The response is written to `packet`, which must be exactly + /// [`ReceivedHandshake::RESPONSE_SIZE`] bytes. + /// + /// # Panics + /// + /// If `packet` is the wrong size. + pub fn finish(self, packet: &mut [u8], max_nonce: u64) -> Session { + assert_eq!(packet.len(), Self::RESPONSE_SIZE); + let response = Resp::mut_from_bytes(packet).unwrap(); + let my_ephemeral = x25519_dalek::StaticSecret::random(); + let my_ephemeral_pub = x25519_dalek::PublicKey::from(&my_ephemeral); + + self.state + .mix_hash(my_ephemeral_pub.as_bytes()) // <- e + .mix_dh(&my_ephemeral, &self.peer_ephemeral_pub) // ee + .mix_dh(&my_ephemeral, &self.peer_static_pub.into()) // se + .seal(&mut [], &mut response.auth_tag) // payload + .finish_as_responder(max_nonce) + } +} + +/// A partially completed handshake, where the peer is the handshake's responder. +pub struct SentHandshake { + state: State, + my_ephemeral: x25519_dalek::StaticSecret, + my_static: x25519_dalek::StaticSecret, + peer_static: x25519_dalek::PublicKey, +} + +impl SentHandshake { + /// The size of the packet expected by [`SentHandshake::new`]. + const PACKET_SIZE: usize = size_of::(); + + /// Generate an outgoing handshake initiation for the given peer identity. + /// + /// # Panics + /// + /// If `packet` is not [`SentHandshake::PACKET_SIZE`] bytes. + pub fn new( + my_static: x25519_dalek::StaticSecret, + peer_static: x25519_dalek::PublicKey, + prologue: &[u8], + packet: &mut [u8], + ) -> Self { + assert_eq!(packet.len(), Self::PACKET_SIZE); + + let pkt = Init::mut_from_bytes(packet).unwrap(); + + let ephemeral = x25519_dalek::StaticSecret::random(); + let ephemeral_pub = x25519_dalek::PublicKey::from(&ephemeral); + + pkt.ephemeral_pub = ephemeral_pub.to_bytes(); + pkt.static_pub = x25519_dalek::PublicKey::from(&my_static).to_bytes(); + + let state = State::new(b"Noise_IK_25519_ChaChaPoly_BLAKE2s") + .mix_hash(prologue) + .mix_hash(x25519_dalek::PublicKey::from(&my_static).as_ref()) + .mix_hash(pkt.ephemeral_pub.as_ref()) + .mix_dh(&ephemeral, &peer_static) + .seal(&mut pkt.static_pub, &mut pkt.static_pub_tag) + .mix_dh(&my_static, &peer_static) + .seal(&mut [], &mut pkt.auth_tag); + + SentHandshake { + state, + my_ephemeral: ephemeral, + my_static, + peer_static, + } + } + + /// Try to finalize the handshake and generate a response. + /// + /// If the response is invalid, returns `Err(self)` to allow for another finalization + /// attempt later. + pub fn try_finish( + self, + packet: &mut [u8], + psk: &[u8; 32], + max_nonce: u64, + ) -> Result { + let Ok(packet) = Resp::mut_from_bytes(packet) else { + return Err(self); + }; + + let peer_ephemeral_pub = x25519_dalek::PublicKey::from(packet.ephemeral_pub); + let state = self.state.clone(); + + Ok(state + .mix_hash(&packet.ephemeral_pub) + .mix_dh(&self.my_ephemeral, &peer_ephemeral_pub) + .mix_dh(&self.my_ephemeral, &self.peer_static) + .open(&mut [], &packet.auth_tag) + .ok_or(self)? + .finish_as_initiator(max_nonce)) + } +} diff --git a/ts_noise/src/ikpsk2.rs b/ts_noise/src/ikpsk2.rs new file mode 100644 index 00000000..002f8ea0 --- /dev/null +++ b/ts_noise/src/ikpsk2.rs @@ -0,0 +1,169 @@ +use zerocopy::{FromBytes, IntoBytes}; + +use crate::{ + core::{Session, State}, + messages::{Init, Resp}, +}; + +/// A partially completed handshake, where the peer is the handshake's initiator. +pub struct ReceivedHandshake { + state: State, + peer_ephemeral_pub: x25519_dalek::PublicKey, + /// The peer's static identity. + pub peer_static_pub: [u8; 32], +} + +impl ReceivedHandshake { + /// Size of the packet expected by [`ReceivedHandshake::respond`]. + pub const RESPONSE_SIZE: usize = size_of::(); + + /// Process an incoming handshake initiation packet. + /// + /// The `prologue` and `private_key` must match those used by the initiator. + /// + /// Returns a [`ReceivedHandshake`] with information about the peer on success, or + /// None if the handshake message is invalid in some way. + pub fn new( + packet: &mut [u8], + prologue: &[u8], + private_key: impl Into<[u8; 32]>, + ) -> Option { + let packet = Init::mut_from_bytes(packet).ok()?; + + let peer_ephemeral_pub = x25519_dalek::PublicKey::from(packet.ephemeral_pub); + let my_static = x25519_dalek::StaticSecret::from(private_key.into()); + let my_static_pub = x25519_dalek::PublicKey::from(&my_static); + + let handshake = State::new(b"Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s") + .mix_hash(prologue) // prologue + .mix_hash(my_static_pub.as_ref()) // <- s ... + .mix_hash(&packet.ephemeral_pub) // -> e + .mix_key(&peer_ephemeral_pub) // e again (extra mixing required for IKpsk variant) + .mix_dh(&my_static, &peer_ephemeral_pub) // es + .open(&mut packet.static_pub, &packet.static_pub_tag)? // s + .mix_dh(&my_static, &packet.static_pub.into()) // ss + .open(&mut [], &packet.auth_tag)?; // payload + + Some(ReceivedHandshake { + state: handshake, + peer_ephemeral_pub, + peer_static_pub: packet.static_pub, + }) + } + + /// Finalize the handshake and generate a response. + /// + /// The response is written to `packet`, which must be exactly + /// [`ReceivedHandshake::RESPONSE_SIZE`] bytes. + /// + /// # Panics + /// + /// If `packet` is the wrong size. + pub fn finish(self, packet: &mut [u8], psk: &[u8; 32], max_nonce: u64) -> Session { + assert_eq!(packet.len(), Self::RESPONSE_SIZE); + let response = Resp::mut_from_bytes(packet).unwrap(); + let my_ephemeral = x25519_dalek::StaticSecret::random(); + let my_ephemeral_pub = x25519_dalek::PublicKey::from(&my_ephemeral); + + self.state + .mix_hash(my_ephemeral_pub.as_bytes()) // <- e + .mix_key(&my_ephemeral_pub) // e again (extra mixing required for IKpsk variant) + .mix_dh(&my_ephemeral, &self.peer_ephemeral_pub) // ee + .mix_dh(&my_ephemeral, &self.peer_static_pub.into()) // se + .mix_psk(psk) // psk + .seal(&mut [], &mut response.auth_tag) // payload + .finish_as_responder(max_nonce) + } +} + +struct SentHandshakeInner { + state: State, + my_ephemeral: x25519_dalek::StaticSecret, + my_static: x25519_dalek::StaticSecret, + peer_static: x25519_dalek::PublicKey, +} + +/// A partially completed handshake, where the peer is the handshake's responder. +pub struct SentHandshake { + inner: Option>, +} + +impl SentHandshake { + /// The size of the packet expected by [`SentHandshake::new`]. + const PACKET_SIZE: usize = size_of::(); + + /// Generate an outgoing handshake initiation for the given peer identity. + /// + /// # Panics + /// + /// If `packet` is not [`SentHandshake::PACKET_SIZE`] bytes. + pub fn new( + my_static: x25519_dalek::StaticSecret, + peer_static: x25519_dalek::PublicKey, + prologue: &[u8], + packet: &mut [u8], + ) -> Self { + assert_eq!(packet.len(), Self::PACKET_SIZE); + + let pkt = Init::mut_from_bytes(packet).unwrap(); + + let ephemeral = x25519_dalek::StaticSecret::random(); + let ephemeral_pub = x25519_dalek::PublicKey::from(&ephemeral); + + pkt.ephemeral_pub = ephemeral_pub.to_bytes(); + pkt.static_pub = x25519_dalek::PublicKey::from(&my_static).to_bytes(); + + let state = State::new(b"Noise_IK_25519_ChaChaPoly_BLAKE2s") + .mix_hash(prologue) + .mix_hash(x25519_dalek::PublicKey::from(&my_static).as_ref()) + .mix_hash(pkt.ephemeral_pub.as_ref()) + .mix_key(&ephemeral_pub) + .mix_dh(&ephemeral, &peer_static) + .seal(&mut pkt.static_pub, &mut pkt.static_pub_tag) + .mix_dh(&my_static, &peer_static) + .seal(&mut [], &mut pkt.auth_tag); + + SentHandshake { + inner: Some(Box::new(SentHandshakeInner { + state, + my_ephemeral: ephemeral, + my_static, + peer_static, + })), + } + } + + /// Try to finalize the handshake and generate a response. + /// + /// If the response is invalid, returns `Err(self)` to allow for another finalization + /// attempt later. + pub fn try_finish( + &mut self, + packet: &mut [u8], + psk: &[u8; 32], + max_nonce: u64, + ) -> Option { + let Some(inner) = &self.inner else { + panic!("Attempted to finalize handshake twice"); + }; + + let Ok(packet) = Resp::mut_from_bytes(packet) else { + return None; + }; + + let peer_ephemeral_pub = x25519_dalek::PublicKey::from(packet.ephemeral_pub); + let state = inner.state.clone(); + + let ret = state + .mix_hash(&packet.ephemeral_pub) // e + .mix_key(&peer_ephemeral_pub) // e (extra mixing require by psk variant) + .mix_dh(&inner.my_ephemeral, &peer_ephemeral_pub) // ee + .mix_dh(&inner.my_ephemeral, &inner.peer_static) // se + .mix_psk(psk) // psk + .open(&mut [], &packet.auth_tag)? // payload + .finish_as_initiator(max_nonce); + + self.inner = None; + Some(ret) + } +} diff --git a/ts_noise/src/lib.rs b/ts_noise/src/lib.rs new file mode 100644 index 00000000..d06990a3 --- /dev/null +++ b/ts_noise/src/lib.rs @@ -0,0 +1,8 @@ +//! Implementation of the Noise protocol framework instantiations we require. +//! +//! For details on the Noise protocol framework, see https://noiseprotocol.org/ + +pub mod core; +pub mod ik; +mod ikpsk2; +mod messages; diff --git a/ts_noise/src/messages.rs b/ts_noise/src/messages.rs new file mode 100644 index 00000000..11c50515 --- /dev/null +++ b/ts_noise/src/messages.rs @@ -0,0 +1,17 @@ +use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, Unaligned}; + +#[repr(C)] +#[derive(FromBytes, IntoBytes, Immutable, KnownLayout, Unaligned)] +pub struct Init { + pub ephemeral_pub: [u8; 32], + pub static_pub: [u8; 32], + pub static_pub_tag: [u8; 16], + pub auth_tag: [u8; 16], +} + +#[repr(C)] +#[derive(FromBytes, IntoBytes, Immutable, KnownLayout, Unaligned)] +pub struct Resp { + pub ephemeral_pub: [u8; 32], + pub auth_tag: [u8; 16], +} diff --git a/ts_tunnel/src/handshake.rs b/ts_tunnel/src/handshake.rs index 56488d09..7ebfb9e8 100644 --- a/ts_tunnel/src/handshake.rs +++ b/ts_tunnel/src/handshake.rs @@ -74,7 +74,7 @@ fn must_hkdf3(chaining_key: &[u8; 32], key: &[u8]) -> ([u8; 32], [u8; 32], [u8; } impl HandshakeState { - fn new(responder_static: NodePublicKey) -> HandshakeState { + fn new(responder_static_pub: impl Into<[u8; 32]>) -> HandshakeState { // TODO: precompute initial hash and chaining key, unless the compiler // is clever enough to figure it out by itself? let init = Blake2s256::digest("Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s"); @@ -84,7 +84,7 @@ impl HandshakeState { cipher: None, } .mix_hash(b"WireGuard v1 zx2c4 Jason@zx2c4.com") - .mix_hash(responder_static.as_bytes()) + .mix_hash(responder_static_pub.into()) } /// Mix data into the handshake state.