-
Notifications
You must be signed in to change notification settings - Fork 55
Expand file tree
/
Copy pathconnection_cookie.rs
More file actions
388 lines (324 loc) · 16.5 KB
/
Copy pathconnection_cookie.rs
File metadata and controls
388 lines (324 loc) · 16.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
//! Module for Generating and Verifying Connection IDs (Cookies) in the UDP Tracker Protocol
//!
//! **Overview:**
//!
//! In the `BitTorrent` UDP tracker protocol, clients initiate communication by obtaining a connection ID from the server. This connection ID serves as a safeguard against IP spoofing and replay attacks, ensuring that only legitimate clients can interact with the tracker.
//!
//! To maintain a stateless server architecture, this module implements a method for generating and verifying connection IDs based on the client's fingerprint (typically derived from the client's IP address) and the time of issuance, without storing state on the server.
//!
//! The connection ID is an encrypted, opaque cookie held by the client. Since the same server that generates the cookie also validates it, endianness is not a concern.
//!
//! **Connection ID Generation Algorithm:**
//!
//! 1. **Issue Time (`issue_at`):**
//! - Obtain a 64-bit floating-point number (`f64`), this number should be a normal number.
//!
//! 2. **Fingerprint:**
//! - Use an 8-byte fingerprint unique to the client (e.g., derived from the client's IP address).
//!
//! 3. **Assemble Cookie Value:**
//! - Interpret the bytes of `issue_at` as a 64-bit integer (`i64`) without altering the bit pattern.
//! - Similarly, interpret the fingerprint bytes as an `i64`.
//! - Compute the cookie value:
//! ```rust,ignore
//! let cookie_value = issue_at_i64.wrapping_add(fingerprint_i64);
//! ```
//! - *Note:* Wrapping addition handles potential integer overflows gracefully.
//!
//! 4. **Encrypt Cookie Value:**
//! - Encrypt `cookie_value` using a symmetric block cipher obtained from `Current::get_cipher()`.
//! - The encrypted `cookie_value` becomes the connection ID sent to the client.
//!
//! **Connection ID Verification Algorithm:**
//!
//! When a client sends a request with a connection ID, the server verifies it using the following steps:
//!
//! 1. **Decrypt Connection ID:**
//! - Decrypt the received connection ID using the same cipher to retrieve `cookie_value`.
//! - *Important:* The decryption is non-authenticated, meaning it does not verify the integrity or authenticity of the ciphertext. The decrypted `cookie_value` can be any byte sequence, including manipulated data.
//!
//! 2. **Recover Issue Time:**
//! - Interpret the fingerprint bytes as `i64`.
//! - Compute the issue time:
//! ```rust,ignore
//! let issue_at_i64 = cookie_value.wrapping_sub(fingerprint_i64);
//! ```
//! - *Note:* Wrapping subtraction handles potential integer underflows gracefully.
//! - Reinterpret `issue_at_i64` bytes as an `f64` to get `issue_time`.
//!
//! 3. **Validate Issue Time:**
//! - **Handling Arbitrary `issue_time` Values:**
//! - Since the decrypted `cookie_value` may be arbitrary, `issue_time` can be any `f64` value, including special values like `NaN`, positive or negative infinity, and subnormal numbers.
//! - **Validation Steps:**
//! - **Step 1:** Check if `issue_time` is finite using `issue_time.is_finite()`.
//! - If `issue_time` is `NaN` or infinite, it is considered invalid.
//! - **Step 2:** If `issue_time` is finite, perform range checks:
//! - Verify that `min <= issue_time <= max`.
//! - If `issue_time` passes these checks, accept the connection ID; otherwise, reject it with an appropriate error.
//!
//! **Security Considerations:**
//!
//! - **Non-Authenticated Encryption:**
//! - Due to protocol constraints (an 8-byte connection ID), using an authenticated encryption algorithm is not feasible.
//! - As a result, attackers might attempt to forge or manipulate connection IDs.
//! - However, the probability of an arbitrary 64-bit value decrypting to a valid `issue_time` within the acceptable range is extremely low, effectively serving as a form of authentication.
//!
//! - **Fingerprint is NOT client authentication:**
//! - The fingerprint is mixed into the cookie via simple integer `wrapping_add` / `wrapping_sub`, **not** via a cryptographic MAC.
//! - A cookie made for fingerprint A can, by coincidence, pass validation when verified with fingerprint B if the arithmetic delta lands the recovered `issue_time` within the valid range.
//! - This is because `wrapping_sub(fingerprint_b)` produces an `i64` that, when reinterpreted as `f64`, still satisfies `is_normal()` and falls inside `valid_range`.
//! - The fingerprint mixing raises the bar against naive replay (an attacker cannot trivially reuse a cookie from a different client address without guessing the offset), but it is **not** a substitute for client identity authentication.
//!
//! - **Scope of the fingerprint:**
//! - The `gen_remote_fingerprint()` function (used in production) hashes the full [`SocketAddr`] (IP + port) via [`DefaultHasher`].
//! - Two connections from the same IP on different ports get different fingerprints.
//! - Two connections from different IPs on the same port likewise.
//! - The unit tests with small integer fingerprints (e.g. `1_000_000` vs `2_000_000`) may coincidentally pass the range check with a wrong fingerprint — this is expected behaviour given the arithmetic mixing, not a bug. The realistic-address test (using `gen_remote_fingerprint`) is the authoritative verification.
//!
//! - **Probability of Successful Attack:**
//! - For a uniformly random 64-bit ciphertext, approximately `2^42` out of `2^64` possible values represent normal `f64` numbers (the rest are NaN, infinity, or subnormal).
//! - With a typical 120-second cookie lifetime, the fraction of those that land within the valid window is roughly `window_duration / f64_range ≈ 120s / ~10^21 years`.
//! - Combined probability per guess: ~1 in 4 million for a 120s window.
//! - This is low enough for practical purposes, but it is **probabilistic**, not cryptographic.
//!
//! - **Handling Special `f64` Values:**
//! - By checking `issue_time.is_finite()`, the implementation excludes `NaN` and infinite values, ensuring that only valid, finite timestamps are considered.
//!
//! - **Replay protection is time-based, not connection-bound:**
//! - A valid cookie remains valid for its entire lifetime regardless of how many times it is used (until it expires).
//! - The same cookie can be reused across multiple announce/scrape requests within the same session.
//! - There is no server-side session state or nonce tracking.
//!
//! **Key Points:**
//!
//! - The server maintains a stateless design, reducing resource consumption and complexity.
//! - Wrapping arithmetic ensures that the addition and subtraction of `i64` values are safe from overflow or underflow issues.
//! - The validation process is robust against malformed or malicious connection IDs due to stringent checks on the deserialized `issue_time`.
//! - The module leverages existing cryptographic primitives while acknowledging and addressing the limitations imposed by the protocol's specifications.
//!
use cookie_builder::{assemble, decode, disassemble, encode};
use thiserror::Error;
use torrust_tracker_udp_protocol::ConnectionId as Cookie;
use tracing::instrument;
use zerocopy::IntoBytes as _;
use crate::crypto::keys::CipherArrayBlowfish;
/// Error returned when there was an error with the connection cookie.
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ConnectionCookieError {
#[error("cookie value is not normal: {not_normal_value}")]
ValueNotNormal { not_normal_value: f64 },
#[error("cookie value is expired: {expired_value}, expected > {min_value}")]
ValueExpired { expired_value: f64, min_value: f64 },
#[error("cookie value is from future: {future_value}, expected < {max_value}")]
ValueFromFuture { future_value: f64, max_value: f64 },
}
/// Generates a new connection cookie.
///
/// # Errors
///
/// It would error if the supplied `issue_at` value is a zero, infinite, subnormal, or NaN.
///
/// # Panics
///
/// It would panic if the cookie is not exactly 8 bytes is size.
///
#[instrument(err)]
pub fn make(fingerprint: u64, issue_at: f64) -> Result<Cookie, ConnectionCookieError> {
if !issue_at.is_normal() {
return Err(ConnectionCookieError::ValueNotNormal {
not_normal_value: issue_at,
});
}
let cookie = assemble(fingerprint, issue_at);
let cookie = encode(cookie);
// using `read_from_bytes` as the array may be not correctly aligned
Ok(zerocopy::FromBytes::read_from_bytes(cookie.as_slice()).expect("it should be the same size"))
}
use std::hash::{DefaultHasher, Hash, Hasher};
use std::net::SocketAddr;
use std::ops::Range;
/// Checks if the supplied `connection_cookie` is valid.
///
/// # Errors
///
/// It would error if the connection cookie is somehow invalid or expired.
///
/// # Panics
///
/// It would panic if the range start is not smaller than it's end.
#[instrument]
pub fn check(cookie: &Cookie, fingerprint: u64, valid_range: Range<f64>) -> Result<f64, ConnectionCookieError> {
assert!(valid_range.start <= valid_range.end, "range start is larger than range end");
let cookie_bytes = CipherArrayBlowfish::try_from(cookie.0.as_bytes()).expect("it should be the same size");
let cookie_bytes = decode(cookie_bytes);
let issue_time = disassemble(fingerprint, cookie_bytes);
if !issue_time.is_normal() {
return Err(ConnectionCookieError::ValueNotNormal {
not_normal_value: issue_time,
});
}
if issue_time < valid_range.start {
return Err(ConnectionCookieError::ValueExpired {
expired_value: issue_time,
min_value: valid_range.start,
});
}
if issue_time > valid_range.end {
return Err(ConnectionCookieError::ValueFromFuture {
future_value: issue_time,
max_value: valid_range.end,
});
}
Ok(issue_time)
}
#[must_use]
pub fn gen_remote_fingerprint(remote_addr: &SocketAddr) -> u64 {
let mut state = DefaultHasher::new();
remote_addr.hash(&mut state);
state.finish()
}
mod cookie_builder {
use cipher::{BlockCipherDecrypt, BlockCipherEncrypt};
use tracing::instrument;
use zerocopy::{IntoBytes as _, NativeEndian, byteorder};
pub type CookiePlainText = CipherArrayBlowfish;
pub type CookieCipherText = CipherArrayBlowfish;
use crate::crypto::keys::{CipherArrayBlowfish, Current, Keeper};
#[instrument()]
pub(super) fn assemble(fingerprint: u64, issue_at: f64) -> CookiePlainText {
let issue_at: byteorder::I64<NativeEndian> =
*zerocopy::FromBytes::ref_from_bytes(&issue_at.to_ne_bytes()).expect("it should be aligned");
let fingerprint: byteorder::I64<NativeEndian> =
*zerocopy::FromBytes::ref_from_bytes(&fingerprint.to_ne_bytes()).expect("it should be aligned");
let cookie = issue_at.get().wrapping_add(fingerprint.get());
let cookie: byteorder::I64<NativeEndian> =
*zerocopy::FromBytes::ref_from_bytes(&cookie.to_ne_bytes()).expect("it should be aligned");
CipherArrayBlowfish::try_from(cookie.as_bytes()).expect("it should be the same size")
}
#[instrument()]
pub(super) fn disassemble(fingerprint: u64, cookie: CookiePlainText) -> f64 {
let fingerprint: byteorder::I64<NativeEndian> =
*zerocopy::FromBytes::ref_from_bytes(&fingerprint.to_ne_bytes()).expect("it should be aligned");
// the array may be not aligned, so we read instead of reference.
let cookie: byteorder::I64<NativeEndian> =
zerocopy::FromBytes::read_from_bytes(cookie.as_bytes()).expect("it should be the same size");
let issue_time_bytes = cookie.get().wrapping_sub(fingerprint.get()).to_ne_bytes();
let issue_time: byteorder::F64<NativeEndian> =
*zerocopy::FromBytes::ref_from_bytes(&issue_time_bytes).expect("it should be aligned");
issue_time.get()
}
#[instrument()]
pub(super) fn encode(mut cookie: CookiePlainText) -> CookieCipherText {
let cipher = Current::get_cipher_blowfish();
cipher.encrypt_block(&mut cookie);
cookie
}
#[instrument()]
pub(super) fn decode(mut cookie: CookieCipherText) -> CookiePlainText {
let cipher = Current::get_cipher_blowfish();
cipher.decrypt_block(&mut cookie);
cookie
}
}
#[cfg(test)]
mod tests {
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use super::*;
#[test]
fn it_should_make_a_connection_cookie() {
let fingerprint = 1_000_000;
let issue_at = 1000.0;
let cookie = make(fingerprint, issue_at).unwrap().0.get();
// Expected connection ID derived through experimentation
assert_eq!(cookie.to_le_bytes(), [10, 130, 175, 211, 244, 253, 230, 210]);
}
#[test]
fn it_should_create_same_cookie_for_same_input() {
let fingerprint = 1_000_000;
let issue_at = 1000.0;
let cookie1 = make(fingerprint, issue_at).unwrap();
let cookie2 = make(fingerprint, issue_at).unwrap();
assert_eq!(cookie1, cookie2);
}
#[test]
fn it_should_create_different_cookies_for_different_fingerprints() {
let fingerprint1 = 1_000_000;
let fingerprint2 = 2_000_000;
let issue_at = 1000.0;
let cookie1 = make(fingerprint1, issue_at).unwrap();
let cookie2 = make(fingerprint2, issue_at).unwrap();
assert_ne!(cookie1, cookie2);
}
#[test]
fn it_should_create_different_cookies_for_different_issue_times() {
let fingerprint = 1_000_000;
let issue_at1 = 1000.0;
let issue_at2 = 2000.0;
let cookie1 = make(fingerprint, issue_at1).unwrap();
let cookie2 = make(fingerprint, issue_at2).unwrap();
assert_ne!(cookie1, cookie2);
}
#[test]
fn it_should_validate_a_valid_cookie() {
let fingerprint = 1_000_000;
let issue_at = 1_000_000_000_f64;
let cookie = make(fingerprint, issue_at).unwrap();
let min = issue_at - 10.0;
let max = issue_at + 10.0;
let result = check(&cookie, fingerprint, min..max).unwrap();
// we should have exactly the same bytes returned
assert_eq!(result.to_ne_bytes(), issue_at.to_ne_bytes());
}
#[test]
fn it_should_reject_an_expired_cookie() {
let fingerprint = 1_000_000;
let issue_at = 1_000_000_000_f64;
let cookie = make(fingerprint, issue_at).unwrap();
let min = issue_at + 10.0;
let max = issue_at + 20.0;
let result = check(&cookie, fingerprint, min..max).unwrap_err();
match result {
ConnectionCookieError::ValueExpired { .. } => {} // Expected error
_ => panic!("Expected ConnectionIdExpired error"),
}
}
#[test]
fn it_should_reject_a_cookie_from_the_future() {
let fingerprint = 1_000_000;
let issue_at = 1_000_000_000_f64;
let cookie = make(fingerprint, issue_at).unwrap();
let min = issue_at - 20.0;
let max = issue_at - 10.0;
let result = check(&cookie, fingerprint, min..max).unwrap_err();
match result {
ConnectionCookieError::ValueFromFuture { .. } => {} // Expected error
_ => panic!("Expected ConnectionIdFromFuture error"),
}
}
#[test]
fn it_should_reject_a_cookie_with_a_wrong_fingerprint_realistic_addresses() {
// A cookie obtained from one client address should not validate
// when presented from a different client address.
//
// This relies on the fingerprint (which covers the full SocketAddr)
// being different for each address. Because the fingerprint is mixed
// via wrapping arithmetic (not a MAC), the test must use realistic
// fingerprints produced by gen_remote_fingerprint() — small integer
// fingerprints may coincidentally pass (see module-level docs under
// "Fingerprint is NOT client authentication").
let issue_at = 1_000_000_000_f64;
let client_addr_a = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 4000);
let client_addr_b = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)), 4000);
let fingerprint_a = gen_remote_fingerprint(&client_addr_a);
let fingerprint_b = gen_remote_fingerprint(&client_addr_b);
assert_ne!(fingerprint_a, fingerprint_b, "test requires different fingerprints");
let cookie = make(fingerprint_a, issue_at).unwrap();
let min = issue_at - 120.0;
let max = issue_at + 120.0;
let result = check(&cookie, fingerprint_b, min..max);
assert!(
result.is_err(),
"cookie issued for client A should be invalid when verified with client B's fingerprint"
);
}
}