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

nordisk/edda

crates/estate-clock/src/peer.rs

//! **Signed time from a peer** — how the appliance follows the front without
//! the internet, DNS, or a clock of its own.
//!
//! # Why not the control plane
//!
//! The front already talks to the appliance over authenticated gRPC. That path
//! cannot carry time: its gate refuses any credential more than
//! `MAX_CLOCK_SKEW_MS` from the server's clock, which is exactly the situation
//! in which a sync is needed. A time source that only works when the clocks
//! already agree is not a time source.
//!
//! # The protocol (TCP, one exchange, then close)
//!
//! ```text
//!   appliance → front   ESTATE-TIME/1 <nonce>\n                  32 random bytes, hex
//!   front → appliance   ESTATE-TIME/1 <nonce> <unix_ms>\n
//!                       -----BEGIN SSH SIGNATURE-----            SSHSIG, ed25519,
//!                       …                                        namespace NAMESPACE, over
//!                       -----END SSH SIGNATURE-----              "estate-time-v1\n<nonce>\n<unix_ms>\n"
//! ```
//!
//! **Authenticity without a clock.** Verifying an ed25519 signature needs no
//! notion of "now" — no certificate validity window, nothing a wrong clock can
//! break. That is the property TLS lacks here (rustls checks certificate dates
//! against the very clock being corrected) and the reason the front signs
//! rather than serving HTTPS.
//!
//! **The nonce is what makes an old answer useless.** A signed timestamp alone
//! could be recorded and replayed later, winding a clock *back* — and a clock
//! wound back re-opens the replay window of every credential issued since. The
//! appliance picks the nonce, fresh from the OS RNG, per exchange; a reply is
//! accepted only if it names that nonce, so a recorded reply names a nonce
//! nobody is waiting for. The round trip is bounded ([`MAX_ROUND_TRIP`]) so a
//! reply cannot be *held* either: the offset is measured against the midpoint of
//! this exchange and is wrong by at most half of it.
//!
//! **The namespace is not the credential's.** gunnar-grpc signs credentials in
//! the `gunnar-grpc` SSHSIG namespace; this is [`NAMESPACE`]. A signature made
//! here can never verify as a credential and a credential can never verify as
//! time, whatever key signed it.
//!
//! # Gunnar's dialect during its move (lane G3b, 2026-10-21)
//!
//! gunnar-clock speaks the same exchange as `GUNNAR-TIME/1`, signed in
//! `gunnar-time-v1@gunnar.rs` over `"gunnar-time-v1\n<nonce>\n<unix_ms>\n"`
//! ([`Dialect::Gunnar`]). While Gunnar's estates move onto this crate (front
//! first, then the twin, then the main) the front and the boxes speak different
//! dialects, so this side accepts and answers both: [`answer`] replies in the
//! request's dialect, and [`query`] asks in `ESTATE-TIME/1` and asks once more
//! in `GUNNAR-TIME/1` when the front answers in it. Dropped after the move.
//!
//! # Which key: a dedicated time key, named in boot configuration
//!
//! Not the console's key. `/opt/gunnar-ui/console_key` is enrolled as a
//! principal with real grants on the forge; the process holding it here would
//! be a network listener parsing untrusted bytes, and a compromise of it would
//! be a compromise of the console's authority. A key whose *only* power is to
//! say what time it is costs one file and removes that coupling.
//!
//! The appliance learns the public half from `ESTATE_CLOCK_KEY` on its kernel
//! command line, not from its key table. The key table is writable by any
//! gunnar-admin over the network, and "any enrolled key may set the clock" would
//! let every enrolled user wind it; a dedicated role in the table would be new
//! ACL surface for one fact. The command line is baked by the ISO build, read
//! before any store opens, and changed only by a re-image — the same channel the
//! appliance's network and bind configuration already use.

use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::time::{Duration, Instant, SystemTime};

use crate::decide::Sample;

/// The `ssh-key` this crate signs and verifies with (=0.7.0-rc.11), re-exported:
/// a consumer whose own graph still holds ssh-key 0.6 names the time key's type
/// as `estate_clock::peer::ssh_key::PrivateKey`, never a second copy that does
/// not unify (holger's tests, lane G3b).
pub use ssh_key;

/// The first token of both lines.
pub const MAGIC: &str = "ESTATE-TIME/1";
/// The SSHSIG namespace. Distinct from every credential namespace — see the
/// module note.
pub const NAMESPACE: &str = "estate-time-v1@nordisk";
/// The first line of the signed message.
pub const PREAMBLE: &str = "estate-time-v1";
/// Random bytes in a nonce (hex on the wire: twice this many characters).
pub const NONCE_BYTES: usize = 32;
/// A request is one short line; anything longer is not a request.
pub const MAX_REQUEST_BYTES: usize = MAGIC.len() + 1 + NONCE_BYTES * 2 + 1;
/// A reply is a line and an armoured ed25519 SSHSIG (~300 bytes). The bound is
/// what keeps a hostile "peer" from feeding the appliance's PID 1 a gigabyte.
pub const MAX_REPLY_BYTES: usize = 4096;
/// Connect, write and read together, per exchange.
pub const PEER_TIMEOUT: Duration = Duration::from_secs(4);
/// The longest exchange whose answer is used. The offset is wrong by at most
/// half of the round trip; five seconds bounds that to 2.5 s against a
/// 300 s credential window.
pub const MAX_ROUND_TRIP: Duration = Duration::from_secs(5);

/// **Gunnar's dialect, GUNNAR-TIME/1** (gunnar-clock, Gunnar e4234a34
/// `crates/gunnar-clock/src/peer.rs`): the same exchange under Gunnar's names.
/// Spoken while Gunnar moves from gunnar-clock onto this crate: gunnar.rs and
/// test2 update the front first, then the twin, then the main, so for a while
/// the front and the boxes speak different dialects (lane G3b, 2026-10-21).
/// Dropped once no Gunnar estate runs gunnar-clock.
pub const GUNNAR_MAGIC: &str = "GUNNAR-TIME/1";
/// Gunnar's SSHSIG namespace.
pub const GUNNAR_NAMESPACE: &str = "gunnar-time-v1@gunnar.rs";
/// The first line of Gunnar's signed message.
pub const GUNNAR_PREAMBLE: &str = "gunnar-time-v1";

/// Which names an exchange is spoken in. The bytes are the same exchange; only
/// the magic, the namespace and the preamble differ.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dialect {
    /// `ESTATE-TIME/1`, `estate-time-v1@nordisk` — this crate's own.
    Estate,
    /// `GUNNAR-TIME/1`, `gunnar-time-v1@gunnar.rs` — gunnar-clock's.
    Gunnar,
}

impl Dialect {
    /// Both, this crate's own first.
    pub const ALL: [Dialect; 2] = [Dialect::Estate, Dialect::Gunnar];

    /// The first token of both lines.
    pub fn magic(self) -> &'static str {
        match self {
            Dialect::Estate => MAGIC,
            Dialect::Gunnar => GUNNAR_MAGIC,
        }
    }

    /// The SSHSIG namespace.
    pub fn namespace(self) -> &'static str {
        match self {
            Dialect::Estate => NAMESPACE,
            Dialect::Gunnar => GUNNAR_NAMESPACE,
        }
    }

    /// The first line of the signed message.
    pub fn preamble(self) -> &'static str {
        match self {
            Dialect::Estate => PREAMBLE,
            Dialect::Gunnar => GUNNAR_PREAMBLE,
        }
    }

    /// The dialect a request or reply line starts in (`<magic> `), if any.
    pub fn of(bytes: &[u8]) -> Option<Dialect> {
        Dialect::ALL.into_iter().find(|d| bytes.strip_prefix(d.magic().as_bytes()).is_some_and(|rest| rest.first() == Some(&b' ')))
    }
}

/// The bytes the signature covers, in `dialect`.
pub fn signed_message_in(dialect: Dialect, nonce: &str, unix_ms: i64) -> String {
    format!("{}\n{nonce}\n{unix_ms}\n", dialect.preamble())
}

/// The request line for a nonce, in `dialect`.
pub fn request_line_in(dialect: Dialect, nonce: &str) -> String {
    format!("{} {nonce}\n", dialect.magic())
}

/// The dialect and the nonce of a request in either dialect, or why it is not one.
pub fn parse_request_in(bytes: &[u8]) -> Result<(Dialect, String), String> {
    let text = std::str::from_utf8(bytes).map_err(|_| "the request is not UTF-8".to_owned())?;
    let line = text
        .strip_suffix('\n')
        .ok_or_else(|| "the request is not one terminated line".to_owned())?;
    let (dialect, nonce) = Dialect::ALL
        .into_iter()
        .find_map(|d| line.strip_prefix(d.magic()).and_then(|rest| rest.strip_prefix(' ')).map(|nonce| (d, nonce)))
        .ok_or_else(|| format!("the request does not start with {MAGIC} or {GUNNAR_MAGIC}"))?;
    if !is_nonce(nonce) {
        return Err(format!(
            "the nonce is not {} lowercase hex characters",
            NONCE_BYTES * 2
        ));
    }
    Ok((dialect, nonce.to_owned()))
}

/// [`verify_reply`] for an exchange asked in `dialect`.
pub fn verify_reply_in(dialect: Dialect, reply: &[u8], key: &PeerKey, expected_nonce: &str) -> Result<i64, String> {
    let (magic, namespace) = (dialect.magic(), dialect.namespace());
    if reply.len() > MAX_REPLY_BYTES {
        return Err(format!("the reply is longer than {MAX_REPLY_BYTES} bytes"));
    }
    let text = std::str::from_utf8(reply).map_err(|_| "the reply is not UTF-8".to_owned())?;
    let (first, armor) = text
        .split_once('\n')
        .ok_or_else(|| "the reply has no header line".to_owned())?;
    let rest = first
        .strip_prefix(magic)
        .and_then(|rest| rest.strip_prefix(' '))
        .ok_or_else(|| format!("the reply does not start with {magic}"))?;
    if let Some(why) = rest.strip_prefix("REFUSED ") {
        return Err(format!("the peer refused to sign: {why}"));
    }
    let (nonce, unix_ms) = rest
        .split_once(' ')
        .ok_or_else(|| "the reply header is not `<nonce> <unix_ms>`".to_owned())?;
    // The replay check, before any cryptography: a recorded reply names a nonce
    // nobody is waiting for any more.
    if nonce != expected_nonce {
        return Err(
            "the reply names a nonce this exchange did not send — a replayed or misrouted \
             answer, refused"
                .to_owned(),
        );
    }
    let unix_ms: i64 = unix_ms
        .parse()
        .map_err(|_| "the reply's time is not an integer millisecond".to_owned())?;
    let signature = ssh_key::SshSig::from_pem(armor.trim_end())
        .map_err(|err| format!("the reply's signature does not parse: {err}"))?;
    key.0
        .verify(
            namespace,
            signed_message_in(dialect, expected_nonce, unix_ms).as_bytes(),
            &signature,
        )
        .map_err(|err| match err {
            ssh_key::Error::PublicKey => format!(
                "the reply is signed by a key that is not the configured peer key ({})",
                key.fingerprint()
            ),
            ssh_key::Error::Namespace => {
                format!("the reply is signed in a namespace that is not {namespace}")
            }
            other => format!("the reply's signature does not verify: {other}"),
        })?;
    Ok(unix_ms)
}

/// [`query`] in one dialect only.
pub fn query_in(dialect: Dialect, addr: SocketAddr, key: &PeerKey) -> Result<Sample, String> {
    exchange(dialect, addr, key).map_err(|(why, _)| why)
}

/// The peer's ed25519 public key, parsed.
#[derive(Debug, Clone)]
pub struct PeerKey(ssh_key::PublicKey);

impl PeerKey {
    /// An OpenSSH public key line (what `config` normalises to).
    pub fn parse(line: &str) -> Result<PeerKey, String> {
        let key = ssh_key::PublicKey::from_openssh(line.trim())
            .map_err(|err| format!("the peer time key does not parse: {err}"))?;
        if key.algorithm() != ssh_key::Algorithm::Ed25519 {
            return Err(format!(
                "the peer time key is {}, and the time signature is ed25519 only",
                key.algorithm()
            ));
        }
        Ok(PeerKey(key))
    }

    /// `SHA256:…`, for reports.
    pub fn fingerprint(&self) -> String {
        self.0.fingerprint(ssh_key::HashAlg::Sha256).to_string()
    }

    /// The whole OpenSSH line.
    pub fn to_openssh(&self) -> String {
        self.0.to_openssh().unwrap_or_default()
    }
}

/// The bytes the signature covers.
pub fn signed_message(nonce: &str, unix_ms: i64) -> String {
    signed_message_in(Dialect::Estate, nonce, unix_ms)
}

/// A fresh nonce from the OS RNG, hex.
pub fn fresh_nonce() -> String {
    use rand::Rng as _;
    let mut bytes = [0u8; NONCE_BYTES];
    rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn is_nonce(candidate: &str) -> bool {
    candidate.len() == NONCE_BYTES * 2
        && candidate
            .bytes()
            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}

/// The request line for a nonce.
pub fn request_line(nonce: &str) -> String {
    request_line_in(Dialect::Estate, nonce)
}

/// The nonce out of a request in either dialect, or why it is not one.
pub fn parse_request(bytes: &[u8]) -> Result<String, String> {
    parse_request_in(bytes).map(|(_, nonce)| nonce)
}

/// **The front's half.** Answer one request.
///
/// `vouched` is the front's own standing: `Ok` while its clock is known good,
/// `Err(why)` otherwise. A front whose clock is unsynchronised says so —
/// `ESTATE-TIME/1 REFUSED <why>` — instead of signing a time it cannot stand
/// behind, because the appliance would believe it: a signature proves who said
/// something, never that it was right.
///
/// **Either dialect** (lane G3b): a request in `GUNNAR-TIME/1` is answered in
/// it, signed in [`GUNNAR_NAMESPACE`], so an old Gunnar box follows a new front;
/// a request in neither is refused in `ESTATE-TIME/1`.
pub fn answer(
    request: &[u8],
    signer: &ssh_key::PrivateKey,
    now_unix_ms: i64,
    vouched: Result<(), String>,
) -> Vec<u8> {
    let dialect = Dialect::of(request).unwrap_or(Dialect::Estate);
    let magic = dialect.magic();
    let refused = |why: &str| format!("{magic} REFUSED {}\n", why.replace('\n', " ")).into_bytes();
    let nonce = match parse_request_in(request) {
        Ok((_, nonce)) => nonce,
        Err(why) => return refused(&why),
    };
    if let Err(why) = vouched {
        return refused(&why);
    }
    let message = signed_message_in(dialect, &nonce, now_unix_ms);
    let signature = match signer.sign(dialect.namespace(), ssh_key::HashAlg::Sha512, message.as_bytes()) {
        Ok(signature) => signature,
        Err(err) => return refused(&format!("signing failed: {err}")),
    };
    let armor = match signature.to_pem(ssh_key::LineEnding::LF) {
        Ok(armor) => armor,
        Err(err) => return refused(&format!("armouring failed: {err}")),
    };
    format!("{magic} {nonce} {now_unix_ms}\n{armor}").into_bytes()
}

/// **The appliance's half.** The peer's epoch millisecond, if and only if
/// `reply` is a signature by `key` over the nonce this side chose.
pub fn verify_reply(reply: &[u8], key: &PeerKey, expected_nonce: &str) -> Result<i64, String> {
    verify_reply_in(Dialect::Estate, reply, key, expected_nonce)
}

/// **The appliance's half, on the wire.** One exchange with `addr`, in
/// `ESTATE-TIME/1`; a front that answers in `GUNNAR-TIME/1` (an old Gunnar
/// front, gunnar-clock, which refuses the estate dialect in its own) is asked
/// once more in its dialect, with a fresh nonce (lane G3b).
pub fn query(addr: SocketAddr, key: &PeerKey) -> Result<Sample, String> {
    match exchange(Dialect::Estate, addr, key) {
        Err((_, Some(other))) if other != Dialect::Estate => query_in(other, addr, key),
        done => done.map_err(|(why, _)| why),
    }
}

/// One exchange in `dialect`. The error carries the dialect the reply was
/// spoken in, when it had one.
fn exchange(dialect: Dialect, addr: SocketAddr, key: &PeerKey) -> Result<Sample, (String, Option<Dialect>)> {
    let label = format!("peer://{addr}");
    let nonce = fresh_nonce();
    let started = Instant::now();
    let before = SystemTime::now();
    let mut stream = TcpStream::connect_timeout(&addr, PEER_TIMEOUT)
        .map_err(|err| (format!("{label} did not accept a connection: {err}"), None))?;
    let _ = stream.set_read_timeout(Some(PEER_TIMEOUT));
    let _ = stream.set_write_timeout(Some(PEER_TIMEOUT));
    stream
        .write_all(request_line_in(dialect, &nonce).as_bytes())
        .map_err(|err| (format!("{label}: writing the request failed: {err}"), None))?;
    let mut reply = Vec::with_capacity(1024);
    (&mut stream)
        .take(MAX_REPLY_BYTES as u64 + 1)
        .read_to_end(&mut reply)
        .map_err(|err| (format!("{label}: reading the reply failed: {err}"), None))?;
    let after = SystemTime::now();
    let round_trip = started.elapsed();

    let peer_unix_ms = verify_reply_in(dialect, &reply, key, &nonce)
        .map_err(|why| (format!("{label}: {why}"), Dialect::of(&reply)))?;
    if round_trip > MAX_ROUND_TRIP {
        return Err((
            format!(
                "{label}: the exchange took {} ms, longer than the {} ms an answer may be held",
                round_trip.as_millis(),
                MAX_ROUND_TRIP.as_millis()
            ),
            None,
        ));
    }
    let (start, end) = (crate::unix_ms(before), crate::unix_ms(after));
    let local_mid = start + (end - start) / 2;
    Ok(Sample {
        source: label,
        source_unix_ms: peer_unix_ms,
        offset_ms: peer_unix_ms - local_mid,
    })
}

/// **The front's listener.** Serves forever, one short exchange at a time.
///
/// Sequential on purpose: the only caller is one appliance per hour and at
/// boot, every exchange is bounded by [`PEER_TIMEOUT`], and a thread per
/// connection would be a thread per stranger for a port the firewall already
/// narrows to one address.
pub fn serve(
    listener: TcpListener,
    signer: &ssh_key::PrivateKey,
    vouched: &dyn Fn() -> Result<(), String>,
    on_answer: &dyn Fn(SocketAddr, &Result<(), String>),
) {
    for stream in listener.incoming() {
        let Ok(mut stream) = stream else { continue };
        let peer = stream
            .peer_addr()
            .unwrap_or_else(|_| SocketAddr::from(([0, 0, 0, 0], 0)));
        let _ = stream.set_read_timeout(Some(PEER_TIMEOUT));
        let _ = stream.set_write_timeout(Some(PEER_TIMEOUT));
        let mut request = Vec::with_capacity(MAX_REQUEST_BYTES);
        let mut byte = [0u8; 1];
        while request.len() < MAX_REQUEST_BYTES {
            match stream.read(&mut byte) {
                Ok(1) => {
                    request.push(byte[0]);
                    if byte[0] == b'\n' {
                        break;
                    }
                }
                _ => break,
            }
        }
        let standing = vouched();
        let reply = answer(&request, signer, crate::now_unix_ms(), standing.clone());
        let _ = stream.write_all(&reply);
        on_answer(peer, &standing);
    }
}

/// A fresh ed25519 time key.
pub fn generate_key() -> Result<ssh_key::PrivateKey, String> {
    ssh_key::PrivateKey::random(&mut rand::rand_core::UnwrapErr(rand::rngs::SysRng), ssh_key::Algorithm::Ed25519)
        .map_err(|err| format!("generating an ed25519 key failed: {err}"))
}

#[cfg(test)]
mod tests {
    use super::*;

    const NOW_MS: i64 = 1_789_394_521_628;

    fn keypair() -> (ssh_key::PrivateKey, PeerKey) {
        let private = generate_key().unwrap();
        let public = PeerKey::parse(&private.public_key().to_openssh().unwrap()).unwrap();
        (private, public)
    }

    #[test]
    fn a_valid_signed_time_is_accepted() {
        let (signer, key) = keypair();
        let nonce = fresh_nonce();
        let reply = answer(request_line(&nonce).as_bytes(), &signer, NOW_MS, Ok(()));
        assert_eq!(verify_reply(&reply, &key, &nonce), Ok(NOW_MS));
    }

    #[test]
    fn a_reply_signed_by_another_key_is_refused() {
        let (_, key) = keypair();
        let (impostor, _) = keypair();
        let nonce = fresh_nonce();
        let reply = answer(request_line(&nonce).as_bytes(), &impostor, NOW_MS, Ok(()));
        let refusal = verify_reply(&reply, &key, &nonce).expect_err("wrong key");
        assert!(refusal.contains("not the configured peer key"), "{refusal}");
    }

    #[test]
    fn a_reply_for_a_different_nonce_is_refused() {
        let (signer, key) = keypair();
        let sent = fresh_nonce();
        let other = fresh_nonce();
        let reply = answer(request_line(&other).as_bytes(), &signer, NOW_MS, Ok(()));
        let refusal = verify_reply(&reply, &key, &sent).expect_err("mismatched nonce");
        assert!(refusal.contains("nonce"), "{refusal}");
    }

    #[test]
    fn a_recorded_reply_replayed_into_a_later_exchange_is_refused() {
        // An attacker records a perfectly genuine reply at T and plays it back
        // at T + 1 day to wind the clock back. The later exchange chose a new
        // nonce, so the recording answers a question nobody is asking.
        let (signer, key) = keypair();
        let first = fresh_nonce();
        let recorded = answer(request_line(&first).as_bytes(), &signer, NOW_MS, Ok(()));
        assert_eq!(verify_reply(&recorded, &key, &first), Ok(NOW_MS));
        let later = fresh_nonce();
        assert!(verify_reply(&recorded, &key, &later).is_err());
    }

    #[test]
    fn a_reply_whose_time_was_rewritten_after_signing_is_refused() {
        let (signer, key) = keypair();
        let nonce = fresh_nonce();
        let reply = String::from_utf8(answer(
            request_line(&nonce).as_bytes(),
            &signer,
            NOW_MS,
            Ok(()),
        ))
        .unwrap();
        let tampered = reply.replacen(&NOW_MS.to_string(), &(NOW_MS - 7_200_000).to_string(), 1);
        let refusal = verify_reply(tampered.as_bytes(), &key, &nonce).expect_err("tampered");
        assert!(refusal.contains("does not verify"), "{refusal}");
    }

    #[test]
    fn a_credential_signature_can_never_pass_as_time() {
        // Same key, same bytes, the credential namespace: refused by namespace.
        let (signer, key) = keypair();
        let nonce = fresh_nonce();
        let sig = signer
            .sign(
                "a-credential-namespace",
                ssh_key::HashAlg::Sha512,
                signed_message(&nonce, NOW_MS).as_bytes(),
            )
            .unwrap();
        let reply = format!(
            "{MAGIC} {nonce} {NOW_MS}\n{}",
            sig.to_pem(ssh_key::LineEnding::LF).unwrap()
        );
        let refusal = verify_reply(reply.as_bytes(), &key, &nonce).expect_err("namespace");
        assert!(refusal.contains("namespace"), "{refusal}");
    }

    #[test]
    fn a_front_that_cannot_vouch_for_its_clock_refuses_to_sign_and_says_why() {
        let (signer, key) = keypair();
        let nonce = fresh_nonce();
        let reply = answer(
            request_line(&nonce).as_bytes(),
            &signer,
            NOW_MS,
            Err("this front's own clock has not synchronised".into()),
        );
        let refusal = verify_reply(&reply, &key, &nonce).expect_err("unvouched");
        assert!(refusal.contains("has not synchronised"), "{refusal}");
    }

    #[test]
    fn a_malformed_request_is_refused_and_signs_nothing() {
        let (signer, _) = keypair();
        for bad in [
            b"GET / HTTP/1.1\r\n".as_slice(),
            b"ESTATE-TIME/1 short\n",
            b"ESTATE-TIME/1 0000000000000000000000000000000000000000000000000000000000000000",
        ] {
            let reply = String::from_utf8(answer(bad, &signer, NOW_MS, Ok(()))).unwrap();
            assert!(reply.starts_with("ESTATE-TIME/1 REFUSED "), "{reply}");
            assert!(!reply.contains("SSH SIGNATURE"));
        }
    }

    #[test]
    fn an_oversized_reply_is_refused_before_it_is_parsed() {
        let (_, key) = keypair();
        let huge = vec![b'x'; MAX_REPLY_BYTES + 1];
        assert!(verify_reply(&huge, &key, &fresh_nonce()).is_err());
    }

    #[test]
    fn the_whole_exchange_over_loopback_yields_a_sample_near_zero() {
        let (signer, key) = keypair();
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        std::thread::spawn(move || serve(listener, &signer, &|| Ok(()), &|_, _| {}));
        let sample = query(addr, &key).expect("loopback exchange");
        assert_eq!(sample.source, format!("peer://{addr}"));
        assert!(sample.offset_ms.abs() < 1_000, "{sample:?}");
    }

    #[test]
    fn an_unreachable_peer_is_an_error_not_a_hang() {
        let (_, key) = keypair();
        // Bind then drop: the port is closed, the connect is refused at once.
        let addr = TcpListener::bind("127.0.0.1:0")
            .unwrap()
            .local_addr()
            .unwrap();
        let refusal = query(addr, &key).expect_err("nobody listening");
        assert!(refusal.contains("did not accept a connection"), "{refusal}");
    }

    /// **GUNNAR-TIME/1** (lane G3b): gunnar-clock's own tests (Gunnar e4234a34
    /// `crates/gunnar-clock/src/peer.rs`), ported, run in BOTH dialects
    /// against this crate's one implementation.
    mod both_dialects {
        use super::*;

        #[test]
        fn gunnar_time_v1_is_gunnar_clocks_wire_byte_for_byte() {
            let nonce = "ab".repeat(NONCE_BYTES);
            assert_eq!(GUNNAR_MAGIC, "GUNNAR-TIME/1");
            assert_eq!(GUNNAR_NAMESPACE, "gunnar-time-v1@gunnar.rs");
            assert_eq!(request_line_in(Dialect::Gunnar, &nonce), format!("GUNNAR-TIME/1 {nonce}\n"));
            assert_eq!(signed_message_in(Dialect::Gunnar, &nonce, NOW_MS), format!("gunnar-time-v1\n{nonce}\n{NOW_MS}\n"));
            assert_eq!(request_line_in(Dialect::Estate, &nonce), format!("ESTATE-TIME/1 {nonce}\n"));
            assert_eq!(signed_message_in(Dialect::Estate, &nonce, NOW_MS), format!("estate-time-v1\n{nonce}\n{NOW_MS}\n"));
            // Both requests are the same length, so one bound reads both.
            assert_eq!(GUNNAR_MAGIC.len(), MAGIC.len());
        }

        #[test]
        fn a_request_names_its_dialect_and_the_answer_is_in_it() {
            let (signer, _) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                assert_eq!(parse_request_in(request_line_in(d, &nonce).as_bytes()), Ok((d, nonce.clone())));
                let reply = String::from_utf8(answer(request_line_in(d, &nonce).as_bytes(), &signer, NOW_MS, Ok(()))).unwrap();
                assert!(reply.starts_with(&format!("{} {nonce} {NOW_MS}\n-----BEGIN SSH SIGNATURE-----", d.magic())), "{d:?}: {reply}");
                assert_eq!(Dialect::of(reply.as_bytes()), Some(d));
            }
        }

        #[test]
        fn a_valid_signed_time_is_accepted() {
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                let reply = answer(request_line_in(d, &nonce).as_bytes(), &signer, NOW_MS, Ok(()));
                assert_eq!(verify_reply_in(d, &reply, &key, &nonce), Ok(NOW_MS), "{d:?}");
            }
        }

        #[test]
        fn an_answer_in_one_dialect_never_verifies_as_the_other() {
            // The namespaces differ, so a Gunnar signature is not an estate one.
            let (signer, key) = keypair();
            let nonce = fresh_nonce();
            for (asked, read_as) in [(Dialect::Estate, Dialect::Gunnar), (Dialect::Gunnar, Dialect::Estate)] {
                let reply = answer(request_line_in(asked, &nonce).as_bytes(), &signer, NOW_MS, Ok(()));
                assert!(verify_reply_in(read_as, &reply, &key, &nonce).is_err(), "{asked:?} read as {read_as:?}");
                let relabelled = String::from_utf8(reply).unwrap().replacen(asked.magic(), read_as.magic(), 1);
                let refusal = verify_reply_in(read_as, relabelled.as_bytes(), &key, &nonce).expect_err("relabelled");
                assert!(refusal.contains("namespace"), "{refusal}");
            }
        }

        #[test]
        fn a_reply_signed_by_another_key_is_refused() {
            let (_, key) = keypair();
            let (impostor, _) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                let reply = answer(request_line_in(d, &nonce).as_bytes(), &impostor, NOW_MS, Ok(()));
                let refusal = verify_reply_in(d, &reply, &key, &nonce).expect_err("wrong key");
                assert!(refusal.contains("not the configured peer key"), "{d:?}: {refusal}");
            }
        }

        #[test]
        fn a_reply_for_a_different_nonce_is_refused() {
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let (sent, other) = (fresh_nonce(), fresh_nonce());
                let reply = answer(request_line_in(d, &other).as_bytes(), &signer, NOW_MS, Ok(()));
                let refusal = verify_reply_in(d, &reply, &key, &sent).expect_err("mismatched nonce");
                assert!(refusal.contains("nonce"), "{d:?}: {refusal}");
            }
        }

        #[test]
        fn a_recorded_reply_replayed_into_a_later_exchange_is_refused() {
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let first = fresh_nonce();
                let recorded = answer(request_line_in(d, &first).as_bytes(), &signer, NOW_MS, Ok(()));
                assert_eq!(verify_reply_in(d, &recorded, &key, &first), Ok(NOW_MS), "{d:?}");
                assert!(verify_reply_in(d, &recorded, &key, &fresh_nonce()).is_err(), "{d:?}");
            }
        }

        #[test]
        fn a_reply_whose_time_was_rewritten_after_signing_is_refused() {
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                let reply = String::from_utf8(answer(request_line_in(d, &nonce).as_bytes(), &signer, NOW_MS, Ok(()))).unwrap();
                let tampered = reply.replacen(&NOW_MS.to_string(), &(NOW_MS - 7_200_000).to_string(), 1);
                let refusal = verify_reply_in(d, tampered.as_bytes(), &key, &nonce).expect_err("tampered");
                assert!(refusal.contains("does not verify"), "{d:?}: {refusal}");
            }
        }

        #[test]
        fn a_credential_signature_can_never_pass_as_time() {
            // gunnar-clock's own case: same key, same bytes, `gunnar-grpc`'s namespace.
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                let sig = signer.sign("gunnar-grpc", ssh_key::HashAlg::Sha512, signed_message_in(d, &nonce, NOW_MS).as_bytes()).unwrap();
                let reply = format!("{} {nonce} {NOW_MS}\n{}", d.magic(), sig.to_pem(ssh_key::LineEnding::LF).unwrap());
                let refusal = verify_reply_in(d, reply.as_bytes(), &key, &nonce).expect_err("namespace");
                assert!(refusal.contains("namespace"), "{d:?}: {refusal}");
            }
        }

        #[test]
        fn a_front_that_cannot_vouch_for_its_clock_refuses_to_sign_and_says_why() {
            let (signer, key) = keypair();
            for d in Dialect::ALL {
                let nonce = fresh_nonce();
                let reply = answer(request_line_in(d, &nonce).as_bytes(), &signer, NOW_MS, Err("this front's own clock has not synchronised".into()));
                assert!(String::from_utf8_lossy(&reply).starts_with(&format!("{} REFUSED ", d.magic())), "{d:?}");
                let refusal = verify_reply_in(d, &reply, &key, &nonce).expect_err("unvouched");
                assert!(refusal.contains("has not synchronised"), "{d:?}: {refusal}");
            }
        }

        #[test]
        fn a_malformed_request_is_refused_in_its_dialect_and_signs_nothing() {
            let (signer, _) = keypair();
            for (bad, d) in [
                (b"GET / HTTP/1.1\r\n".as_slice(), Dialect::Estate),
                (b"GUNNAR-TIME/1 short\n", Dialect::Gunnar),
                (b"GUNNAR-TIME/1 0000000000000000000000000000000000000000000000000000000000000000", Dialect::Gunnar),
                (b"ESTATE-TIME/1 short\n", Dialect::Estate),
            ] {
                let reply = String::from_utf8(answer(bad, &signer, NOW_MS, Ok(()))).unwrap();
                assert!(reply.starts_with(&format!("{} REFUSED ", d.magic())), "{reply}");
                assert!(!reply.contains("SSH SIGNATURE"));
            }
        }

        #[test]
        fn the_whole_exchange_over_loopback_in_each_dialect_yields_a_sample_near_zero() {
            let (signer, key) = keypair();
            let listener = TcpListener::bind("127.0.0.1:0").unwrap();
            let addr = listener.local_addr().unwrap();
            std::thread::spawn(move || serve(listener, &signer, &|| Ok(()), &|_, _| {}));
            for d in Dialect::ALL {
                let sample = query_in(d, addr, &key).unwrap_or_else(|e| panic!("{d:?}: {e}"));
                assert_eq!(sample.source, format!("peer://{addr}"));
                assert!(sample.offset_ms.abs() < 1_000, "{sample:?}");
            }
        }
    }
}