server/lib/src/exposed/tls.rs
//! Shared TLS material for an exposed endpoint.
//!
//! One [`TlsSettings`] (loaded from RON) drives both transports of an endpoint:
//! the gRPC server (tonic) and the HTTP/OCI gateway (hyper + rustls). rustls is
//! already in the dependency tree via reqwest, so this adds no OpenSSL surface —
//! consistent with the airgap posture.
//!
//! Client-auth semantics differ by transport on purpose:
//! * gRPC: when `client_ca` is set, mTLS is **required** (clients are the
//! agent / CI, which can always present a cert).
//! * HTTP gateway: client certs are **requested but optional**, so anonymous
//! pulls and Kubernetes `/healthz` probes still work; write requests are
//! rejected at the application layer unless a valid identity is present.
use std::fs;
use std::sync::Arc;
use anyhow::{Context, Result};
use rustls::pki_types::pem::PemObject as _;
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::ServerConfig;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TlsSettings {
/// Path to the PEM server certificate chain.
pub cert: String,
/// Path to the PEM private key.
pub key: String,
/// Optional PEM CA bundle. When set, client certificates are trusted
/// against it (mTLS).
#[serde(default)]
pub client_ca: Option<String>,
/// **Accept a gRPC connection that presents NO client certificate**, and let
/// the application layer decide what it may reach.
///
/// Default `false`, which is what every existing config has: with
/// `client_ca` set, the gRPC handshake REQUIRES a client certificate and a
/// certless client never reaches an RPC at all. That is mTLS *across the
/// board*, and it is the right answer for an estate where every client is an
/// agent or a CI runner.
///
/// It is the wrong answer for the other posture: one upstream where `crates`
/// demands a certificate and everything else stays open. A transport that
/// refuses the handshake cannot express "this repository, not that one" —
/// by the time a repository NAME exists, the connection is already gone. So
/// the requirement moves up one layer: set this, mark the repositories that
/// demand a credential with `ron_require_auth`, and the read doors refuse
/// exactly those.
///
/// **On its own it weakens nothing.** A certificate that IS presented is
/// still verified against `client_ca` — rustls' `allow_unauthenticated`
/// admits an ABSENT certificate, never an untrusted one. What moves is only
/// WHERE the refusal happens, and it still happens: a repository with
/// `ron_require_auth` and no identity is `Unauthenticated`, and a server
/// that declares a gated repository with no auth method at all refuses to
/// boot rather than serving a repository nobody can ever reach.
///
/// Setting it WITHOUT a `client_ca` is meaningless (there is no client
/// verification to make optional) and holger says so at boot.
#[serde(default)]
pub client_auth_optional: bool,
/// **The client certificates' revocation lists** (lane HOL-NCSC, NCSC P3): a PEM or DER
/// CRL file, or a directory of them (`*.crl`, `*.pem`, `*.der`). With it, every client
/// certificate a door verifies against `client_ca` is also checked against these CRLs, the
/// whole chain: a revoked one fails the handshake (and leaves an audit row,
/// `refusals::handshake_refused`). Each CRL must be issued and signed by a certificate in
/// `client_ca` and carry a nextUpdate that has not passed, or the start is refused by name
/// (`holger-tls-1`); a CRL that expires while the door runs refuses every client
/// certificate until a fresh one is in place. The file (or directory) is read again at the
/// next handshake after it changes: no restart. `security_profile: Critical` requires it
/// wherever `client_ca` is set.
#[serde(default)]
pub crl: Option<String>,
}
impl TlsSettings {
pub fn mtls_enabled(&self) -> bool {
self.client_ca.is_some()
}
fn load_certs(path: &str) -> Result<Vec<CertificateDer<'static>>> {
let pem = fs::read(path).with_context(|| format!("reading cert {path}"))?;
let certs = CertificateDer::pem_slice_iter(&pem)
.collect::<std::result::Result<Vec<_>, _>>()
.with_context(|| format!("parsing certs in {path}"))?;
if certs.is_empty() {
anyhow::bail!("no certificates found in {path}");
}
Ok(certs)
}
fn load_key(path: &str) -> Result<PrivateKeyDer<'static>> {
let pem = crate::keyfile::read_private(std::path::Path::new(path), "the TLS key")
.with_context(|| format!("reading key {path}"))?;
match PrivateKeyDer::from_pem_slice(&pem) {
Ok(key) => Ok(key),
Err(rustls::pki_types::pem::Error::NoItemsFound) => {
Err(anyhow::anyhow!("no private key found in {path}"))
}
Err(e) => Err(e).with_context(|| format!("parsing key {path}")),
}
}
fn provider() -> Arc<rustls::crypto::CryptoProvider> {
Arc::new(door_provider())
}
/// **The client verifier of a door with `client_ca`**: webpki's, and with [`Self::crl`]
/// one that checks revocation and follows the CRL file ([`CrlVerifier`]).
fn client_verifier(
&self,
ca_path: &str,
allow_unauthenticated: bool,
) -> Result<Arc<dyn rustls::server::danger::ClientCertVerifier>> {
match &self.crl {
None => {
let mut roots = rustls::RootCertStore::empty();
for c in Self::load_certs(ca_path)? {
roots.add(c).context("adding client CA")?;
}
let b = rustls::server::WebPkiClientVerifier::builder_with_provider(
Arc::new(roots),
Self::provider(),
);
let b = if allow_unauthenticated {
b.allow_unauthenticated()
} else {
b
};
b.build().context("building client verifier")
}
Some(crl) => Ok(Arc::new(CrlVerifier::new(
ca_path,
crl,
allow_unauthenticated,
)?)),
}
}
}
/// **Read and judge the CRLs at `path`** against the client CA certificates `cas`, at
/// `now` (Unix seconds). Every CRL must parse, be issued by a certificate in `cas` (its
/// subject is the CRL's issuer) and signed by its key, and carry a nextUpdate after `now`.
/// Any refusal names the file and is `holger-tls-1`.
pub fn load_crls(
path: &str,
cas: &[CertificateDer<'static>],
now: i64,
) -> Result<Vec<rustls::pki_types::CertificateRevocationListDer<'static>>> {
use rustls::pki_types::CertificateRevocationListDer;
let refuse = |why: String| anyhow::anyhow!(holger_errcode::TLS_CRL_REFUSED.tag(why));
let p = std::path::Path::new(path);
let files: Vec<std::path::PathBuf> = if p.is_dir() {
let mut f: Vec<_> = fs::read_dir(p)
.map_err(|e| refuse(format!("the CRL directory {path} cannot be read: {e}")))?
.flatten()
.map(|e| e.path())
.filter(|f| {
f.is_file()
&& f.extension()
.is_some_and(|x| x == "crl" || x == "pem" || x == "der")
})
.collect();
f.sort();
f
} else {
vec![p.to_path_buf()]
};
if files.is_empty() {
return Err(refuse(format!(
"the CRL directory {path} holds no *.crl, *.pem or *.der file"
)));
}
let parsed_cas: Vec<x509_parser::certificate::X509Certificate<'_>> = cas
.iter()
.filter_map(|c| {
x509_parser::parse_x509_certificate(c.as_ref())
.ok()
.map(|(_, c)| c)
})
.collect();
let mut out = Vec::new();
for f in files {
let shown = f.display().to_string();
let bytes =
fs::read(&f).map_err(|e| refuse(format!("the CRL {shown} cannot be read: {e}")))?;
let ders: Vec<CertificateRevocationListDer<'static>> =
if bytes.windows(15).any(|w| w == b"-----BEGIN X509") {
CertificateRevocationListDer::pem_slice_iter(&bytes)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(|e| refuse(format!("the CRL {shown} is not a CRL: {e}")))?
} else {
vec![CertificateRevocationListDer::from(bytes)]
};
if ders.is_empty() {
return Err(refuse(format!(
"the CRL {shown} is not a CRL: it holds no X509 CRL"
)));
}
for der in ders {
let (_, crl) = x509_parser::parse_x509_crl(der.as_ref())
.map_err(|e| refuse(format!("the CRL {shown} is not a CRL: {e}")))?;
let issuer = crl.issuer().to_string();
let ca = parsed_cas
.iter()
.find(|c| c.subject().as_raw() == crl.issuer().as_raw())
.ok_or_else(|| refuse(format!("the CRL {shown} is issued by {issuer:?}, which is not the client CA (`client_ca`)")))?;
crl.verify_signature(ca.public_key()).map_err(|e| {
refuse(format!(
"the CRL {shown} is not signed by the client CA {issuer:?}: {e}"
))
})?;
match crl.next_update() {
None => {
return Err(refuse(format!(
"the CRL {shown} carries no nextUpdate, so it never says it is stale"
)))
}
Some(t) if t.timestamp() <= now => {
return Err(refuse(format!(
"the CRL {shown} expired: its nextUpdate {t} has passed; publish a fresh one"
)))
}
Some(_) => {}
}
out.push(der);
}
}
Ok(out)
}
/// **A client verifier that checks revocation and follows its CRL file** (lane HOL-NCSC).
/// It builds webpki's verifier with the CRLs ([`load_crls`], whole chain, nextUpdate
/// enforced) at the start, and again at the first handshake after the file (or directory)
/// changed. A CRL that cannot be judged at a reload refuses every client certificate (fail
/// closed) until a good one is in place, and says why in the log.
#[derive(Debug)]
struct CrlVerifier {
ca_path: String,
crl_path: String,
allow_unauthenticated: bool,
hints: Vec<rustls::DistinguishedName>,
current: std::sync::RwLock<(String, Built)>,
}
/// A verifier built from the CRL, or why the CRL was refused.
type Built = std::result::Result<Arc<dyn rustls::server::danger::ClientCertVerifier>, String>;
impl CrlVerifier {
fn new(ca_path: &str, crl_path: &str, allow_unauthenticated: bool) -> Result<Self> {
let stamp = Self::stamp(crl_path);
let (inner, hints) = Self::build(ca_path, crl_path, allow_unauthenticated)?;
Ok(Self {
ca_path: ca_path.into(),
crl_path: crl_path.into(),
allow_unauthenticated,
hints,
current: std::sync::RwLock::new((stamp, Ok(inner))),
})
}
fn build(
ca_path: &str,
crl_path: &str,
allow_unauthenticated: bool,
) -> Result<(
Arc<dyn rustls::server::danger::ClientCertVerifier>,
Vec<rustls::DistinguishedName>,
)> {
let cas = TlsSettings::load_certs(ca_path)?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or_default();
let crls = load_crls(crl_path, &cas, now)?;
let mut roots = rustls::RootCertStore::empty();
for c in cas {
roots.add(c).context("adding client CA")?;
}
let hints = roots.subjects();
let b = rustls::server::WebPkiClientVerifier::builder_with_provider(
Arc::new(roots),
TlsSettings::provider(),
)
.with_crls(crls)
.enforce_revocation_expiration();
let b = if allow_unauthenticated {
b.allow_unauthenticated()
} else {
b
};
Ok((b.build().context("building client verifier")?, hints))
}
/// What says the CRL changed: each file's name, length and modification time.
fn stamp(path: &str) -> String {
let one = |p: &std::path::Path| {
fs::metadata(p)
.map(|m| format!("{}:{}:{:?};", p.display(), m.len(), m.modified().ok()))
.unwrap_or_default()
};
let p = std::path::Path::new(path);
if p.is_dir() {
let mut all: Vec<String> = fs::read_dir(p)
.into_iter()
.flatten()
.flatten()
.map(|e| one(&e.path()))
.collect();
all.sort();
all.concat()
} else {
one(p)
}
}
fn inner(
&self,
) -> std::result::Result<Arc<dyn rustls::server::danger::ClientCertVerifier>, rustls::Error>
{
use crate::lock::RwLockOrRecover as _;
let stamp = Self::stamp(&self.crl_path);
{
let cur = self.current.read_or_recover();
if cur.0 == stamp {
return cur.1.clone().map_err(rustls::Error::General);
}
}
let built = match Self::build(&self.ca_path, &self.crl_path, self.allow_unauthenticated) {
Ok((v, _)) => {
log::info!(
"TLS: the client certificates' CRL {} was read again",
self.crl_path
);
Ok(v)
}
Err(e) => {
let why = format!("{e:#}");
log::error!("TLS: the client certificates' CRL {} changed and is refused, so every client certificate is refused until it is fixed: {why}", self.crl_path);
Err(why)
}
};
*self.current.write_or_recover() = (stamp, built.clone());
built.map_err(rustls::Error::General)
}
}
impl rustls::server::danger::ClientCertVerifier for CrlVerifier {
fn client_auth_mandatory(&self) -> bool {
!self.allow_unauthenticated
}
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
&self.hints
}
fn verify_client_cert(
&self,
end_entity: &CertificateDer<'_>,
intermediates: &[CertificateDer<'_>],
now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error> {
self.inner()?
.verify_client_cert(end_entity, intermediates, now)
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
self.inner()?.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
self.inner()?.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
TlsSettings::provider()
.signature_verification_algorithms
.supported_schemes()
}
}
/// **The provider every door of this process is served with, and the process default**
/// (lane WG-PSK, 2026-10-30): rustls's ring provider with X25519MLKEM768 offered FIRST and the
/// classical groups after it (Skidbladnir `nordisk_pairing::pqtls`, Gunnar's front's own). A
/// recording of a handshake cannot be opened later by a quantum computer; a client that does
/// not offer the hybrid group still connects on X25519.
#[cfg(feature = "pq-tls")]
pub fn door_provider() -> rustls::crypto::CryptoProvider {
nordisk_pairing::pqtls::provider()
}
/// [`door_provider`] of a slim shape (no `pq-tls`): ring's classical groups.
#[cfg(not(feature = "pq-tls"))]
pub fn door_provider() -> rustls::crypto::CryptoProvider {
rustls::crypto::ring::default_provider()
}
/// Install [`door_provider`] (ring, X25519MLKEM768 first) as the process default, once.
///
/// Both `ring` and `aws-lc-rs` end up in the dependency tree (via reqwest), so
/// rustls cannot pick a default on its own and panics on first use. Pinning ring
/// makes every rustls consumer in-process (our servers, reqwest) deterministic.
pub fn ensure_crypto_provider() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = door_provider().install_default();
});
}
impl TlsSettings {
/// Build a rustls `ServerConfig` for the HTTP/OCI gateway — TLS 1.3 only.
/// Client certs are requested but NOT required when `client_ca` is set (the
/// public browser door): the app layer enforces identity on writes.
pub fn rustls_gateway_config(&self) -> Result<Arc<ServerConfig>> {
let certs = Self::load_certs(&self.cert)?;
let key = Self::load_key(&self.key)?;
// TLS 1.3 ONLY (stinky SB #39): every browser and package client of
// today speaks it, and 1.2's legacy suites are the downgrade surface.
let builder = ServerConfig::builder_with_provider(Self::provider())
.with_protocol_versions(&[&rustls::version::TLS13])
.context("rustls protocol versions")?;
let mut config = match &self.client_ca {
Some(ca_path) => {
let verifier = self.client_verifier(ca_path, true)?;
builder
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.context("loading server identity")?
}
None => builder
.with_no_client_auth()
.with_single_cert(certs, key)
.context("loading server identity")?,
};
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
/// **The gRPC door's rustls config — TLS 1.3 only** (stinky SB #39). tonic's
/// own `ServerTlsConfig` offers TLS 1.2 too and has no knob for it, so the
/// door terminates TLS on THIS config ([`serve_grpc_tls13`]). With
/// `client_ca` — the box-to-box posture (a paired downstream, an agent) — a
/// client certificate chained to it is REQUIRED in the handshake, unless the
/// operator declared [`TlsSettings::client_auth_optional`] (then a presented
/// one is still verified). ALPN: `h2` for gRPC, `http/1.1` for gRPC-web.
pub fn rustls_grpc_config(&self) -> Result<Arc<ServerConfig>> {
let certs = Self::load_certs(&self.cert)?;
let key = Self::load_key(&self.key)?;
let builder = ServerConfig::builder_with_provider(Self::provider())
.with_protocol_versions(&[&rustls::version::TLS13])
.context("rustls protocol versions")?;
let mut config = match &self.client_ca {
Some(ca_path) => {
let verifier = self.client_verifier(ca_path, self.client_auth_optional)?;
builder
.with_client_cert_verifier(verifier)
.with_single_cert(certs, key)
.context("loading server identity")?
}
None => builder
.with_no_client_auth()
.with_single_cert(certs, key)
.context("loading server identity")?,
};
config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Ok(Arc::new(config))
}
/// Build a tonic `ServerTlsConfig` for the gRPC server. When `client_ca` is
/// set, mTLS is required.
pub fn tonic_config(&self) -> Result<tonic::transport::ServerTlsConfig> {
use tonic::transport::{Certificate, Identity, ServerTlsConfig};
let cert_pem =
fs::read(&self.cert).with_context(|| format!("reading cert {}", self.cert))?;
let key_pem = crate::keyfile::read_private(std::path::Path::new(&self.key), "the TLS key")
.with_context(|| format!("reading key {}", self.key))?;
let mut tls = ServerTlsConfig::new().identity(Identity::from_pem(cert_pem, key_pem));
if let Some(crl) = &self.crl {
anyhow::bail!(holger_errcode::TLS_CRL_REFUSED.tag(format!("tonic's own TLS config cannot check the CRL {crl}; this door serves on `rustls_grpc_config`")));
}
if let Some(ca) = &self.client_ca {
let ca_pem = fs::read(ca).with_context(|| format!("reading client CA {ca}"))?;
tls = tls.client_ca_root(Certificate::from_pem(ca_pem));
// The ONE place the per-repository posture differs from the
// across-the-board one. See [`TlsSettings::client_auth_optional`]: a
// presented certificate is still verified against `client_ca`; only
// its ABSENCE becomes the application layer's problem instead of the
// handshake's.
if self.client_auth_optional {
tls = tls.client_auth_optional(true);
}
}
Ok(tls)
}
}
/// How long one client may take over its TLS handshake before the gRPC door
/// drops it — a slow client must not hold a connection slot open for ever.
pub const GRPC_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// **Serve gRPC (and gRPC-web) over TLS 1.3 only** (stinky SB #39) — the TLS
/// half of `nornir_service::serve_with_tls_web`, on holger's own rustls config
/// ([`TlsSettings::rustls_grpc_config`]) because tonic's offers TLS 1.2. Every
/// handshake runs on a task of its own, bounded by [`GRPC_HANDSHAKE_TIMEOUT`],
/// so a slow or refused client never stalls the accept loop; the TLS stream
/// carries its peer certificates to `Request::peer_certs` exactly as tonic's
/// own acceptor does.
pub async fn serve_grpc_tls13(
routes: tonic::service::Routes,
listener: tokio::net::TcpListener,
config: Arc<ServerConfig>,
audit: Option<Arc<dyn crate::audit::AuditLog>>,
) -> std::result::Result<(), tonic::transport::Error> {
serve_grpc_tls13_allowing(routes, listener, config, audit, None).await
}
/// [`serve_grpc_tls13`] holding every peer to `allow` first (the admin door, HOL-NCSC).
async fn serve_grpc_tls13_allowing(
routes: tonic::service::Routes,
listener: tokio::net::TcpListener,
config: Arc<ServerConfig>,
audit: Option<Arc<dyn crate::audit::AuditLog>>,
allow: Option<Arc<crate::listen::AllowList>>,
) -> std::result::Result<(), tonic::transport::Error> {
let acceptor = tokio_rustls::TlsAcceptor::from(config);
let (tx, rx) = tokio::sync::mpsc::channel::<
std::result::Result<tokio_rustls::server::TlsStream<tokio::net::TcpStream>, std::io::Error>,
>(64);
// A32-14, stays a task: the gRPC TLS accept loop, for the listener's life.
tokio::spawn(async move {
loop {
let (tcp, peer) = match listener.accept().await {
// TCP_NODELAY (HOL-PERF-3): `serve_with_incoming` ignores tonic's own
// `tcp_nodelay`, so the accept loop sets it.
Ok(c) => {
let _ = c.0.set_nodelay(true);
c
}
Err(e) => {
log::warn!("gRPC door: accept failed: {e}");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
};
if let Some(a) = &allow {
if !a.allows(peer.ip()) {
let row = crate::listen::AllowList::refusal(&peer);
match &audit {
Some(l) => crate::refusals::record_to(
l,
"admin listener",
"anonymous",
&peer.to_string(),
&row,
),
None => crate::refusals::record(
"admin listener",
"anonymous",
&peer.to_string(),
&row,
),
}
continue;
}
}
let (acceptor, out, audit) = (acceptor.clone(), tx.clone(), audit.clone());
// A32-14, stays a task: one TLS handshake per connection, bounded by its timeout.
tokio::spawn(async move {
match tokio::time::timeout(GRPC_HANDSHAKE_TIMEOUT, acceptor.accept(tcp)).await {
Ok(Ok(tls)) => {
let _ = out.send(Ok(tls)).await;
}
Ok(Err(e)) => {
log::info!("gRPC door: TLS handshake from {peer} refused: {e}");
crate::refusals::handshake_refused(
audit.as_ref(),
"gRPC door",
&peer.to_string(),
&e.to_string(),
);
}
Err(_) => {
log::info!("gRPC door: TLS handshake from {peer} timed out");
crate::refusals::handshake_refused(
audit.as_ref(),
"gRPC door",
&peer.to_string(),
"no handshake within the door's timeout",
);
}
}
});
if tx.is_closed() {
return;
}
}
});
tonic::transport::Server::builder()
.accept_http1(true)
.layer(tonic_web::GrpcWebLayer::new())
.layer(crate::boundary::DomainLayer)
.add_routes(routes)
.serve_with_incoming(tokio_stream::wrappers::ReceiverStream::new(rx))
.await
}
/// **The admin gRPC door** (lane HOL-NCSC): every accepted peer is held to `allow` (dropped
/// and audited, `holger-listen-4`, when outside it), then TLS 1.3 when `config` is set, as
/// [`serve_grpc_tls13`], or plain h2c with gRPC-web.
pub async fn serve_grpc_admin(
routes: tonic::service::Routes,
listener: tokio::net::TcpListener,
config: Option<Arc<ServerConfig>>,
allow: Arc<crate::listen::AllowList>,
audit: Arc<dyn crate::audit::AuditLog>,
) -> std::result::Result<(), tonic::transport::Error> {
if let Some(config) = config {
return serve_grpc_tls13_allowing(routes, listener, config, Some(audit), Some(allow)).await;
}
let (tx, rx) = tokio::sync::mpsc::channel::<
std::result::Result<tokio::net::TcpStream, std::io::Error>,
>(64);
// A32-14, stays a task: the admin door's accept loop, for the listener's life.
tokio::spawn(async move {
loop {
let (tcp, peer) = match listener.accept().await {
Ok(c) => c,
Err(e) => {
log::warn!("admin gRPC door: accept failed: {e}");
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
};
if !allow.allows(peer.ip()) {
crate::refusals::record_to(
&audit,
"admin listener",
"anonymous",
&peer.to_string(),
&crate::listen::AllowList::refusal(&peer),
);
continue;
}
let _ = tcp.set_nodelay(true);
if tx.send(Ok(tcp)).await.is_err() {
return;
}
}
});
tonic::transport::Server::builder()
.accept_http1(true)
.layer(tonic_web::GrpcWebLayer::new())
.layer(crate::boundary::DomainLayer)
.add_routes(routes)
.serve_with_incoming(tokio_stream::wrappers::ReceiverStream::new(rx))
.await
}
/// Extract the subject Common Name from the leaf of a DER client-cert chain.
/// Used to map an mTLS peer to an identity for [`crate::auth::validate_request`].
pub fn leaf_common_name(chain: &[CertificateDer<'_>]) -> Option<String> {
let leaf = chain.first()?;
let (_, cert) = x509_parser::parse_x509_certificate(leaf.as_ref()).ok()?;
let mut cn = None;
for attr in cert.subject().iter_common_name() {
if let Ok(s) = attr.as_str() {
cn = Some(s.to_string());
break;
}
}
cn
}
#[cfg(test)]
mod tests {
use super::*;
/// One CA, a server leaf for `localhost` and a client leaf, as PEM files in `dir`.
fn material(
dir: &std::path::Path,
) -> (
TlsSettings,
rustls::RootCertStore,
(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>),
) {
let ca_key = rcgen::KeyPair::generate().unwrap();
let mut ca_params = rcgen::CertificateParams::new(vec![]).unwrap();
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
let ca = ca_params.self_signed(&ca_key).unwrap();
let issuer = rcgen::Issuer::new(ca_params, ca_key);
let leaf = |names: Vec<String>, cn: &str| {
let k = rcgen::KeyPair::generate().unwrap();
let mut p = rcgen::CertificateParams::new(names).unwrap();
p.distinguished_name.push(rcgen::DnType::CommonName, cn);
(p.signed_by(&k, &issuer).unwrap(), k)
};
let (server, server_key) = leaf(vec!["localhost".into()], "localhost");
let (client, client_key) = leaf(vec![], "box-b");
std::fs::write(dir.join("server.pem"), server.pem()).unwrap();
std::fs::write(dir.join("server.key"), server_key.serialize_pem()).unwrap();
std::fs::set_permissions(
dir.join("server.key"),
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
std::fs::write(dir.join("ca.pem"), ca.pem()).unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(ca.der().clone()).unwrap();
let settings = TlsSettings {
cert: dir.join("server.pem").display().to_string(),
key: dir.join("server.key").display().to_string(),
client_ca: Some(dir.join("ca.pem").display().to_string()),
client_auth_optional: false,
crl: None,
};
let client_key = PrivateKeyDer::try_from(client_key.serialize_der()).unwrap();
(settings, roots, (vec![client.der().clone()], client_key))
}
/// An in-memory handshake; `Ok(version)` or the error that ended it.
fn handshake(
server: Arc<ServerConfig>,
client: rustls::ClientConfig,
) -> std::result::Result<rustls::ProtocolVersion, String> {
let mut c =
rustls::ClientConnection::new(Arc::new(client), "localhost".try_into().unwrap())
.map_err(|e| e.to_string())?;
let mut s = rustls::ServerConnection::new(server).map_err(|e| e.to_string())?;
for _ in 0..20 {
let mut buf = Vec::new();
while c.wants_write() {
c.write_tls(&mut buf).map_err(|e| e.to_string())?;
}
if !buf.is_empty() {
s.read_tls(&mut &buf[..]).map_err(|e| e.to_string())?;
s.process_new_packets().map_err(|e| e.to_string())?;
}
let mut back = Vec::new();
while s.wants_write() {
s.write_tls(&mut back).map_err(|e| e.to_string())?;
}
if !back.is_empty() {
c.read_tls(&mut &back[..]).map_err(|e| e.to_string())?;
c.process_new_packets().map_err(|e| e.to_string())?;
}
if !c.is_handshaking() && !s.is_handshaking() {
return c.protocol_version().ok_or_else(|| "no version".into());
}
}
Err("the handshake never finished".into())
}
fn client(
versions: &[&'static rustls::SupportedProtocolVersion],
roots: &rustls::RootCertStore,
cert: Option<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)>,
) -> rustls::ClientConfig {
let b = rustls::ClientConfig::builder_with_provider(Arc::new(
rustls::crypto::ring::default_provider(),
))
.with_protocol_versions(versions)
.unwrap()
.with_root_certificates(roots.clone());
match cert {
Some((c, k)) => b.with_client_auth_cert(c, k).unwrap(),
None => b.with_no_client_auth(),
}
}
/// ★ **TLS 1.3 only, on every door** (stinky SB #39): the public HTTP door
/// and the gRPC door refuse a client that speaks TLS 1.2 at most. The PUBLIC
/// (browser) door still takes a client with no certificate; the gRPC door
/// with a `client_ca` — the box-to-box posture — REQUIRES one, unless the
/// operator declared `client_auth_optional`.
#[test]
fn every_door_speaks_tls_1_3_only_and_box_doors_require_a_client_certificate() {
let dir = tempfile::tempdir().unwrap();
let (settings, roots, client_id) = material(dir.path());
let v12: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS12];
let v13: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
let web = settings.rustls_gateway_config().unwrap();
assert!(
handshake(web.clone(), client(v12, &roots, None)).is_err(),
"the public door refuses TLS 1.2"
);
assert_eq!(
handshake(web, client(v13, &roots, None)),
Ok(rustls::ProtocolVersion::TLSv1_3),
"a browser with no certificate is served over TLS 1.3"
);
let grpc = settings.rustls_grpc_config().unwrap();
assert!(
handshake(
grpc.clone(),
client(v12, &roots, Some(client_id.clone_key()))
)
.is_err(),
"the gRPC door refuses TLS 1.2"
);
assert!(
handshake(grpc.clone(), client(v13, &roots, None)).is_err(),
"a box door with a client CA requires a client certificate"
);
assert_eq!(
handshake(grpc, client(v13, &roots, Some(client_id.clone_key()))),
Ok(rustls::ProtocolVersion::TLSv1_3)
);
assert_eq!(
settings.rustls_grpc_config().unwrap().alpn_protocols,
vec![b"h2".to_vec(), b"http/1.1".to_vec()],
"h2 for gRPC, http/1.1 for gRPC-web"
);
let optional = TlsSettings {
client_auth_optional: true,
..settings.clone()
};
assert_eq!(
handshake(
optional.rustls_grpc_config().unwrap(),
client(v13, &roots, None)
),
Ok(rustls::ProtocolVersion::TLSv1_3),
"client_auth_optional admits an absent certificate"
);
let plain = TlsSettings {
client_ca: None,
..settings
};
assert_eq!(
handshake(
plain.rustls_grpc_config().unwrap(),
client(v13, &roots, None)
),
Ok(rustls::ProtocolVersion::TLSv1_3)
);
}
/// ★ **Every door offers X25519MLKEM768 FIRST, X25519 after it** (lane WG-PSK, 2026-10-30:
/// holger.rs negotiated plain X25519 while gunnar.rs negotiates the hybrid). The HTTP/OCI
/// door and the gRPC door take Skidbladnir's post-quantum provider (`nordisk_pairing::pqtls`,
/// Gunnar's own): a client offering only the hybrid group is served with it, and a classical
/// client still connects on X25519 (prefer, never require).
#[cfg(feature = "pq-tls")]
#[test]
fn every_door_offers_x25519mlkem768_first_and_a_classical_client_still_connects() {
let dir = tempfile::tempdir().unwrap();
let (settings, roots, client_id) = material(dir.path());
let v13: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
let hybrid_only = || {
let pq = nordisk_pairing::pqtls::provider();
let provider = rustls::crypto::CryptoProvider {
kx_groups: vec![pq.kx_groups[0]],
..pq
};
rustls::ClientConfig::builder_with_provider(Arc::new(provider))
.with_protocol_versions(v13)
.unwrap()
.with_root_certificates(roots.clone())
};
let doors = [
(
"the HTTP/OCI door",
settings.rustls_gateway_config().unwrap(),
),
("the gRPC door", settings.rustls_grpc_config().unwrap()),
];
for (door, cfg) in doors {
let names: Vec<rustls::NamedGroup> = cfg
.crypto_provider()
.kx_groups
.iter()
.map(|g| g.name())
.collect();
assert_eq!(
names.get(..2),
Some(
&[
rustls::NamedGroup::X25519MLKEM768,
rustls::NamedGroup::X25519
][..]
),
"{door}: {names:?}"
);
let pq = hybrid_only()
.with_client_auth_cert(client_id.0.clone(), client_id.1.clone_key())
.unwrap();
assert_eq!(
handshake_group(cfg.clone(), pq),
Ok(rustls::NamedGroup::X25519MLKEM768),
"{door}: a client offering only the hybrid group"
);
let classical = client(v13, &roots, Some(client_id.clone_key()));
assert_eq!(
handshake_group(cfg, classical),
Ok(rustls::NamedGroup::X25519),
"{door}: a classical client still connects"
);
}
assert_eq!(
door_provider().kx_groups.first().map(|g| g.name()),
Some(rustls::NamedGroup::X25519MLKEM768),
"the process default (tonic's door) is the same provider"
);
}
/// [`handshake`], answering the key exchange group the two ends agreed on.
#[cfg(feature = "pq-tls")]
fn handshake_group(
server: Arc<ServerConfig>,
client: rustls::ClientConfig,
) -> std::result::Result<rustls::NamedGroup, String> {
let mut c =
rustls::ClientConnection::new(Arc::new(client), "localhost".try_into().unwrap())
.map_err(|e| e.to_string())?;
let mut s = rustls::ServerConnection::new(server).map_err(|e| e.to_string())?;
for _ in 0..20 {
let mut buf = Vec::new();
while c.wants_write() {
c.write_tls(&mut buf).map_err(|e| e.to_string())?;
}
if !buf.is_empty() {
s.read_tls(&mut &buf[..]).map_err(|e| e.to_string())?;
s.process_new_packets().map_err(|e| e.to_string())?;
}
let mut back = Vec::new();
while s.wants_write() {
s.write_tls(&mut back).map_err(|e| e.to_string())?;
}
if !back.is_empty() {
c.read_tls(&mut &back[..]).map_err(|e| e.to_string())?;
c.process_new_packets().map_err(|e| e.to_string())?;
}
if !c.is_handshaking() && !s.is_handshaking() {
return c
.negotiated_key_exchange_group()
.map(|g| g.name())
.ok_or_else(|| "no group".into());
}
}
Err("the handshake never finished".into())
}
/// A CA that may sign CRLs, two client leaves (serials 7 and 8) and the server leaf, as
/// PEM files in `dir`; the settings name `crl.pem` there.
struct CrlPki {
settings: TlsSettings,
roots: rustls::RootCertStore,
issuer: rcgen::Issuer<'static, rcgen::KeyPair>,
revoked: (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>),
good: (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>),
}
fn crl_pki(dir: &std::path::Path) -> CrlPki {
let ca_key = rcgen::KeyPair::generate().unwrap();
let mut ca_params = rcgen::CertificateParams::new(vec![]).unwrap();
ca_params
.distinguished_name
.push(rcgen::DnType::CommonName, "holger client CA");
ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
ca_params.key_usages = vec![
rcgen::KeyUsagePurpose::KeyCertSign,
rcgen::KeyUsagePurpose::CrlSign,
rcgen::KeyUsagePurpose::DigitalSignature,
];
let ca = ca_params.self_signed(&ca_key).unwrap();
let issuer = rcgen::Issuer::new(ca_params, ca_key);
let leaf = |names: Vec<String>, cn: &str, serial: u64| {
let k = rcgen::KeyPair::generate().unwrap();
let mut p = rcgen::CertificateParams::new(names).unwrap();
p.distinguished_name.push(rcgen::DnType::CommonName, cn);
p.serial_number = Some(rcgen::SerialNumber::from(serial));
(p.signed_by(&k, &issuer).unwrap(), k)
};
let (server, server_key) = leaf(vec!["localhost".into()], "localhost", 1);
let id = |(c, k): (rcgen::Certificate, rcgen::KeyPair)| {
(
vec![c.der().clone()],
PrivateKeyDer::try_from(k.serialize_der()).unwrap(),
)
};
let revoked = id(leaf(vec![], "box-b", 7));
let good = id(leaf(vec![], "box-c", 8));
std::fs::write(dir.join("server.pem"), server.pem()).unwrap();
std::fs::write(dir.join("server.key"), server_key.serialize_pem()).unwrap();
std::fs::set_permissions(
dir.join("server.key"),
<std::fs::Permissions as std::os::unix::fs::PermissionsExt>::from_mode(0o600),
)
.unwrap();
std::fs::write(dir.join("ca.pem"), ca.pem()).unwrap();
let mut roots = rustls::RootCertStore::empty();
roots.add(ca.der().clone()).unwrap();
let settings = TlsSettings {
cert: dir.join("server.pem").display().to_string(),
key: dir.join("server.key").display().to_string(),
client_ca: Some(dir.join("ca.pem").display().to_string()),
client_auth_optional: false,
crl: Some(dir.join("crl.pem").display().to_string()),
};
CrlPki {
settings,
roots,
issuer,
revoked,
good,
}
}
/// A CRL by `issuer` revoking `serials`, valid until 1 January of `until`, at `path`.
fn write_crl(
issuer: &rcgen::Issuer<'static, rcgen::KeyPair>,
path: &std::path::Path,
serials: &[u64],
until: i32,
) {
let params = rcgen::CertificateRevocationListParams {
this_update: rcgen::date_time_ymd(2020, 1, 1),
next_update: rcgen::date_time_ymd(until, 1, 1),
crl_number: rcgen::SerialNumber::from(u64::try_from(until).unwrap()),
issuing_distribution_point: None,
revoked_certs: serials
.iter()
.map(|s| rcgen::RevokedCertParams {
serial_number: rcgen::SerialNumber::from(*s),
revocation_time: rcgen::date_time_ymd(2021, 1, 1),
reason_code: Some(rcgen::RevocationReason::KeyCompromise),
invalidity_date: None,
})
.collect(),
key_identifier_method: rcgen::KeyIdMethod::Sha256,
};
std::fs::write(path, params.signed_by(issuer).unwrap().pem().unwrap()).unwrap();
}
/// ★ HOL-NCSC (NCSC P3): **a revoked client certificate fails the handshake** at the gRPC
/// door and the HTTP door (where a client certificate is optional, a revoked one is still
/// refused); a certificate the CRL does not name is served.
#[test]
fn a_revoked_client_certificate_fails_the_handshake_at_every_door() {
let dir = tempfile::tempdir().unwrap();
let p = crl_pki(dir.path());
write_crl(&p.issuer, &dir.path().join("crl.pem"), &[7], 2099);
let v13: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
for (door, cfg) in [
("the gRPC door", p.settings.rustls_grpc_config().unwrap()),
("the HTTP door", p.settings.rustls_gateway_config().unwrap()),
] {
let e = handshake(
cfg.clone(),
client(v13, &p.roots, Some(p.revoked.clone_key())),
)
.unwrap_err();
assert!(e.to_lowercase().contains("revoked"), "{door}: {e}");
assert_eq!(
handshake(cfg, client(v13, &p.roots, Some(p.good.clone_key()))),
Ok(rustls::ProtocolVersion::TLSv1_3),
"{door}"
);
}
}
/// ★ **A CRL that cannot be trusted is refused at the start, by name** (`holger-tls-1`):
/// a missing file, one that is not a CRL, an expired one (its nextUpdate passed) and one
/// the client CA did not sign. Fail-closed: no door starts on it.
#[test]
fn a_crl_that_cannot_be_trusted_refuses_the_start() {
let dir = tempfile::tempdir().unwrap();
let p = crl_pki(dir.path());
let refused = |why: &str| {
for e in [
p.settings.rustls_grpc_config().err(),
p.settings.rustls_gateway_config().err(),
] {
let e = format!("{:#}", e.expect("refused"));
assert!(
e.contains("[holger-tls-1]") && e.contains(why),
"{why}: {e}"
);
}
};
refused("crl.pem");
std::fs::write(dir.path().join("crl.pem"), b"not a CRL").unwrap();
refused("not a CRL");
write_crl(&p.issuer, &dir.path().join("crl.pem"), &[7], 2021);
refused("expired");
let other = tempfile::tempdir().unwrap();
let q = crl_pki(other.path());
write_crl(&q.issuer, &dir.path().join("crl.pem"), &[7], 2099);
refused("client CA");
}
/// ★ **A new CRL is read at the next handshake**, no restart: the door built before the
/// revocation refuses the certificate once the file names it.
#[test]
fn a_new_crl_is_read_at_the_next_handshake() {
let dir = tempfile::tempdir().unwrap();
let p = crl_pki(dir.path());
write_crl(&p.issuer, &dir.path().join("crl.pem"), &[], 2098);
let v13: &[&'static rustls::SupportedProtocolVersion] = &[&rustls::version::TLS13];
let grpc = p.settings.rustls_grpc_config().unwrap();
assert_eq!(
handshake(
grpc.clone(),
client(v13, &p.roots, Some(p.revoked.clone_key()))
),
Ok(rustls::ProtocolVersion::TLSv1_3),
"not revoked yet"
);
write_crl(&p.issuer, &dir.path().join("crl.pem"), &[7], 2099);
let e = handshake(grpc, client(v13, &p.roots, Some(p.revoked.clone_key()))).unwrap_err();
assert!(e.to_lowercase().contains("revoked"), "{e}");
}
trait CloneKey {
fn clone_key(&self) -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>);
}
impl CloneKey for (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>) {
fn clone_key(&self) -> (Vec<CertificateDer<'static>>, PrivateKeyDer<'static>) {
(self.0.clone(), self.1.clone_key())
}
}
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
#[test]
fn extracts_common_name_from_cert() {
let mut params = rcgen::CertificateParams::new(vec![]).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, "writer-bot");
let kp = rcgen::KeyPair::generate().unwrap();
let cert = params.self_signed(&kp).unwrap();
let der = cert.der().clone();
let cn = leaf_common_name(&[der]);
assert_eq!(cn.as_deref(), Some("writer-bot"));
#[cfg(feature = "testmatrix")]
fstatus(
"tls",
"extracts_common_name_from_cert",
cn.as_deref() == Some("writer-bot"),
&format!("leaf CN = {cn:?}"),
);
}
#[test]
fn empty_chain_yields_no_cn() {
let cn = leaf_common_name(&[]);
assert_eq!(cn, None);
#[cfg(feature = "testmatrix")]
fstatus(
"tls",
"empty_chain_yields_no_cn",
cn.is_none(),
&format!("empty cert chain -> CN {cn:?}"),
);
}
/// A self-signed leaf carrying `cn`, as DER.
fn cert_with_cn(cn: &str) -> CertificateDer<'static> {
let mut params = rcgen::CertificateParams::new(vec![]).unwrap();
params
.distinguished_name
.push(rcgen::DnType::CommonName, cn);
let kp = rcgen::KeyPair::generate().unwrap();
params.self_signed(&kp).unwrap().der().clone()
}
/// **The identity is the LEAF's CN, never a CA's.** A client presents its own
/// certificate first and the issuing chain behind it; reading any other
/// element would map every client of one CA onto a single identity — one
/// shared principal for the whole fleet, and RBAC would stop distinguishing
/// anybody.
#[test]
fn leaf_common_name_reads_the_first_cert_in_the_chain() {
let leaf = cert_with_cn("writer-bot");
let intermediate = cert_with_cn("holger-issuing-ca");
let root = cert_with_cn("holger-root-ca");
let cn = leaf_common_name(&[leaf, intermediate, root]);
assert_eq!(
cn.as_deref(),
Some("writer-bot"),
"the leaf's CN is the identity"
);
#[cfg(feature = "testmatrix")]
fstatus(
"tls",
"leaf_common_name_reads_the_first_cert_in_the_chain",
cn.as_deref() == Some("writer-bot"),
&format!("3-cert chain -> CN {cn:?}"),
);
}
/// A certificate that names no CN, and bytes that are not a certificate at
/// all, both yield `None` — no identity, and no panic on the TLS accept path
/// (where the peer controls the bytes).
#[test]
fn a_cn_less_or_unparseable_certificate_yields_no_identity() {
let mut params = rcgen::CertificateParams::new(vec!["holger.example".to_string()]).unwrap();
// An EMPTY subject: the certificate identifies itself by SAN only.
params.distinguished_name = rcgen::DistinguishedName::new();
let kp = rcgen::KeyPair::generate().unwrap();
let no_cn = params.self_signed(&kp).unwrap().der().clone();
assert_eq!(leaf_common_name(&[no_cn]), None, "a SAN is not a CN");
let garbage = CertificateDer::from(vec![0x30, 0x00, 0xde, 0xad, 0xbe, 0xef]);
assert_eq!(
leaf_common_name(&[garbage]),
None,
"unparseable DER is no identity"
);
#[cfg(feature = "testmatrix")]
fstatus(
"tls",
"a_cn_less_or_unparseable_certificate_yields_no_identity",
true,
"no CN and garbage DER both map to None",
);
}
/// mTLS is enabled by the presence of a client CA and by nothing else — the
/// one switch that decides whether the gRPC transport REQUIRES a client
/// certificate.
#[test]
fn mtls_is_enabled_by_the_client_ca_and_nothing_else() {
let base = TlsSettings {
cert: "/etc/holger/server.pem".into(),
key: "/etc/holger/server.key".into(),
client_ca: None,
client_auth_optional: false,
crl: None,
};
assert!(!base.mtls_enabled(), "no client CA ⇒ no mTLS");
let mtls = TlsSettings {
client_ca: Some("/etc/holger/ca.pem".into()),
..base.clone()
};
assert!(mtls.mtls_enabled());
}
/// A TLS block pointing at files that are not there fails LOUDLY on both
/// transports, naming the file — a server must never start with a silently
/// empty identity and serve cleartext or an anonymous handshake instead.
#[test]
fn missing_or_unreadable_tls_material_is_an_error_not_a_silent_downgrade() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("nope.pem");
let s = TlsSettings {
cert: missing.display().to_string(),
key: missing.display().to_string(),
client_ca: None,
client_auth_optional: false,
crl: None,
};
let e = s
.rustls_gateway_config()
.expect_err("a missing cert must fail the gateway");
assert!(e.to_string().contains("reading cert"), "{e:#}");
let e = s
.tonic_config()
.expect_err("a missing cert must fail the gRPC transport");
assert!(e.to_string().contains("reading cert"), "{e:#}");
// A file that exists but holds no certificate is refused just as loudly,
// rather than yielding an empty chain.
let empty = dir.path().join("empty.pem");
std::fs::write(&empty, b"# nothing here\n").unwrap();
let s = TlsSettings {
cert: empty.display().to_string(),
key: empty.display().to_string(),
client_ca: None,
client_auth_optional: false,
crl: None,
};
let e = s
.rustls_gateway_config()
.expect_err("an empty PEM must fail");
assert!(e.to_string().contains("no certificates found"), "{e:#}");
}
}