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

nordisk/holger

server/lib/tests/estate_clock.rs

//! **A holger box follows the FRONT's signed time** (edda `estate-clock`,
//! `holger_server_lib::clock`), and holger's expiry checks read that time.
//!
//! Its own test binary on purpose: the estate clock is process-global (one view
//! of "now" per box), and the lib's own unit tests read it. The fronts here are
//! the real front half of the protocol (`estate_clock::peer::answer`, what
//! holger's front serves through `estate_clock::front::run`), answering with a
//! chosen skew so a box that is two hours behind can be made on a machine whose
//! clock is right. The machine's clock is never moved: these syncs run as a
//! process that does not own it, so the verified offset is owed in-process.

use std::collections::BTreeMap;
use std::io::{Read as _, Write as _};
use std::net::{SocketAddr, TcpListener};
use std::sync::Mutex;
use std::time::SystemTime;

use holger_server_lib::clock::{self, Outcome, Trigger};

static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());

const TWO_HOURS_MS: i64 = 7_200_000;

/// A front that signs with `signer`, its clock `skew_ms` ahead of this machine's.
/// `tamper`: rewrite the signed time after signing (a path attacker).
fn front(
    signer: estate_clock::peer::ssh_key::PrivateKey,
    skew_ms: i64,
    tamper: bool,
) -> SocketAddr {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    std::thread::spawn(move || {
        for stream in listener.incoming() {
            let Ok(mut stream) = stream else { continue };
            let mut request = Vec::new();
            let mut byte = [0u8; 1];
            while request.len() < estate_clock::peer::MAX_REQUEST_BYTES {
                match stream.read(&mut byte) {
                    Ok(1) => {
                        request.push(byte[0]);
                        if byte[0] == b'\n' {
                            break;
                        }
                    }
                    _ => break,
                }
            }
            let now = estate_clock::now_unix_ms() + skew_ms;
            let mut reply =
                String::from_utf8(estate_clock::peer::answer(&request, &signer, now, Ok(())))
                    .unwrap();
            if tamper {
                reply = reply.replacen(&now.to_string(), &(now + TWO_HOURS_MS).to_string(), 1);
            }
            let _ = stream.write_all(reply.as_bytes());
        }
    });
    addr
}

fn key() -> (estate_clock::peer::ssh_key::PrivateKey, String) {
    let k = estate_clock::peer::generate_key().unwrap();
    let line = k.public_key().to_openssh().unwrap();
    (k, line)
}

/// The feed a holger box is handed when holger's front offers its clock
/// (`holger-estate-front` `HolgerFront::offer`, relayed as `offer.<vm>.<key>`).
fn fed_by_front(addr: SocketAddr, key_line: &str) -> BTreeMap<String, String> {
    BTreeMap::from([
        ("offer.vm1.clock_peer".to_string(), format!("peer://{addr}")),
        ("offer.vm1.clock_key".to_string(), key_line.to_string()),
    ])
}

fn sync_from(feed: &BTreeMap<String, String>) -> clock::Report {
    clock::set_feed(feed);
    clock::sync(&clock::config_now(), Trigger::Boot, false)
}

fn estate_minus_system_ms() -> i64 {
    clock::now_unix_ms() - estate_clock::unix_ms(SystemTime::now())
}

#[test]
fn a_skewed_box_clock_is_corrected_by_the_fronts_signed_time() {
    let _g = ONE_AT_A_TIME.lock().unwrap_or_else(|p| p.into_inner());
    clock::reset();
    assert!(
        !clock::is_trusted(),
        "no answer yet: nothing stands behind the clock"
    );

    // This box is two hours BEHIND its front — the fresh-boot offset gunnar's
    // estate measured (7 199 529 ms).
    let (signer, line) = key();
    let r = sync_from(&fed_by_front(front(signer, TWO_HOURS_MS, false), &line));
    assert_eq!(
        r.outcome,
        Outcome::ApplyFailed,
        "measured, and the machine's clock (not this process's) was left alone: {r:?}"
    );
    assert!((r.offset_ms - TWO_HOURS_MS).abs() < 1_000, "{r:?}");
    assert!(r.detail.contains("holger's clock carries"), "{}", r.detail);
    assert!(
        clock::is_trusted(),
        "a verified answer from the front stands behind the clock"
    );
    let delta = estate_minus_system_ms();
    assert!(
        (delta - TWO_HOURS_MS).abs() < 1_000,
        "holger's clock reads the front's time: {delta} ms ahead of the system clock"
    );
    assert_eq!(
        estate_clock::report::last().map(|l| l.outcome),
        Some(Outcome::ApplyFailed),
        "recorded for the control plane"
    );

    // The front and the box agree again: nothing is owed.
    let (signer, line) = key();
    let r = sync_from(&fed_by_front(front(signer, 0, false), &line));
    assert_eq!(r.outcome, Outcome::AlreadyRight, "{r:?}");
    assert!(estate_minus_system_ms().abs() < 50);
    clock::reset();
}

#[test]
fn a_forged_signature_is_refused_and_moves_nothing() {
    let _g = ONE_AT_A_TIME.lock().unwrap_or_else(|p| p.into_inner());
    clock::reset();
    let (_pinned, pinned_line) = key();

    // Signed by a key that is not the front's pinned one, two hours ahead.
    let (impostor, _) = key();
    let r = sync_from(&fed_by_front(
        front(impostor, TWO_HOURS_MS, false),
        &pinned_line,
    ));
    assert_eq!(r.outcome, Outcome::NoQuorum, "{r:?}");
    let why = r.sources[0].error.clone().unwrap_or_default();
    assert!(why.contains("not the configured peer key"), "{why}");
    assert!(
        !clock::is_trusted() && clock::owed_ms() == 0,
        "a forged answer is refused, not believed"
    );
    assert!(estate_minus_system_ms().abs() < 50);

    // The right key, and the time rewritten on the path after signing.
    let (signer, line) = key();
    let r = sync_from(&fed_by_front(front(signer, 0, true), &line));
    assert_eq!(r.outcome, Outcome::NoQuorum, "{r:?}");
    let why = r.sources[0].error.clone().unwrap_or_default();
    assert!(why.contains("does not verify"), "{why}");
    assert!(!clock::is_trusted() && clock::owed_ms() == 0);

    // A medium naming one front and the door feeding another: refused by name.
    std::env::set_var(clock::CLOCK_ENV, "peer://10.13.8.99:50123");
    let (_, line) = key();
    let refused = {
        clock::set_feed(&fed_by_front("127.0.0.1:9".parse().unwrap(), &line));
        clock::config_now()
    };
    std::env::remove_var(clock::CLOCK_ENV);
    assert!(refused.unwrap_err().contains("[pairing.feed_mismatch]"));
    clock::reset();
}

/// **The expiry checks read the synced clock.** A passkey invite (24 h) minted
/// on this box is refused once the front says a day and an hour has passed —
/// though the machine's own clock still says it is fresh — and accepted again
/// when the front agrees with the machine.
#[cfg(feature = "auth-passkey")]
#[test]
fn the_expiry_checks_use_the_synced_clock() {
    use holger_server_lib::auth::AuthConfig;
    use holger_server_lib::passkey::PasskeyDoor;
    use serde_json::json;
    let _g = ONE_AT_A_TIME.lock().unwrap_or_else(|p| p.into_inner());
    clock::reset();
    let dir = tempfile::tempdir().unwrap();
    let auth = AuthConfig {
        methods: vec![holger_server_lib::auth::AuthMethodConfig::Passkey {
            rp_id: "holger.lab.internal".into(),
            rp_origin: "https://holger.lab.internal".into(),
            rp_name: "holger".into(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        }],
        ..AuthConfig::default()
    };
    let s = PasskeyDoor::open(
        dir.path(),
        &auth,
        std::sync::Arc::new(holger_server_lib::audit::MemoryAuditLog::new()),
    )
    .unwrap()
    .unwrap();
    let out = s
        .mint_invite_as(&json!({ "display": "bo" }), "admin", None, &auth)
        .unwrap();
    let secret = out["invite"].as_str().unwrap().to_owned();
    let expires = out["expires"].as_i64().unwrap();
    let system_now = estate_clock::unix_ms(SystemTime::now()) / 1_000;
    assert!(
        expires > system_now + 23 * 3_600,
        "a fresh invite by the machine's clock"
    );

    let (signer, line) = key();
    let r = sync_from(&fed_by_front(front(signer, 25 * 3_600_000, false), &line));
    assert!(clock::is_trusted(), "{r:?}");
    let refused = s.open_enrolment(&json!({ "invite": secret }), None, None, &auth, "10.0.0.1");
    assert_eq!(
        refused.map(|_| ()).unwrap_err().0,
        401,
        "expired by the estate's clock, whatever the system clock says"
    );

    let (signer, line) = key();
    let r = sync_from(&fed_by_front(front(signer, 0, false), &line));
    assert_eq!(r.outcome, Outcome::AlreadyRight, "{r:?}");
    assert!(
        s.open_enrolment(&json!({ "invite": secret }), None, None, &auth, "10.0.0.1")
            .is_ok(),
        "fresh again once the front agrees"
    );
    clock::reset();
}

#[test]
fn only_a_verified_answer_moves_the_view_and_a_failed_one_leaves_it() {
    let _g = ONE_AT_A_TIME.lock().unwrap_or_else(|p| p.into_inner());
    clock::reset();
    let mut r = clock::Report::without_sources(Trigger::Timer, Outcome::NoQuorum, "front silent");
    clock::settle(&r);
    assert!(
        !clock::is_trusted() && clock::owed_ms() == 0,
        "no answer: nothing is trusted, nothing owed"
    );
    r.outcome = Outcome::ApplyFailed;
    r.offset_ms = 7_200_000;
    clock::settle(&r);
    assert!(
        !clock::is_trusted(),
        "an ApplyFailed with no source row is no verified answer"
    );
    r.sources = vec![clock::SourceReport {
        source: "peer://10.13.8.1:50123".into(),
        offset_ms: Some(7_200_000),
        error: None,
    }];
    clock::settle(&r);
    assert_eq!((clock::is_trusted(), clock::owed_ms()), (true, 7_200_000));
    clock::settle(&clock::Report::without_sources(
        Trigger::Timer,
        Outcome::Disagree,
        "later",
    ));
    assert_eq!(
        (clock::is_trusted(), clock::owed_ms()),
        (true, 7_200_000),
        "a later failure leaves the verified view standing"
    );
    let mut stepped = r.clone();
    stepped.outcome = Outcome::Stepped;
    clock::settle(&stepped);
    assert_eq!(
        (clock::is_trusted(), clock::owed_ms()),
        (true, 0),
        "the machine's clock took it: nothing owed"
    );
    clock::reset();
}