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