Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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)
    );
}