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