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/tests/gunnar_time_compat/gunnar_clock_peer.rs

// VERBATIM from Gunnar master e4234a34 (file last changed f46d1dfe), crates/gunnar-clock/src/peer.rs: the
// old Gunnar front and box this crate must keep talking to while Gunnar moves
// (lane G3b). Changed: its tests module dropped (ported into src/peer.rs, run
// in both dialects) and the three `crate::` paths pointed at estate_clock.

//! **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   GUNNAR-TIME/1 <nonce>\n                  32 random bytes, hex
//!   front → appliance   GUNNAR-TIME/1 <nonce> <unix_ms>\n
//!                       -----BEGIN SSH SIGNATURE-----            SSHSIG, ed25519,
//!                       …                                        namespace NAMESPACE, over
//!                       -----END SSH SIGNATURE-----              "gunnar-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.
//!
//! # 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 `GUNNAR_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 estate_clock::decide::Sample;

/// The first token of both lines.
pub const MAGIC: &str = "GUNNAR-TIME/1";
/// The SSHSIG namespace. Distinct from every credential namespace — see the
/// module note.
pub const NAMESPACE: &str = "gunnar-time-v1@gunnar.rs";
/// The first line of the signed message.
pub const PREAMBLE: &str = "gunnar-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);

/// 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 {
    format!("{PREAMBLE}\n{nonce}\n{unix_ms}\n")
}

/// 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 {
    format!("{MAGIC} {nonce}\n")
}

/// The nonce out of a request, or why it is not one.
pub fn parse_request(bytes: &[u8]) -> Result<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 nonce = line
        .strip_prefix(MAGIC)
        .and_then(|rest| rest.strip_prefix(' '))
        .ok_or_else(|| format!("the request does not start with {MAGIC}"))?;
    if !is_nonce(nonce) {
        return Err(format!(
            "the nonce is not {} lowercase hex characters",
            NONCE_BYTES * 2
        ));
    }
    Ok(nonce.to_owned())
}

/// **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 —
/// `GUNNAR-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.
pub fn answer(
    request: &[u8],
    signer: &ssh_key::PrivateKey,
    now_unix_ms: i64,
    vouched: Result<(), String>,
) -> Vec<u8> {
    let refused = |why: &str| format!("{MAGIC} REFUSED {}\n", why.replace('\n', " ")).into_bytes();
    let nonce = match parse_request(request) {
        Ok(nonce) => nonce,
        Err(why) => return refused(&why),
    };
    if let Err(why) = vouched {
        return refused(&why);
    }
    let message = signed_message(&nonce, now_unix_ms);
    let signature = match signer.sign(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> {
    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(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)
}

/// **The appliance's half, on the wire.** One exchange with `addr`.
pub fn query(addr: SocketAddr, key: &PeerKey) -> Result<Sample, String> {
    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}"))?;
    let _ = stream.set_read_timeout(Some(PEER_TIMEOUT));
    let _ = stream.set_write_timeout(Some(PEER_TIMEOUT));
    stream
        .write_all(request_line(&nonce).as_bytes())
        .map_err(|err| format!("{label}: writing the request failed: {err}"))?;
    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}"))?;
    let after = SystemTime::now();
    let round_trip = started.elapsed();

    let peer_unix_ms =
        verify_reply(&reply, key, &nonce).map_err(|why| format!("{label}: {why}"))?;
    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()
        ));
    }
    let (start, end) = (estate_clock::unix_ms(before), estate_clock::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, estate_clock::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}"))
}