Skip to content

Commit 6bd7d6e

Browse files
committed
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 <danderson@tailscale.com> Change-Id: I690c8c138843750e0486ff9b1cf36df26a6a6964
1 parent 4df8081 commit 6bd7d6e

8 files changed

Lines changed: 847 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 19 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ members = [
2626
"ts_netstack_smoltcp_core",
2727
"ts_netstack_smoltcp_socket",
2828
"ts_nodecapability",
29+
"ts_noise",
2930
"ts_overlay_router",
3031
"ts_packet",
3132
"ts_packetfilter",
@@ -100,9 +101,10 @@ tokio-util = { version = "0.7", default-features = false }
100101
tracing = { version = "0.1", default-features = false, features = ["attributes"] }
101102
tracing-test = "0.2"
102103
url = { version = "2", default-features = false }
103-
x25519-dalek = { version = "3.0.0-pre.6", features = ["getrandom", "reusable_secrets", "static_secrets"] }
104+
x25519-dalek = { version = "3.0.0-pre.6", features = ["getrandom", "reusable_secrets", "static_secrets", "zeroize"] }
104105
yoke = { version = "0.8", default-features = false }
105106
zerocopy = { version = "0.8", features = ["derive"] }
107+
zeroize = { version = "1.8.2", features = ["zeroize_derive"] }
106108

107109
# local workspace deps
108110
tailscale = { path = ".", version = "0.2.0" }
@@ -125,6 +127,7 @@ ts_netstack_smoltcp = { path = "ts_netstack_smoltcp", version = "0.2.0" }
125127
ts_netstack_smoltcp_core = { path = "ts_netstack_smoltcp_core", version = "0.2.0" }
126128
ts_netstack_smoltcp_socket = { path = "ts_netstack_smoltcp_socket", version = "0.2.0" }
127129
ts_nodecapability = { path = "ts_nodecapability", version = "0.2.0" }
130+
ts_noise = { path = "ts_noise", version = "0.2.0" }
128131
ts_overlay_router = { path = "ts_overlay_router", version = "0.2.0" }
129132
ts_packet = { path = "ts_packet", version = "0.2.0" }
130133
ts_packetfilter = { path = "ts_packetfilter", version = "0.2.0" }

ts_noise/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "ts_noise"
3+
edition.workspace = true
4+
license.workspace = true
5+
publish.workspace = true
6+
version.workspace = true
7+
repository.workspace = true
8+
rust-version.workspace = true
9+
10+
[dependencies]
11+
aead.workspace = true
12+
blake2.workspace = true
13+
chacha20poly1305.workspace = true
14+
hkdf.workspace = true
15+
itertools.workspace = true
16+
x25519-dalek.workspace = true
17+
zerocopy.workspace = true
18+
zeroize.workspace = true
19+
20+
[dev-dependencies]
21+
hex.workspace = true
22+
23+
[lints]
24+
workspace = true

ts_noise/src/core.rs

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
//! Supporting machinery for executing Noise handshakes.
2+
//!
3+
//! This is not a general-purpose Noise protocol library. The provided functionality is
4+
//! sufficient to execute the two protocols that Tailscale cares about (IK and IKpsk2).
5+
//!
6+
//! This module only provides the primitive operations found in Noise handshakes. It is
7+
//! the caller's responsibility to chain the primitives together correctly to produce the
8+
//! desired handshake pattern.
9+
//!
10+
//! This module uses typestates to make some invalid sequences of operations compile-time
11+
//! errors, in an effort to make protocol construction errors more obvious. This does not
12+
//! cover all possible invalid sequences, it is still the caller's responsibility to ensure
13+
//! that their sequence correctly reflects the desired handshake pattern.
14+
15+
use aead::AeadInPlace;
16+
use blake2::{Blake2s256, Digest};
17+
use chacha20poly1305::{ChaCha20Poly1305, KeyInit};
18+
use hkdf::SimpleHkdf;
19+
use zerocopy::IntoBytes;
20+
use zeroize::ZeroizeOnDrop;
21+
22+
/// Initialize a ChaCha20Poly1305 cipher with the given key.
23+
fn must_cipher(key: [u8; 32]) -> ChaCha20Poly1305 {
24+
ChaCha20Poly1305::new(&key.into())
25+
}
26+
27+
/// Use HKDF to derive two 32-byte values.
28+
fn must_hkdf2(chaining_key: &[u8; 32], key: &[u8]) -> ([u8; 32], [u8; 32]) {
29+
let kdf = SimpleHkdf::<Blake2s256>::new(Some(chaining_key), key);
30+
let mut expanded = [0; 64];
31+
// Expansion only fails if you request more bytes than the KDF can provide. This KDF can always
32+
// provide 64 bytes.
33+
kdf.expand(&[], &mut expanded).unwrap();
34+
(
35+
expanded[..32].try_into().unwrap(),
36+
expanded[32..].try_into().unwrap(),
37+
)
38+
}
39+
40+
/// Use HKDF to derive three 32-byte values.
41+
fn must_hkdf3(chaining_key: &[u8; 32], key: &[u8]) -> ([u8; 32], [u8; 32], [u8; 32]) {
42+
let kdf = SimpleHkdf::<Blake2s256>::new(Some(chaining_key), key);
43+
let mut expanded = [0; 96];
44+
// Expansion only fails if you request more bytes than the KDF can provide. This KDF can always
45+
// provide 96 bytes.
46+
kdf.expand(&[], &mut expanded).unwrap();
47+
(
48+
expanded[..32].try_into().unwrap(),
49+
expanded[32..64].try_into().unwrap(),
50+
expanded[64..].try_into().unwrap(),
51+
)
52+
}
53+
54+
/// A symmetric session.
55+
pub struct Session {
56+
/// The key to send data.
57+
pub send: chacha20poly1305::Key,
58+
/// The key to receive data.
59+
pub recv: chacha20poly1305::Key,
60+
}
61+
62+
/// Base Noise handshake state.
63+
#[derive(Clone, ZeroizeOnDrop)]
64+
pub struct State {
65+
hash: [u8; 32],
66+
chaining_key: [u8; 32],
67+
}
68+
69+
impl State {
70+
/// Initialize a new Noise handshake.
71+
///
72+
/// `protocol_name` is the Noise protocol name as specified
73+
/// in https://noiseprotocol.org/noise.html#protocol-names-and-modifiers.
74+
pub fn new(protocol_name: &[u8]) -> State {
75+
let init = Blake2s256::digest(protocol_name);
76+
State {
77+
hash: init.into(),
78+
chaining_key: init.into(),
79+
}
80+
}
81+
82+
/// Mix data into the handshake state.
83+
///
84+
/// This is the MixHash() operation in the Noise spec.
85+
#[inline]
86+
pub fn mix_hash(self, data: &[u8]) -> Self {
87+
self.mix_hash_gather(&[data])
88+
}
89+
90+
/// Like mix_hash, but the data can be provided in multiple non-contiguous pieces.
91+
pub fn mix_hash_gather(mut self, data: &[&[u8]]) -> Self {
92+
let mut h = Blake2s256::new_with_prefix(self.hash);
93+
for d in data {
94+
h.update(d);
95+
}
96+
h.finalize_into(self.hash.as_mut_bytes().into());
97+
self
98+
}
99+
100+
/// Mix a public key into the handshake state.
101+
///
102+
/// This should only be used to mix in the ephemeral public key in psk handshake variants,
103+
/// in accordance with sections 9.2 and 9.3 of the Noise spec. Use [`State::mix_dh`] if
104+
/// you're looking to MixKey the result of an X25519 operation.
105+
///
106+
/// This is the MixHash(ephemeral)+MixKey(ephemeral) operations in the Noise spec.
107+
pub fn mix_hash_and_key(mut self, public: &x25519_dalek::PublicKey) -> State {
108+
self = self.mix_hash(public.as_ref());
109+
let (ck, _) = must_hkdf2(&self.chaining_key, public.as_ref());
110+
State {
111+
hash: self.hash,
112+
chaining_key: ck,
113+
}
114+
}
115+
116+
/// Perform an X25519 operation, and mix the result into the handshake state.
117+
///
118+
/// This is the MixKey(DH(private, public)) operation in the Noise spec.
119+
pub fn mix_dh(
120+
self,
121+
private: &x25519_dalek::StaticSecret,
122+
public: &x25519_dalek::PublicKey,
123+
) -> StateWithAead {
124+
let shared = private.diffie_hellman(public);
125+
let (ck, k) = must_hkdf2(&self.chaining_key, shared.as_ref());
126+
StateWithAead {
127+
state: State {
128+
hash: self.hash,
129+
chaining_key: ck,
130+
},
131+
aead: must_cipher(k),
132+
}
133+
}
134+
135+
/// Finalize the handshake as the initiator role.
136+
///
137+
/// This is the Split() operation in the Noise spec.
138+
pub fn finish_as_initiator(self) -> Session {
139+
let (send, recv) = must_hkdf2(&self.chaining_key, &[]);
140+
141+
Session {
142+
send: send.into(),
143+
recv: recv.into(),
144+
}
145+
}
146+
147+
/// Finalize the handshake as the responder role.
148+
///
149+
/// This is the Split() operation in the Noise spec.
150+
pub fn finish_as_responder(self) -> Session {
151+
let (recv, send) = must_hkdf2(&self.chaining_key, &[]);
152+
153+
Session {
154+
send: send.into(),
155+
recv: recv.into(),
156+
}
157+
}
158+
}
159+
160+
/// A pre-shared symmetric key.
161+
pub type Psk = [u8; 32];
162+
163+
/// The authentication tag of an AEAD ciphertext.
164+
pub type AeadTag = [u8; 16];
165+
166+
/// Noise handshake state when AEAD operations are available.
167+
///
168+
/// For the supported handshake patterns, when the handshake is in this state there are
169+
/// only two valid ways to continue:
170+
///
171+
/// - Perform an AEAD operation ([`StateWithAead::seal`] or [`StateWithAead::open`]), which
172+
/// consumes the AEAD and returns a plain [`State`].
173+
/// - Mix additional key material into the handshake ([`StateWithAead::mix_dh`] or
174+
/// [`StateWithAead::mix_psk`], which returns an updated [`StateWithAead`].
175+
pub struct StateWithAead {
176+
state: State,
177+
aead: ChaCha20Poly1305,
178+
}
179+
180+
impl StateWithAead {
181+
/// Perform an X25519 operation, and mix the result into the handshake state.
182+
///
183+
/// This is the MixKey(DH(private, public)) operation in the Noise spec.
184+
pub fn mix_dh(
185+
self,
186+
private: &x25519_dalek::StaticSecret,
187+
public: &x25519_dalek::PublicKey,
188+
) -> StateWithAead {
189+
self.state.mix_dh(private, public)
190+
}
191+
192+
/// Mix a pre-shared symmetric key into the handshake state.
193+
///
194+
/// This is the MixKeyAndHash() operation in the Noise spec.
195+
pub fn mix_psk(self, psk: &Psk) -> StateWithAead {
196+
let (ck, h, k) = must_hkdf3(&self.state.chaining_key, psk);
197+
StateWithAead {
198+
state: State {
199+
hash: self.state.hash,
200+
chaining_key: ck,
201+
}
202+
.mix_hash(&h),
203+
aead: must_cipher(k),
204+
}
205+
}
206+
207+
/// Seal `cleartext` in place.
208+
///
209+
/// `cleartext` overwritten with ciphertext, and the authentication tag is written to `tag`.
210+
pub fn seal(self, cleartext: &mut [u8], tag: &mut AeadTag) -> State {
211+
let nonce = [0; 12];
212+
let res = self
213+
.aead
214+
.encrypt_in_place_detached(&nonce.into(), &self.state.hash, cleartext)
215+
.unwrap();
216+
res.write_to(tag).unwrap();
217+
218+
self.state.mix_hash_gather(&[cleartext, tag])
219+
}
220+
221+
/// Decrypt `ciphertext` in place, authenticating with `tag`.
222+
///
223+
/// If successful, `ciphertext` is overwritten with cleartext. Returns `None` if decryption
224+
/// fails, implicitly terminating the handshake.
225+
///
226+
/// This is the DecryptAndHash() operation in the Noise spec.
227+
pub fn open(self, ciphertext: &mut [u8], tag: &AeadTag) -> Option<State> {
228+
// On successful decryption, we have to mix the ciphertext into the handshake state.
229+
// This pairs awkwardly with the in place crypto we're doing, where a successful decryption
230+
// overwrites the ciphertext.
231+
// Instead, update the handshake hash before the decryption attempt. This is okay because
232+
// we discard the handshake state entirely on decryption failure.
233+
let hash = self.state.hash;
234+
let state = self.state.mix_hash_gather(&[ciphertext, tag]);
235+
236+
let nonce = [0; 12];
237+
self.aead
238+
.decrypt_in_place_detached(&nonce.into(), &hash, ciphertext, tag.into())
239+
.ok()?;
240+
241+
Some(state)
242+
}
243+
}

0 commit comments

Comments
 (0)