-
-
Notifications
You must be signed in to change notification settings - Fork 395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Utilize NEW_TOKEN frames #1912
Open
gretchenfrage
wants to merge
16
commits into
quinn-rs:main
Choose a base branch
from
gretchenfrage:new-token
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,845
−274
Open
Utilize NEW_TOKEN frames #1912
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
cbb050c
Minor style changes
gretchenfrage 4cdb847
proto: Make connection internally use SideState
gretchenfrage 2ff668c
proto: Make Connection externally use SideArgs
gretchenfrage 88c5173
proto: Refactor token encoding helper functions
gretchenfrage aef8455
Adjust terminology regarding tokens
gretchenfrage 39163da
proto: Refactor TokenDecodeError
gretchenfrage 11faff9
proto: Rename RetryToken::from_bytes
gretchenfrage f78dcdb
proto: Factor out IncomingTokenState
gretchenfrage 59d6336
proto: Move validation logic into token module
gretchenfrage f1eae76
proto: Change how tokens are encrypted
gretchenfrage ace88e3
proto: Factor out NewToken frame struct
gretchenfrage 5696532
Allow server to use NEW_TOKEN frames
gretchenfrage 9b86a89
Add BloomTokenLog
gretchenfrage 421a99f
Allow client to use NEW_TOKEN frames
gretchenfrage 055bdb7
Add TokenMemoryCache
gretchenfrage 84b70fe
test(proto): Add and enhance token tests
gretchenfrage File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -6,6 +6,7 @@ Cargo.lock | |
.idea | ||
.DS_Store | ||
.vscode | ||
.zed | ||
|
||
cargo-test-* | ||
tarpaulin-report.html |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,326 @@ | ||
use std::{ | ||
collections::HashSet, | ||
f64::consts::LN_2, | ||
hash::{BuildHasher, Hasher}, | ||
mem::{size_of, swap}, | ||
sync::Mutex, | ||
}; | ||
|
||
use fastbloom::BloomFilter; | ||
use rustc_hash::FxBuildHasher; | ||
use tracing::{trace, warn}; | ||
|
||
use crate::{Duration, SystemTime, TokenLog, TokenReuseError, UNIX_EPOCH}; | ||
|
||
/// Bloom filter-based `TokenLog` | ||
/// | ||
/// Parameterizable over an approximate maximum number of bytes to allocate. Starts out by storing | ||
/// used tokens in a hash set. Once the hash set becomes too large, converts it to a bloom filter. | ||
/// This achieves a memory profile of linear growth with an upper bound. | ||
/// | ||
/// Divides time into periods based on `lifetime` and stores two filters at any given moment, for | ||
/// each of the two periods currently non-expired tokens could expire in. As such, turns over | ||
/// filters as time goes on to avoid bloom filter false positive rate increasing infinitely over | ||
/// time. | ||
pub struct BloomTokenLog(Mutex<State>); | ||
|
||
impl BloomTokenLog { | ||
/// Construct with an approximate maximum memory usage and expected number of validation token | ||
/// usages per expiration period | ||
/// | ||
/// Calculates the optimal bloom filter k number automatically. | ||
/// | ||
/// Panics if: | ||
/// - `max_bytes` < 2 | ||
pub fn new_expected_items(max_bytes: usize, expected_hits: u64) -> Self { | ||
Self::new(max_bytes, optimal_k_num(max_bytes, expected_hits)) | ||
} | ||
|
||
/// Construct with an approximate maximum memory usage and a bloom filter k number | ||
/// | ||
/// If choosing a custom k number, note that `BloomTokenLog` always maintains two filters | ||
/// between them and divides the allocation budget of `max_bytes` evenly between them. As such, | ||
/// each bloom filter will contain `max_bytes * 4` bits. | ||
/// | ||
/// Panics if: | ||
/// - `max_bytes` < 2 | ||
/// - `k_num` < 1 | ||
pub fn new(max_bytes: usize, k_num: u32) -> Self { | ||
assert!(max_bytes >= 2, "BloomTokenLog max_bytes too low"); | ||
assert!(k_num >= 1, "BloomTokenLog k_num must be at least 1"); | ||
|
||
Self(Mutex::new(State { | ||
config: FilterConfig { | ||
filter_max_bytes: max_bytes / 2, | ||
k_num, | ||
}, | ||
period_idx_1: 0, | ||
filter_1: Filter::new(), | ||
filter_2: Filter::new(), | ||
})) | ||
} | ||
} | ||
|
||
fn optimal_k_num(num_bytes: usize, expected_hits: u64) -> u32 { | ||
// be more forgiving rather than panickey here. excessively high num_bits may occur if the user | ||
// wishes it to be unbounded, so just saturate. expected_hits of 0 would cause divide-by-zero, | ||
// so just fudge it up to 1 in that case. | ||
let num_bits = (num_bytes as u64).saturating_mul(8); | ||
let expected_hits = expected_hits.max(1); | ||
(((num_bits as f64 / expected_hits as f64) * LN_2).round() as u32).max(1) | ||
} | ||
|
||
/// Lockable state of [`BloomTokenLog`] | ||
struct State { | ||
config: FilterConfig, | ||
// filter_1 covers tokens that expire in the period starting at | ||
// UNIX_EPOCH + period_idx_1 * lifetime and extending lifetime after. | ||
// filter_2 covers tokens for the next lifetime after that. | ||
period_idx_1: u128, | ||
filter_1: Filter, | ||
filter_2: Filter, | ||
} | ||
|
||
impl TokenLog for BloomTokenLog { | ||
fn check_and_insert( | ||
&self, | ||
rand: u128, | ||
issued: SystemTime, | ||
lifetime: Duration, | ||
) -> Result<(), TokenReuseError> { | ||
trace!(%rand, "check_and_insert"); | ||
let mut guard = self.0.lock().unwrap(); | ||
let state = &mut *guard; | ||
let fingerprint = rand_to_fingerprint(rand); | ||
|
||
// calculate period index for token | ||
let period_idx = (issued + lifetime) | ||
.duration_since(UNIX_EPOCH) | ||
.unwrap() | ||
.as_nanos() | ||
/ lifetime.as_nanos(); | ||
|
||
// get relevant filter | ||
let filter = if period_idx < state.period_idx_1 { | ||
// shouldn't happen unless time travels backwards or new_token_lifetime changes | ||
warn!("BloomTokenLog presented with token too far in past"); | ||
return Err(TokenReuseError); | ||
} else if period_idx == state.period_idx_1 { | ||
&mut state.filter_1 | ||
} else if period_idx == state.period_idx_1 + 1 { | ||
&mut state.filter_2 | ||
} else { | ||
// turn over filters | ||
if period_idx == state.period_idx_1 + 2 { | ||
swap(&mut state.filter_1, &mut state.filter_2); | ||
} else { | ||
state.filter_1 = Filter::new(); | ||
} | ||
state.filter_2 = Filter::new(); | ||
state.period_idx_1 = period_idx - 1; | ||
|
||
&mut state.filter_2 | ||
}; | ||
|
||
filter.check_and_insert(fingerprint, &state.config) | ||
} | ||
} | ||
|
||
/// The token's rand needs to guarantee uniqueness because of the role it plays in the encryption | ||
/// of the tokens, so it is 128 bits. But since the token log can tolerate both false positives and | ||
/// false negatives, we trim it down to 64 bits, which would still only have a small collision rate | ||
/// even at significant amounts of usage, while allowing us to store twice as many in the hash set | ||
/// variant. | ||
/// | ||
/// Token rand values are uniformly randomly generated server-side and cryptographically integrity- | ||
/// checked, so we don't need to employ secure hashing for this, we can simply truncate. | ||
fn rand_to_fingerprint(rand: u128) -> u64 { | ||
(rand & 0xffffffff) as u64 | ||
} | ||
|
||
const DEFAULT_MAX_BYTES: usize = 10 << 20; | ||
const DEFAULT_EXPECTED_HITS: u64 = 1_000_000; | ||
|
||
/// Default to 20 MiB max memory consumption and expected one million hits | ||
impl Default for BloomTokenLog { | ||
fn default() -> Self { | ||
Self::new_expected_items(DEFAULT_MAX_BYTES, DEFAULT_EXPECTED_HITS) | ||
} | ||
} | ||
|
||
/// Unchanging parameters governing [`Filter`] behavior | ||
struct FilterConfig { | ||
filter_max_bytes: usize, | ||
k_num: u32, | ||
} | ||
|
||
/// Period filter within [`State`] | ||
enum Filter { | ||
Set(IdentityHashSet), | ||
Bloom(FxBloomFilter), | ||
} | ||
|
||
impl Filter { | ||
fn new() -> Self { | ||
Self::Set(HashSet::default()) | ||
} | ||
|
||
fn check_and_insert( | ||
&mut self, | ||
fingerprint: u64, | ||
config: &FilterConfig, | ||
) -> Result<(), TokenReuseError> { | ||
match *self { | ||
Self::Set(ref mut hset) => { | ||
if !hset.insert(fingerprint) { | ||
return Err(TokenReuseError); | ||
} | ||
|
||
if hset.capacity() * size_of::<u64>() > config.filter_max_bytes { | ||
// convert to bloom | ||
let mut bloom = BloomFilter::with_num_bits(config.filter_max_bytes * 8) | ||
.hasher(FxBuildHasher) | ||
.hashes(config.k_num); | ||
for item in hset.iter() { | ||
bloom.insert(item); | ||
} | ||
*self = Self::Bloom(bloom); | ||
} | ||
} | ||
Self::Bloom(ref mut bloom) => { | ||
if bloom.insert(&fingerprint) { | ||
return Err(TokenReuseError); | ||
} | ||
} | ||
} | ||
Ok(()) | ||
} | ||
} | ||
|
||
/// Bloom filter that uses `FxHasher`s | ||
type FxBloomFilter = BloomFilter<512, FxBuildHasher>; | ||
|
||
/// `BuildHasher` of `IdentityHasher` | ||
#[derive(Default)] | ||
struct IdentityBuildHasher; | ||
|
||
impl BuildHasher for IdentityBuildHasher { | ||
type Hasher = IdentityHasher; | ||
|
||
fn build_hasher(&self) -> Self::Hasher { | ||
IdentityHasher::default() | ||
} | ||
} | ||
|
||
/// Hasher that is the identity operation--it assumes that exactly 8 bytes will be hashed, and the | ||
/// resultant hash is those bytes as a `u64` | ||
#[derive(Default)] | ||
struct IdentityHasher { | ||
data: [u8; 8], | ||
#[cfg(debug_assertions)] | ||
wrote_8_byte_slice: bool, | ||
} | ||
|
||
impl Hasher for IdentityHasher { | ||
fn write(&mut self, bytes: &[u8]) { | ||
#[cfg(debug_assertions)] | ||
{ | ||
assert!(!self.wrote_8_byte_slice); | ||
assert_eq!(bytes.len(), 8); | ||
self.wrote_8_byte_slice = true; | ||
} | ||
self.data.copy_from_slice(bytes); | ||
} | ||
|
||
fn finish(&self) -> u64 { | ||
#[cfg(debug_assertions)] | ||
assert!(self.wrote_8_byte_slice); | ||
u64::from_ne_bytes(self.data) | ||
} | ||
} | ||
|
||
/// Hash set of `u64` which are assumed to already be uniformly randomly distributed, and thus | ||
/// effectively pre-hashed | ||
type IdentityHashSet = HashSet<u64, IdentityBuildHasher>; | ||
|
||
#[cfg(test)] | ||
mod test { | ||
use super::*; | ||
use rand::prelude::*; | ||
use rand_pcg::Pcg32; | ||
|
||
fn new_rng() -> impl Rng { | ||
Pcg32::from_seed(0xdeadbeefdeadbeefdeadbeefdeadbeefu128.to_le_bytes()) | ||
} | ||
|
||
#[test] | ||
fn identity_hash_test() { | ||
let mut rng = new_rng(); | ||
let builder = IdentityBuildHasher; | ||
for _ in 0..100 { | ||
let n = rng.gen::<u64>(); | ||
let hash = builder.hash_one(n); | ||
assert_eq!(hash, n); | ||
} | ||
} | ||
|
||
#[test] | ||
fn optimal_k_num_test() { | ||
assert_eq!(optimal_k_num(10 << 20, 1_000_000), 58); | ||
assert_eq!(optimal_k_num(10 << 20, 1_000_000_000_000_000), 1); | ||
// assert that these don't panic: | ||
optimal_k_num(10 << 20, 0); | ||
optimal_k_num(usize::MAX, 1_000_000); | ||
} | ||
|
||
#[test] | ||
fn bloom_token_log_conversion() { | ||
let mut rng = new_rng(); | ||
let log = BloomTokenLog::new_expected_items(800, 200); | ||
|
||
let issued = SystemTime::now(); | ||
let lifetime = Duration::from_secs(1_000_000); | ||
|
||
for i in 0..200 { | ||
let token = rng.gen::<u128>(); | ||
let result = log.check_and_insert(token, issued, lifetime); | ||
{ | ||
let filter = &log.0.lock().unwrap().filter_2; | ||
if let Filter::Set(ref hset) = *filter { | ||
assert!(hset.capacity() * size_of::<u64>() <= 800); | ||
assert_eq!(hset.len(), i + 1); | ||
assert!(result.is_ok()); | ||
} else { | ||
assert!(i > 10, "definitely bloomed too early"); | ||
} | ||
} | ||
assert!(log.check_and_insert(token, issued, lifetime).is_err()); | ||
} | ||
} | ||
|
||
#[test] | ||
fn turn_over() { | ||
let mut rng = new_rng(); | ||
let log = BloomTokenLog::new_expected_items(800, 200); | ||
let lifetime = Duration::from_secs(1_000); | ||
let mut old = Vec::default(); | ||
let mut accepted = 0; | ||
|
||
for i in 0..200 { | ||
let token = rng.gen::<u128>(); | ||
let now = UNIX_EPOCH + lifetime * 10 + lifetime * i / 10; | ||
let issued = now - lifetime.mul_f32(rng.gen_range(0.0..3.0)); | ||
let result = log.check_and_insert(token, issued, lifetime); | ||
if result.is_ok() { | ||
accepted += 1; | ||
} | ||
old.push((token, issued)); | ||
let old_idx = rng.gen::<usize>() % old.len(); | ||
let (old_token, old_issued) = old[old_idx]; | ||
assert!(log | ||
.check_and_insert(old_token, old_issued, lifetime) | ||
.is_err()); | ||
} | ||
assert!(accepted > 0); | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is another commit with a lot of stuff going on. Suggest splitting it up in multiple commits (or PRs). For example, it seems that we could get most of the interfaces/traits in place before we actually slot in
BloomTokenLog
?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done