crates/gunnar-server/src/twin_tls.rs
//! **The twin's mutual-TLS credential, minted at PAIRING and never on the medium.**
//!
//! # Why this module exists
//!
//! `agent/wg-mtls` made the twin refuse to follow a primary on another machine
//! in the clear — correctly: the log it follows carries the KEK as seq 1. What
//! it did not do was give the twin anything to follow WITH. T15's bench, on a
//! medium carrying that change, measured the result: every twin refused
//! ("no client certificate is configured … It will not") and none ever
//! followed. A refusal with no way to satisfy it is a brick with a good error
//! message.
//!
//! The one-medium ruling decides where the certificate may come from: a twin is
//! installed from the SAME image as its primary with NO per-box value in it, so
//! the certificate cannot be on the medium. It is minted when the pair is
//! formed — the same shape as the twin's ssh key (`Bootstrap.BecomeTwin` mints
//! `twin.key` on the box and hands out only the public half):
//!
//! ```text
//! twin Bootstrap.TwinCsr mint twin-tls.key HERE, answer the CSR
//! primary Admin.IssueTwinCertificate issuer signs it for the twin's principal,
//! registers it, answers cert + issuer + port
//! twin Bootstrap.InstallTwinCertificate check it is for OUR key, pin the issuer
//! ```
//!
//! `gunnar control twin certify` is the courier. The private key never leaves
//! the twin; what crosses is a request (public key + proof of possession) and a
//! certificate (public).
//!
//! # What the twin then does with it
//!
//! Dials the primary's **mutual replication listener** — its own port, served
//! only with a client certificate its issuer signed (`serve.rs`,
//! `--grpc-replication-bind`) — at the primary's host and the port the primary
//! reported, verifying the server against the PINNED issuer under the fixed name
//! [`gunnar_grpc::client_signer::REPLICATION_SERVER_NAME`]. Once paired it uses
//! mutual TLS even to a loopback primary: the credential is there, and a lane
//! that encrypts only sometimes is a lane whose tests prove the other branch.
//!
//! # Re-pointing is refused; renewal is not
//!
//! A second install with a DIFFERENT issuer is refused by name — a twin is never
//! re-pointed, and an unauthenticated door that could swap the CA it pins would
//! be a door for redirecting the replication stream. A second install under the
//! SAME issuer for the SAME key is a renewal and is taken: that is the path by
//! which a box with no shell gets a fresh certificate before the old one lapses
//! (`gunnar_core::expiry`), without anyone ever logging in.
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
/// The twin's TLS private key (PKCS#8 PEM, 0600). Born on this box.
pub const TLS_KEY: &str = "twin-tls.key";
/// The request for it, kept so a repeated `TwinCsr` answers the same one.
pub const TLS_CSR: &str = "twin-tls.csr";
/// The certificate the primary's issuer signed for [`TLS_KEY`].
pub const TLS_CERT: &str = "twin-tls.crt";
/// The primary's issuer, PINNED: the only CA the twin accepts the primary's
/// replication listener from.
pub const TLS_CA: &str = "twin-tls-ca.pem";
/// The primary's mutual replication port, as the primary reported it.
pub const TLS_PORT: &str = "twin-tls.port";
/// On a PRIMARY: the port its mutual replication listener is bound on, written
/// by `serve` at every start and removed when the listener is off, so
/// `Admin.IssueTwinCertificate` can tell a twin where to dial — and can refuse
/// by name to certify a twin it has no listener for.
pub const REPLICATION_PORT_FILE: &str = "replication-listener.port";
/// PRIMARY side: the address the replication listener is bound on WHEN it is not
/// the one the twin was joined to — the WireGuard tunnel address, once the
/// listener moved onto `wg0` (owner, 2026-09-22). Absent = the joined host.
pub const REPLICATION_HOST_FILE: &str = "replication-listener.host";
/// TWIN side: the host `twin certify` said to dial the primary's replication
/// port at. Absent = the host this twin was joined to (`twin.json.primary`).
pub const TLS_HOST: &str = "twin-tls.host";
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
fn require_twin(root: &Path) -> Result<()> {
if crate::twin::TwinConfig::load(root)?.is_none() {
bail!(
"this box is not a twin (no twin.json): a replication certificate is only minted and \
installed on a box that `gunnar control twin join` has made a twin"
);
}
Ok(())
}
fn write_private(root: &Path, name: &str, bytes: &[u8]) -> Result<()> {
crate::durable::write_atomic_unframed(root, name, bytes)?;
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(root.join(name), std::fs::Permissions::from_mode(0o600))
.with_context(|| format!("restricting {name} to its owner"))?;
Ok(())
}
/// **`Bootstrap.TwinCsr`**: this twin's certificate signing request, minting
/// the key on first call. Idempotent — the same key, the same request — so a
/// courier that dies half-way can simply run again.
pub fn request(root: &Path) -> Result<String> {
require_twin(root)?;
let (key, csr) = (root.join(TLS_KEY), root.join(TLS_CSR));
if key.exists() && csr.exists() {
return std::fs::read_to_string(&csr).with_context(|| format!("reading {}", csr.display()));
}
let (key_pem, csr_pem) = gunnar_core::issuer::mint_client_request("gunnar-twin")?;
// The key first: a request on disk whose key is not is a request nobody can
// ever use, while a key without its request is re-requested next call.
write_private(root, TLS_KEY, key_pem.as_bytes())?;
crate::durable::write_atomic_unframed(root, TLS_CSR, csr_pem.as_bytes())?;
Ok(csr_pem)
}
/// **`Bootstrap.InstallTwinCertificate`**: take the certificate the primary's
/// issuer signed, and the issuer to pin.
///
/// Refused, each by name: not a twin; no key here (the request was never
/// made); a certificate for ANOTHER key; a certificate the expiry rule already
/// refuses; a different issuer from the one already pinned. Answers the grade,
/// so the courier prints how long the pairing is good for.
pub fn install(
root: &Path,
cert_pem: &str,
ca_pem: &str,
replication_port: u16,
) -> Result<gunnar_core::expiry::Expiry> {
install_at(root, cert_pem, ca_pem, replication_port, None)
}
/// [`install`], with the HOST the primary said to dial its replication port at
/// (its tunnel address) — `None` keeps the joined host, as before. Written
/// before the certificate, which stays the "paired" marker, and cleared when
/// absent so a renewal from a primary that left the tunnel does not dial a
/// dead address.
pub fn install_at(
root: &Path,
cert_pem: &str,
ca_pem: &str,
replication_port: u16,
host: Option<&str>,
) -> Result<gunnar_core::expiry::Expiry> {
require_twin(root)?;
if let Some(h) = host {
// One IP, or `<tunnel>,<utility>` — every one of them an IP.
if hosts(h).is_empty()
|| hosts(h)
.iter()
.any(|one| one.parse::<std::net::IpAddr>().is_err())
{
bail!("the primary reported replication host {h:?}, which is not an IP address; nothing was installed");
}
}
if replication_port == 0 {
bail!("the primary reported no replication port; there is no mutual listener for this twin to dial");
}
let key_pem = std::fs::read_to_string(root.join(TLS_KEY)).map_err(|err| {
anyhow!(
"this twin has no replication key ({TLS_KEY}: {err}); `Bootstrap.TwinCsr` mints it — \
a certificate cannot be installed for a key that was never made"
)
})?;
let ours = gunnar_core::issuer::public_key_info_of_key(&key_pem)?;
let theirs = gunnar_core::issuer::public_key_info_of_cert(cert_pem)?;
if ours != theirs {
bail!(
"this certificate was issued for a DIFFERENT key than the one this twin minted, so it \
could never complete a handshake here. Nothing was installed. Re-run `gunnar control \
twin certify` against this twin — the request it signs is this box's own."
);
}
// The CA must parse, and it must be what the leaf names as its issuer: a
// pinned CA that did not sign the leaf would make every dial fail with a
// handshake error that names neither.
if let Err((named, handed)) = gunnar_core::issuer::issued_by(cert_pem, ca_pem)? {
bail!(
"the certificate names issuer `{named}` and the CA handed with it is `{handed}`: this \
twin would pin a CA that did not sign its own credential"
);
}
let cert_der = gunnar_core::issuer::pem_to_der(cert_pem)?;
if let Ok(pinned) = std::fs::read(root.join(TLS_CA)) {
if pinned != ca_pem.as_bytes() {
bail!(
"this twin already pins a DIFFERENT issuer. A twin is never re-pointed, and a door \
that could swap the CA it trusts is a door for redirecting the stream that carries \
the KEK. A renewal comes from the SAME issuer; a new primary needs a fresh twin."
);
}
}
let expiry = gunnar_core::expiry::grade_der(now_ms(), &cert_der)?;
if expiry.is_refusal() {
bail!("{}", expiry.line("the certificate offered to this twin"));
}
crate::durable::write_atomic_unframed(root, TLS_CA, ca_pem.as_bytes())?;
crate::durable::write_atomic_unframed(root, TLS_PORT, replication_port.to_string().as_bytes())?;
match host {
Some(h) => crate::durable::write_atomic_unframed(root, TLS_HOST, h.as_bytes())?,
None => match std::fs::remove_file(root.join(TLS_HOST)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
return Err(anyhow!("removing {TLS_HOST}: {err}"))
}
_ => {}
},
}
// The certificate LAST: its presence is what `load` treats as "paired", so
// a cut before this line leaves an unpaired twin, never a paired one with
// no CA.
crate::durable::write_atomic_unframed(root, TLS_CERT, cert_pem.as_bytes())?;
Ok(expiry)
}
/// The paired credential, when this twin has one: the trust to dial with and
/// the primary's replication port. `None` before pairing.
pub fn load(root: &Path) -> Result<Option<(gunnar_grpc::client_signer::ClientTrust, u16)>> {
let cert = match std::fs::read(root.join(TLS_CERT)) {
Ok(cert) => cert,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(anyhow!("reading {TLS_CERT}: {err}")),
};
let read = |name: &str| {
std::fs::read(root.join(name))
.with_context(|| format!("{TLS_CERT} is installed but {name} is not readable"))
};
let key = read(TLS_KEY)?;
let ca = read(TLS_CA)?;
let port: u16 = String::from_utf8_lossy(&read(TLS_PORT)?)
.trim()
.parse()
.map_err(|err| anyhow!("{TLS_PORT} does not hold a port: {err}"))?;
Ok(Some((
gunnar_grpc::client_signer::ClientTrust { ca, cert, key },
port,
)))
}
/// Twin: the host `twin certify` said to dial the primary's replication port at,
/// when it named one.
pub fn load_host(root: &Path) -> Option<String> {
std::fs::read_to_string(root.join(TLS_HOST))
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Primary: record (or clear) the hosts the replication doors answer on, in
/// the twin's order of preference — `<tunnel>,<utility>` while a door is open
/// on the tunnel; cleared (the twin dials the host it joined) otherwise.
pub fn record_replication_host(root: &Path, hosts: &[std::net::IpAddr]) -> Result<()> {
if hosts.is_empty() {
return match std::fs::remove_file(root.join(REPLICATION_HOST_FILE)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
Err(anyhow!("removing {REPLICATION_HOST_FILE}: {err}"))
}
_ => Ok(()),
};
}
let list = hosts
.iter()
.map(|h| h.to_string())
.collect::<Vec<_>>()
.join(",");
crate::durable::write_atomic_unframed(root, REPLICATION_HOST_FILE, list.as_bytes())
}
/// Twin: the hosts `twin certify` named, in order (one, or `<tunnel>,<utility>`).
pub fn hosts(recorded: &str) -> Vec<String> {
recorded
.split(',')
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_owned)
.collect()
}
/// Primary: the tunnel address the replication listener is on, if it is on one.
pub fn replication_host(root: &Path) -> Option<String> {
std::fs::read_to_string(root.join(REPLICATION_HOST_FILE))
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
}
/// Primary: record (or clear) the bound replication port.
pub fn record_replication_port(root: &Path, port: Option<u16>) -> Result<()> {
match port {
Some(port) => crate::durable::write_atomic_unframed(
root,
REPLICATION_PORT_FILE,
port.to_string().as_bytes(),
),
None => match std::fs::remove_file(root.join(REPLICATION_PORT_FILE)) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => {
Err(anyhow!("removing {REPLICATION_PORT_FILE}: {err}"))
}
_ => Ok(()),
},
}
}
/// Primary: the bound replication port, if the listener is on.
pub fn replication_port(root: &Path) -> Option<u16> {
std::fs::read_to_string(root.join(REPLICATION_PORT_FILE))
.ok()?
.trim()
.parse()
.ok()
}
/// **`Admin.IssueTwinCertificate`, on the PRIMARY**: sign `csr_pem` for
/// `principal`, register it (with its expiry, so a lapsed certificate stops
/// authenticating in the same table a revocation writes), and answer the
/// certificate, the issuer to pin and the replication port.
///
/// Refused by name when this primary has no mutual replication listener — a
/// certificate for a door that does not exist is a pairing that looks complete
/// and follows nothing — and when `principal` is not a member of group `twin`:
/// this mints a credential into the stream that carries the KEK, and only a
/// principal already enrolled as a twin may hold one.
pub fn issue_for_twin(
stores: &crate::stores::ServerStores,
principal: &str,
csr_pem: &str,
) -> Result<(String, String, u16)> {
let port = replication_port(stores.root()).ok_or_else(|| {
anyhow!(
"this primary serves no mutual replication listener, so a twin certificate would pair it \
to a door that does not exist. Start it with --grpc-replication-bind (it is on by \
default whenever the control plane binds off loopback)"
)
})?;
let groups = stores
.auth()
.groups_of(principal)
.with_context(|| format!("reading the groups of {principal}"))?;
if !groups.iter().any(|g| g == gunnar_grpc::TWIN_GROUP) {
bail!(
"{principal} is not a member of group `{}`. A replication certificate opens the stream \
that carries every sealed secret; it is issued only to a principal already enrolled as \
a twin (`gunnar control group member add {} {principal}`)",
gunnar_grpc::TWIN_GROUP,
gunnar_grpc::TWIN_GROUP
);
}
let pki = stores.pki()?;
let issuer = pki
.issuer()?
.ok_or_else(|| anyhow!("this primary has no issuer, so it cannot certify a twin"))?;
let issued = pki.issue_from_csr(principal, csr_pem, gunnar_core::DEFAULT_LEAF_DAYS)?;
let not_after = gunnar_core::issuer::not_after_ms(&issued.cert_der)?;
stores
.auth()
.register_certificate(principal, &issued.cert_der, Some(not_after))?;
// **A certificate for a NEW key replaces, it does not sit beside** (twin
// reborn, 2026-09-30). A twin's key is minted on its box and never leaves
// it, so a request for another key under the same principal is another box
// — the dead twin's replacement. Its predecessor's certificate is revoked
// here, or a copy of the nuked twin's disk would keep reading the stream
// that carries the KEK beside the new twin. The same key again is a
// renewal and keeps its older certificate until it is installed.
let spki = gunnar_core::issuer::public_key_info_of_der(&issued.cert_der)?;
let superseded = stores.auth().supersede_certificates(principal, &spki)?;
if !superseded.is_empty() {
let fingerprints: Vec<&str> = superseded
.iter()
.map(|k| k.fingerprint_sha256.as_str())
.collect();
tracing::warn!(
target: gunnar_core::observe::TARGET_TWIN,
principal,
superseded = ?fingerprints,
"a twin certificate for a NEW key was issued: the {} certificate(s) {principal} held for another key are revoked (the box they belonged to is replaced)",
superseded.len()
);
}
Ok((issued.cert_pem, issuer.cert_pem, port))
}
#[cfg(test)]
mod replication_host_tests {
/// The recorded list round-trips in the twin's order of preference.
#[test]
fn the_recorded_hosts_are_tunnel_then_utility() {
let dir = tempfile::tempdir().unwrap();
let hosts: Vec<std::net::IpAddr> =
vec!["10.44.0.3".parse().unwrap(), "10.13.9.90".parse().unwrap()];
super::record_replication_host(dir.path(), &hosts).unwrap();
let got = super::replication_host(dir.path()).unwrap();
assert_eq!(super::hosts(&got), ["10.44.0.3", "10.13.9.90"]);
assert_eq!(
super::hosts("10.44.0.3"),
["10.44.0.3"],
"a single host (an older primary) still reads"
);
super::record_replication_host(dir.path(), &[]).unwrap();
assert_eq!(super::replication_host(dir.path()), None);
}
}