crates/nornir-auth-sshsig/tests/replay_burst.rs
//! GUN-REPLAY-GUARD: the replay guard under a legitimate burst, a replay and a
//! flood.
//!
//! GUN-PERF (2026-10-31) measured the control plane behind Gunnar's console: a
//! 10 s burst at c=8 from the console's one key was 171 086 signed calls
//! (65 536 accepted, 105 550 refused), and every console call after it got 429
//! until the guard drained, five minutes later. The guard held 65 536 nonces
//! for 5 minutes in ONE pool, so about 218 signed calls per second, from all
//! keys together, was the whole control plane.
//!
//! What these tests hold the guard to:
//! - a burst at the measured rate, then the console's own throttle (500/s) for
//! ten minutes, is accepted call for call;
//! - every credential is refused as a replay for as long as it can be
//! presented (`issued_at ± MAX_CLOCK_SKEW_MS`), a future-dated one included
//! (0.1.1 forgot that one five minutes early: a replay hole);
//! - a key that floods is refused on its own, and the other keys keep working;
//! - memory stays bounded however many keys flood.
//!
//! The guard sees a credential's fingerprint, nonce and `issued_at`; the
//! signature is checked before it (`check`), so most of these tests hand it
//! unsigned credentials from real key fingerprints, and one drives `check`
//! with real signatures end to end.
use nornir_auth_sshsig::{
check, sign, ssh_key, Accepted, Credential, Refusal, ReplayGuard, Scheme,
DEFAULT_REPLAY_CAPACITY, MAX_CLOCK_SKEW_MS,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
struct Console;
impl Scheme for Console {
const METADATA_KEY: &'static str = "c-authorization";
const SCHEME: &'static str = "c-sshsig-v2";
const SCHEME_UNBOUND: Option<&'static str> = None;
const NAMESPACE: &'static str = "c-grpc";
const PREAMBLE: &'static str = "c-grpc-auth-v2";
const PREAMBLE_UNBOUND: &'static str = "";
type Error = Refusal;
}
const NOW: i64 = 1_790_000_000_000;
/// GUN-PERF's burst: 65 536 accepted + 105 550 refused in 10 s at c=8.
const BURST: usize = 171_086;
const BURST_MS: i64 = 10_000;
/// The console's default throttle: 500 requests per second, all users.
const CONSOLE_THROTTLE_PER_S: i64 = 500;
fn key(seed: u8) -> ssh_key::PrivateKey {
ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[seed; 32]))
}
fn fingerprint(seed: u8) -> String {
key(seed)
.public_key()
.fingerprint(ssh_key::HashAlg::Sha256)
.to_string()
}
/// A credential as the guard sees it (the signature was checked before).
fn cred(fp: &str, issued_at: i64, seq: usize) -> Credential<Console> {
Credential::new(
fp.to_owned(),
issued_at,
format!("nonce-{seq:016x}"),
Some("Admin.Health".to_owned()),
"unsigned".to_owned(),
)
}
/// The instant of call `i` of a burst of `n` spread evenly over `BURST_MS`.
fn burst_at(i: usize, n: usize) -> i64 {
NOW + (i as i64 * BURST_MS) / n as i64
}
#[test]
fn a_console_burst_then_calls_keep_working() {
let guard = ReplayGuard::default();
let console = fingerprint(1);
for i in 0..BURST {
let at = burst_at(i, BURST);
assert_eq!(
guard.accept(&cred(&console, at, i), at),
Accepted::Fresh,
"burst call {i} of {BURST} at +{} ms was refused: an honest burst must not fill the guard",
at - NOW
);
}
// Then the console at its own throttle for ten minutes: two windows, so
// the burst drains while new calls keep arriving.
let step = 1000 / CONSOLE_THROTTLE_PER_S;
let calls = (10 * 60 * CONSOLE_THROTTLE_PER_S) as usize;
for j in 0..calls {
let at = NOW + BURST_MS + j as i64 * step;
assert_eq!(
guard.accept(&cred(&console, at, BURST + j), at),
Accepted::Fresh,
"call {j} after the burst, at +{} ms, was refused",
at - NOW
);
}
// And another key, at the end of it all.
let at = NOW + BURST_MS + calls as i64 * step;
assert_eq!(
guard.accept(&cred(&fingerprint(2), at, 0), at),
Accepted::Fresh
);
}
#[test]
fn every_credential_is_refused_for_its_whole_window() {
let guard = ReplayGuard::default();
let console = fingerprint(1);
for i in 0..BURST {
let at = burst_at(i, BURST);
assert_eq!(guard.accept(&cred(&console, at, i), at), Accepted::Fresh);
}
// A sample, each replayed at the last instant it can still be presented.
for i in (0..BURST).step_by(997) {
let issued = burst_at(i, BURST);
let last = issued + MAX_CLOCK_SKEW_MS;
assert_eq!(
guard.accept(&cred(&console, issued, i), last),
Accepted::Replayed,
"call {i} was forgotten while it could still be presented"
);
}
}
/// A credential dated in the future by the whole skew (a laptop five minutes
/// ahead) is presentable until issued_at + skew = now + 2 skew. 0.1.1 held
/// every entry for one skew from when it was ACCEPTED, so this one was
/// forgotten five minutes too early and could be presented a second time.
#[test]
fn a_future_dated_credential_is_remembered_until_it_expires() {
let guard = ReplayGuard::default();
let laptop = fingerprint(3);
let ahead = cred(&laptop, NOW + MAX_CLOCK_SKEW_MS, 1);
assert_eq!(guard.accept(&ahead, NOW), Accepted::Fresh);
for at in [
NOW + MAX_CLOCK_SKEW_MS,
NOW + MAX_CLOCK_SKEW_MS + 1,
NOW + 2 * MAX_CLOCK_SKEW_MS,
] {
assert_eq!(
guard.accept(&ahead, at),
Accepted::Replayed,
"a future-dated credential was forgotten at +{} ms, inside its window",
at - NOW
);
}
// And one dated in the past by the whole skew, replayed at once.
let behind = cred(&laptop, NOW - MAX_CLOCK_SKEW_MS, 2);
assert_eq!(guard.accept(&behind, NOW), Accepted::Fresh);
assert_eq!(guard.accept(&behind, NOW), Accepted::Replayed);
}
#[test]
fn a_flooding_key_is_refused_for_itself_and_no_one_else() {
let guard = ReplayGuard::default();
let flooder = fingerprint(1);
let first = cred(&flooder, NOW, 0);
// As fast as it likes, all at one instant: refused at some point, and the
// point is bounded.
let mut seq = 0usize;
loop {
match guard.accept(&cred(&flooder, NOW, seq), NOW) {
Accepted::Fresh => seq += 1,
Accepted::Full => break,
Accepted::Replayed => panic!("nonce {seq} is new"),
}
assert!(
seq <= 4 * DEFAULT_REPLAY_CAPACITY,
"a flood was never refused"
);
}
assert_eq!(
guard.accept(&first, NOW),
Accepted::Replayed,
"a replay is a replay, not load"
);
let honest = fingerprint(2);
assert_eq!(
guard.accept(&cred(&honest, NOW, 0), NOW),
Accepted::Fresh,
"one key's flood refused another key"
);
assert_eq!(
guard.accept(&cred(&flooder, NOW, seq + 1), NOW),
Accepted::Full,
"the flooder is still refused"
);
}
#[test]
fn many_flooding_keys_leave_room_for_the_rest_and_memory_stays_bounded() {
let guard = ReplayGuard::default();
let bound = DEFAULT_REPLAY_CAPACITY + DEFAULT_REPLAY_CAPACITY / 8;
for seed in 10..18u8 {
let flooder = fingerprint(seed);
let mut seq = 0usize;
while guard.accept(&cred(&flooder, NOW, seq), NOW) == Accepted::Fresh {
seq += 1;
assert!(guard.len() <= bound, "the guard grew past {bound}");
}
}
assert!(guard.len() <= bound, "the guard grew past {bound}");
for seed in 30..40u8 {
assert_eq!(
guard.accept(&cred(&fingerprint(seed), NOW, 0), NOW),
Accepted::Fresh,
"eight flooding keys shut out key {seed}"
);
}
// Everything drains once nothing can be presented any more.
let later = NOW + MAX_CLOCK_SKEW_MS + 1_000;
assert_eq!(
guard.accept(&cred(&fingerprint(50), later, 0), later),
Accepted::Fresh
);
assert_eq!(guard.len(), 1, "expired credentials must be forgotten");
}
/// A credential issued four minutes ago can be presented for one more
/// minute, not five; holding it longer only costs room.
#[test]
fn a_past_dated_credential_is_held_only_while_it_can_be_presented() {
let guard = ReplayGuard::new(1);
let fp = fingerprint(4);
let old = cred(&fp, NOW - 4 * 60_000, 0);
assert_eq!(guard.accept(&old, NOW), Accepted::Fresh);
assert_eq!(guard.accept(&cred(&fp, NOW, 1), NOW), Accepted::Full);
let after = NOW + 60_000 + 1_000;
assert_eq!(
guard.accept(&cred(&fp, after, 2), after),
Accepted::Fresh,
"a credential that can no longer be presented still held its room"
);
}
/// The server's clock steps back an hour and then forward a day: nothing
/// still presentable is forgotten on the way.
#[test]
fn a_clock_step_never_forgets_a_presentable_credential() {
let guard = ReplayGuard::default();
let fp = fingerprint(5);
let held = cred(&fp, NOW, 0);
assert_eq!(guard.accept(&held, NOW), Accepted::Fresh);
let back = NOW - 3_600_000;
assert_eq!(guard.accept(&cred(&fp, back, 1), back), Accepted::Fresh);
assert_eq!(
guard.accept(&held, NOW + MAX_CLOCK_SKEW_MS),
Accepted::Replayed
);
let day = NOW + 86_400_000;
assert_eq!(guard.accept(&cred(&fp, day, 2), day), Accepted::Fresh);
assert_eq!(guard.len(), 1, "a day later only the new one is held");
}
/// End to end with real signatures: one second of the console at its
/// throttle through `check`, then every call replayed.
#[test]
fn signed_calls_through_check_are_single_use() {
let guard = ReplayGuard::default();
let k = key(6);
let line = k.public_key().to_openssh().unwrap();
let fp = fingerprint(6);
let keys = |f: &str| (f == fp).then(|| line.clone());
let path = "/console.v1.Admin/Health";
let n = CONSOLE_THROTTLE_PER_S as usize;
let headers: Vec<(i64, String)> = (0..n)
.map(|i| {
let at = NOW + i as i64 * 2;
let c =
sign::<Console>(&k, at, &format!("nonce-{i:016x}"), Some("Admin.Health")).unwrap();
(at, c.to_header_value())
})
.collect();
for (at, header) in &headers {
assert_eq!(
check::<Console>(Some(header), path, *at, keys, &guard),
Ok(fp.clone())
);
}
for (at, header) in &headers {
assert_eq!(
check::<Console>(Some(header), path, at + MAX_CLOCK_SKEW_MS, keys, &guard),
Err(Refusal::Replayed)
);
}
}
/// `with_limits` sets both caps, and `held_for` names what one key holds.
#[test]
fn with_limits_caps_one_key_and_counts_it() {
let guard = ReplayGuard::with_limits(64, 4);
let (a, b) = (fingerprint(7), fingerprint(8));
for seq in 0..4 {
assert_eq!(guard.accept(&cred(&a, NOW, seq), NOW), Accepted::Fresh);
}
assert_eq!(guard.accept(&cred(&a, NOW, 4), NOW), Accepted::Full);
assert_eq!(guard.held_for(&a), 4);
assert_eq!(guard.accept(&cred(&b, NOW, 0), NOW), Accepted::Fresh);
assert_eq!(guard.held_for(&b), 1);
let later = NOW + MAX_CLOCK_SKEW_MS + 1_000;
assert_eq!(guard.accept(&cred(&a, later, 5), later), Accepted::Fresh);
assert_eq!(
(guard.held_for(&a), guard.held_for(&b), guard.len()),
(1, 0, 1)
);
}