crates/gunnar-core/src/issuer.rs
//! **The server is its own certificate issuer** (the certificate-identity plan).
//!
//! There is no external PKI to run and no second system to keep in step:
//! `gunnar issuer create` mints a CA, and after that *creating a user is issuing
//! their certificate*.
//!
//! ```text
//! gunnar issuer create mint the CA
//! gunnar issuer import --cert C --key K adopt one that exists
//! gunnar issuer show what is live
//! gunnar principal add alice --csr alice.csr alice's key stays with alice
//! gunnar principal add alice --server-generated-key the convenient, weaker form
//! ```
//!
//! # Where each half of the issuer lives, and why they are not together
//!
//! | half | where | why |
//! |---|---|---|
//! | the **certificate** | a control document in redb, [`ISSUER_REF`] | it is public, and it is what the trusted-CA list needs |
//! | the **private key** | the [`SecretStore`], sealed under the KEK, at [`gunnar_store::secret::ISSUER_PRIVATE_KEY`] | a key that can mint any user is the one secret whose loss is total |
//!
//! An attacker holding the issuer key does not have to *break* authentication.
//! They issue themselves an administrator. So it goes exactly where the SSH host
//! key already goes — through [`crate::seal::SealedSecretStore`], which encrypts
//! on the way in — and **never into redb in the clear**. `tests/issuer.rs`
//! asserts that by reading the control database file back off disk as bytes and
//! searching it, rather than by asking an API that declines to return the key.
//!
//! # Exactly one issuer
//!
//! Not a list, not a default among several. A second issuer is a second answer
//! to *who may mint a user*, and the failure mode of two is that nobody can say
//! which one signed a given client. [`Pki::create`] on a server that has one is
//! **refused** — [`Error::IssuerExists`] — not silently ignored. Replacing it is
//! `issuer rotate`, a different verb, because it invalidates every certificate
//! in the field.
//!
//! # The trusted-CA list stays a list, and the issuer is one entry in it
//!
//! Verifying against many and minting with one are different questions and must
//! not share a field. A rotation needs the old CA and the new one trusted at the
//! same instant, or every client in the field is locked out at the swap. So
//! [`PkiDoc`] has two fields: `issuer`, an `Option` of exactly one, and
//! `trusted`, a list. They ride in **one document** so that "the issuer was
//! created" and "the issuer is trusted" are one compare-and-swap and cannot be
//! interrupted between — the same reason `BootstrapRecord` sits inside
//! `AuthDoc`.
//!
//! # Why `rcgen` and not `cert-helper`
//!
//! The certificate-identity plan names `cert-helper`, and `bench/src/trust.rs`
//! uses it. It cannot be used *here*: `cert-helper` is built on `openssl`, and
//! `openssl`/`openssl-sys` are on `xtask::guard::FORBIDDEN_ANYWHERE` — the
//! dependency budget bans them from both halves of the build, and the appliance
//! is a static musl binary in a distroless image with no system libssl to link
//! against. `bench/` is a detached workspace the guard does not resolve, which
//! is why `trust` may keep its openssl and this may not.
//!
//! That split is worth more than it costs. `trust` and the tests in
//! `tests/issuer.rs` verify what this module mints using **`openssl` the
//! binary**, which shares no code with `rcgen`. A library confirming its own
//! output is the circularity that lets a wrong chain through.
//!
//! # The server decides every field except the public key
//!
//! A CSR is a request, not an instruction. [`Pki::issue_from_csr`] takes exactly
//! one thing out of it — the **subject public key**, which is the whole point of
//! a CSR — and overwrites everything else: the subject name becomes the
//! principal that was asked for, the SANs are dropped, `is_ca` is forced off,
//! and the key usages are set to client authentication. A CSR that claims
//! `CN=admin` while `gunnar principal add alice` is running produces a
//! certificate for **alice**, and `tests/issuer.rs` reads the issued subject
//! back with `openssl x509 -subject` to prove it.
use std::sync::Arc;
use rcgen::{
BasicConstraints, CertificateParams, CertificateSigningRequestParams, DistinguishedName,
DnType, DnValue, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose, SerialNumber,
};
use time::{Duration, OffsetDateTime};
use crate::auth::{certificate_der, fingerprint_of_certificate, Fingerprint};
use crate::control::ControlStore;
use crate::doc::DocRef;
use crate::error::{Error, Result};
use crate::model::{now_ms, UnixMillis};
use gunnar_store::secret::ISSUER_PRIVATE_KEY;
use gunnar_store::{SecretStore, SecretStoreExt};
/// The control document holding the issuer certificate and the trusted-CA list.
///
/// Named `refs/gunnar/…` like [`crate::AUTH_REF`] and
/// [`crate::CATALOG_REF`]: these stopped being git references when the control
/// plane moved to redb (the certificate-identity plan), but they are what every error message and
/// runbook calls these documents.
pub const ISSUER_REF: &str = "refs/gunnar/pki";
/// How long a freshly minted issuer is good for, in days.
///
/// Ten years. An issuer that expires takes every certificate in the field with
/// it, and the recovery is a rotation of the whole estate — so the default is
/// long enough that it is a planned event rather than an outage, and
/// `--days` is there for a deployment with a shorter policy.
pub const DEFAULT_ISSUER_DAYS: u32 = 3650;
/// How long an issued client certificate is good for, in days.
pub const DEFAULT_LEAF_DAYS: u32 = 365;
/// A serial number is 20 bytes of CSPRNG (RFC 5280 allows at most 20).
const SERIAL_LEN: usize = 20;
// ── what is stored ───────────────────────────────────────────────────────────
/// How the live issuer got here.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssuerOrigin {
/// `gunnar issuer create` — the server minted it and has never seen another
/// copy of the key.
Created,
/// `gunnar issuer import` — the key arrived from somewhere else, so
/// somewhere else may still have it.
Imported,
}
impl std::fmt::Display for IssuerOrigin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
IssuerOrigin::Created => "created",
IssuerOrigin::Imported => "imported",
})
}
}
/// The live issuer, as [`Pki::issuer`] reports it.
///
/// The **certificate only**. There is deliberately no field here that could
/// carry the private key: the type an operator prints, a gRPC response
/// serialises and a log line might capture must be structurally incapable of
/// holding it.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Issuer {
/// Its subject common name.
pub subject: String,
/// The certificate, PEM-armoured. Public by definition.
pub cert_pem: String,
/// [`fingerprint_of_certificate`] over its DER — the same `SHA256:` space
/// every other credential in gunnar lives in.
pub fingerprint: Fingerprint,
/// When this server adopted it.
pub created: UnixMillis,
/// Minted here, or handed to us.
pub origin: IssuerOrigin,
}
/// One entry in the trusted-CA list.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TrustedCa {
/// An operator-chosen label. Never part of the credential.
pub label: String,
/// The CA certificate, PEM-armoured.
pub cert_pem: String,
/// Its fingerprint, which is what `untrust` takes.
pub fingerprint: Fingerprint,
/// When it was added.
pub added: UnixMillis,
/// **This entry is the server's own issuer.** Set by [`Pki::create`] and
/// [`Pki::import`], and what stops [`Pki::untrust_ca`] from removing the CA
/// the server is currently minting with.
pub is_issuer: bool,
}
/// A certificate this server issued.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Issued {
/// The principal it was issued for. Equal to [`Issued::common_name`] by
/// construction — the certificate-identity plan takes the identity from the CN.
pub principal_id: String,
/// The subject common name that was actually written into the certificate.
pub common_name: String,
/// The certificate, PEM-armoured.
pub cert_pem: String,
/// Its DER, which is what [`crate::AuthTable::register_certificate`] takes.
pub cert_der: Vec<u8>,
/// Its fingerprint.
pub fingerprint: Fingerprint,
/// **The private key, and only for `--server-generated-key`.**
///
/// `None` on the CSR path, and that `None` is the security property: on that
/// path the key never existed here, so there is nothing to hand back, log,
/// or leak. See [`Pki::issue_with_server_key`] for what taking `Some` costs.
pub key_pem: Option<String>,
}
/// The stored document: one issuer, and a list of trusted CAs.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct PkiDoc {
/// `None` until `issuer create` or `issuer import` has run.
#[serde(default, skip_serializing_if = "Option::is_none")]
issuer: Option<Issuer>,
/// Every CA a client certificate may chain to. The issuer is one of them.
#[serde(default)]
trusted: Vec<TrustedCa>,
}
/// No sharded maps, and that is a decision rather than an oversight.
///
/// [`crate::AuthTable`]'s maps are sharded because a forge holds every
/// principal, key and grant on the server in one document and was parsing all
/// of it per connection. This document holds **one** issuer and, in the
/// worst case a rotation produces, two or three CAs. A trie over three rows is
/// a shard probe and a second blob fetch to save nothing.
impl crate::doc::Doc for PkiDoc {
fn bind(&mut self, _store: &Arc<dyn ControlStore>) {}
fn flush(&mut self) -> Result<()> {
Ok(())
}
}
// ── the issuer ───────────────────────────────────────────────────────────────
/// **The server's certificate authority**: create it, import it, and issue with
/// it.
///
/// Cheap to clone; every clone is the same document and the same secret store.
#[derive(Clone)]
pub struct Pki {
doc: DocRef<PkiDoc>,
secrets: Arc<dyn SecretStore>,
}
impl std::fmt::Debug for Pki {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Pki")
}
}
impl Pki {
/// Open the PKI held in `control`, with private keys in `secrets`.
///
/// **`secrets` must already be sealed.** Hand it the
/// [`SealedSecretStore`](crate::seal::SealedSecretStore) the server built at
/// boot, never a raw backend: this type writes the one key on the machine
/// that is worth stealing, and it cannot tell from the trait object whether
/// what it wrote was encrypted. That is why `ServerStores::pki()` is the
/// only constructor call on the server's boot path.
pub fn new(control: Arc<dyn ControlStore>, secrets: Arc<dyn SecretStore>) -> Result<Self> {
Ok(Pki {
doc: DocRef::new(control, ISSUER_REF)?,
secrets,
})
}
/// **A [`Pki`] that can read the issuer and the CA list and cannot reach a
/// key**, for a process that carries no storage engine.
///
/// The whole point of moving the control plane to redb (the certificate-identity plan) is that
/// the catalog and the identity table are readable without an engine. The
/// issuer *certificate* is a control document and belongs to that set;
/// `gunnar issuer show` must therefore work on a client-shaped binary. The
/// private key does not — it is in the secret log inside the control
/// repository, which does need an engine.
///
/// So this is a constructor and not a flag: the store it installs refuses
/// every call, which makes "`show` cannot touch the key" a property of the
/// value rather than of whoever remembered not to call `create` on it.
pub fn read_only(control: Arc<dyn ControlStore>) -> Result<Self> {
Pki::new(control, Arc::new(NoSecrets))
}
/// The name this PKI is stored under.
pub fn ref_name(&self) -> &str {
self.doc.ref_name()
}
/// The live issuer, or `None` on a server that has never had one.
pub fn issuer(&self) -> Result<Option<Issuer>> {
Ok(self.doc.load()?.issuer.clone())
}
/// Every CA a client certificate may chain to, oldest first.
pub fn trusted_cas(&self) -> Result<Vec<TrustedCa>> {
Ok(self.doc.load()?.trusted.clone())
}
/// **Mint this server's issuer.** Refused if it already has one.
///
/// # The ordering, and what a crash between the two writes leaves behind
///
/// The private key goes in first, through
/// [`SecretStore::get_or_create`], which is atomic across racing callers —
/// so two concurrent `issuer create` calls mint **one** key and both see the
/// same bytes. Then the certificate is committed with the document's
/// compare-and-swap, which is where the second caller is refused.
///
/// A crash between the two leaves a key with no certificate. That is the
/// harmless direction: [`Pki::issuer`] still says `None`, the next `issuer
/// create` adopts the orphaned key rather than minting a second one, and no
/// certificate anybody holds was invalidated. The other order — certificate
/// first — would leave a live issuer whose key cannot be found, and every
/// `principal add` after it would fail.
pub fn create(&self, subject: &str, valid_days: u32) -> Result<Issuer> {
if let Some(existing) = self.issuer()? {
return Err(Error::IssuerExists {
subject: existing.subject,
fingerprint: existing.fingerprint.as_str().to_owned(),
});
}
let key_pem = self.ensure_issuer_key()?;
let key_pair = parse_key(&key_pem)?;
let mut params = CertificateParams::default();
params.distinguished_name = distinguished_name(subject);
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
params.serial_number = Some(random_serial()?);
let (not_before, not_after) = validity(valid_days)?;
params.not_before = not_before;
params.not_after = not_after;
let certificate = params
.self_signed(&key_pair)
.map_err(|err| Error::Pki(format!("self-signing the issuer: {err}")))?;
let cert_pem = certificate.pem();
let issuer = Issuer {
subject: subject.to_owned(),
fingerprint: fingerprint_of_certificate(certificate.der()),
cert_pem: cert_pem.clone(),
created: now_ms(),
origin: IssuerOrigin::Created,
};
self.install(issuer)
}
/// **Adopt an issuer that already exists.** Refused if this server has one.
///
/// Both halves are checked against each other before anything is written: a
/// certificate whose SubjectPublicKeyInfo is not this key's public key is
/// **refused**, because the alternative is a server that accepts the pair,
/// stores both, and only discovers on the first `principal add` that it can
/// mint nothing — by which time the operator has moved on.
pub fn import(&self, cert_pem: &str, key_pem: &str) -> Result<Issuer> {
if let Some(existing) = self.issuer()? {
return Err(Error::IssuerExists {
subject: existing.subject,
fingerprint: existing.fingerprint.as_str().to_owned(),
});
}
let key_pair = parse_key(key_pem)?;
let der = certificate_der(cert_pem.as_bytes())?;
let spki = subject_public_key_info(&der)?;
if spki != rcgen::PublicKeyData::subject_public_key_info(&key_pair) {
return Err(Error::Pki(
"this certificate and this private key are not each other's: the certificate's \
SubjectPublicKeyInfo is not the public half of the key. Nothing was stored"
.to_owned(),
));
}
// Parsed so an unusable certificate is refused here rather than on the
// first `principal add`. `signing_handle` reloads it exactly this way.
rcgen::Issuer::from_ca_cert_pem(cert_pem, &key_pair)
.map_err(|err| Error::Pki(format!("reading the imported issuer certificate: {err}")))?;
let subject =
certificate_common_name(&der)?.unwrap_or_else(|| "<no common name>".to_owned());
let issuer = Issuer {
subject,
fingerprint: fingerprint_of_certificate(&der),
cert_pem: cert_pem.to_owned(),
created: now_ms(),
origin: IssuerOrigin::Imported,
};
// The certificate first here, and the key second, which is the opposite
// of `create` — because the key is the caller's and `get_or_create`
// would silently keep an orphan from an interrupted earlier attempt
// instead of storing what was handed over. The compare-and-swap is what
// makes "this server has no issuer" true at the instant of the write, so
// the key that lands afterwards cannot overwrite a live issuer's.
let installed = self.install(issuer)?;
self.secrets
.put(ISSUER_PRIVATE_KEY, key_pem.as_bytes())
.map_err(Error::from)?;
Ok(installed)
}
/// Commit an issuer into the document, and add it to the trusted-CA list in
/// the same compare-and-swap.
fn install(&self, issuer: Issuer) -> Result<Issuer> {
let entry = TrustedCa {
label: issuer.subject.clone(),
cert_pem: issuer.cert_pem.clone(),
fingerprint: issuer.fingerprint.clone(),
added: issuer.created,
is_issuer: true,
};
self.doc.update(|doc| {
// Re-checked INSIDE the transaction. The check at the top of
// `create` is the good error message; this one is the guarantee.
// `DocRef::update` re-reads and re-runs on a lost race, so a second
// caller that got past the first check finds the winner here.
if let Some(existing) = &doc.issuer {
return Err(Error::IssuerExists {
subject: existing.subject.clone(),
fingerprint: existing.fingerprint.as_str().to_owned(),
});
}
doc.issuer = Some(issuer.clone());
doc.trusted.retain(|ca| ca.fingerprint != entry.fingerprint);
doc.trusted.push(entry.clone());
Ok(issuer.clone())
})
}
/// Trust a CA this server does not mint with.
///
/// The other half of a rotation: old and new both trusted while clients are
/// re-issued. Re-adding one already in the list replaces its label rather
/// than duplicating the entry.
pub fn trust_ca(&self, label: &str, cert_pem: &str) -> Result<TrustedCa> {
let der = certificate_der(cert_pem.as_bytes())?;
x509_parser::parse_x509_certificate(&der)
.map_err(|err| Error::Pki(format!("reading the CA certificate: {err}")))?;
let entry = TrustedCa {
label: label.to_owned(),
cert_pem: cert_pem.to_owned(),
fingerprint: fingerprint_of_certificate(&der),
added: now_ms(),
is_issuer: false,
};
self.doc.update(|doc| {
let is_issuer = doc
.issuer
.as_ref()
.is_some_and(|i| i.fingerprint == entry.fingerprint);
let mut entry = entry.clone();
entry.is_issuer = is_issuer;
doc.trusted.retain(|ca| ca.fingerprint != entry.fingerprint);
doc.trusted.push(entry.clone());
Ok(entry)
})
}
/// Stop trusting a CA. `Ok(None)` if it was not in the list.
///
/// **Refuses to remove the live issuer's own entry.** A server that mints
/// with a CA it does not trust issues certificates it will then refuse, and
/// nothing about the failure would point back here. Rotate first, which
/// changes which entry is the issuer, and then remove the old one.
pub fn untrust_ca(&self, fingerprint: &Fingerprint) -> Result<Option<TrustedCa>> {
self.doc.update(|doc| {
let Some(at) = doc
.trusted
.iter()
.position(|ca| &ca.fingerprint == fingerprint)
else {
return Ok(None);
};
if doc.trusted[at].is_issuer {
return Err(Error::Pki(format!(
"{fingerprint} is this server's own issuer; removing it from the trusted \
list would leave a server minting certificates it refuses. Rotate the \
issuer first"
)));
}
Ok(Some(doc.trusted.remove(at)))
})
}
/// **Issue a certificate from a CSR.** The default, and the strong form.
///
/// `common_name` is the principal the operator asked for, and it is what
/// lands in the subject — **not** whatever the CSR claimed. See the module
/// documentation: the only thing taken out of the request is the public key.
///
/// The private key never existed on this server, so [`Issued::key_pem`] is
/// `None` and there is nothing here to leak.
pub fn issue_from_csr(
&self,
common_name: &str,
csr_pem: &str,
valid_days: u32,
) -> Result<Issued> {
// Parsing VERIFIES the request's self-signature (rcgen's
// `CertificateSigningRequestParams::from_der` calls `verify_signature`),
// which is the proof of possession: a CSR carrying somebody else's
// public key does not sign, and does not get here.
let mut request = CertificateSigningRequestParams::from_pem(csr_pem).map_err(|err| {
Error::Pki(format!(
"this is not a usable certificate signing request: {err}. A CSR that does not \
verify against its own public key proves nothing about who holds the private \
half, which is the only thing it exists to prove"
))
})?;
harden(&mut request.params, common_name, valid_days)?;
let issuer = self.signing_handle()?;
let certificate = request
.signed_by(&issuer)
.map_err(|err| Error::Pki(format!("signing the request for {common_name}: {err}")))?;
Ok(self.issued(
common_name,
certificate.pem(),
certificate.der().to_vec(),
None,
))
}
/// **Issue a certificate whose private key this server generated.**
///
/// Named `--server-generated-key` on the command line rather than being what
/// an operator gets by not thinking about it, because it is the weaker
/// shape and the weakness is not visible from the outside:
///
/// * the key existed here and travelled over the wire, so the server could
/// have kept a copy and nothing in the protocol proves it did not;
/// * it is in a log, a core dump or a proxy's buffer if anything upstream is
/// careless;
/// * *"only alice could have signed this"* stops being true.
///
/// It exists because a machine account being bootstrapped, or a human who
/// cannot run the tooling, genuinely needs it.
pub fn issue_with_server_key(&self, common_name: &str, valid_days: u32) -> Result<Issued> {
let key_pair = KeyPair::generate()
.map_err(|err| Error::Pki(format!("minting a key for {common_name}: {err}")))?;
let mut params = CertificateParams::default();
harden(&mut params, common_name, valid_days)?;
let issuer = self.signing_handle()?;
let certificate = params
.signed_by(&key_pair, &issuer)
.map_err(|err| Error::Pki(format!("signing a certificate for {common_name}: {err}")))?;
Ok(self.issued(
common_name,
certificate.pem(),
certificate.der().to_vec(),
Some(key_pair.serialize_pem()),
))
}
/// Mint a **server** identity for the smart-HTTP listener: leaf certificate
/// with `hosts` as subject alternative names, signed by this issuer, key
/// generated here and never stored.
///
/// Deliberately NOT [`harden`], which is the CLIENT shape — it clears SANs
/// and pins the EKU to `ClientAuth` precisely so a minted client
/// certificate can never be presented as a server for a host it does not
/// own. A server identity is the opposite trade: SANs are the whole point
/// (a TLS client matches the name it dialled against them) and the EKU is
/// `ServerAuth` and nothing else, so this certificate cannot come back in
/// a client's role either. `is_ca` stays forced off.
///
/// This is what makes gunnar a **single executable with its own TLS**: the
/// appliance already mints its issuer on first start, clients already pin
/// that issuer (`gunnar issuer show --pem`), and this leaf closes the
/// chain without an operator ever touching a PEM.
pub fn issue_server_identity(&self, hosts: &[String], valid_days: u32) -> Result<Issued> {
if hosts.is_empty() {
return Err(Error::Pki(
"a server certificate needs at least one host name or address for its subject \
alternative names; a SAN-less server certificate matches nothing a client dials"
.to_owned(),
));
}
let key_pair = KeyPair::generate()
.map_err(|err| Error::Pki(format!("minting a server key: {err}")))?;
let common_name = &hosts[0];
let mut params = CertificateParams::new(hosts.to_vec())
.map_err(|err| Error::Pki(format!("server certificate SANs {hosts:?}: {err}")))?;
params.distinguished_name = distinguished_name(common_name);
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
params.use_authority_key_identifier_extension = true;
params.serial_number = Some(random_serial()?);
let now = OffsetDateTime::now_utc();
params.not_before = now - NOT_BEFORE_SLACK;
params.not_after = now + Duration::days(i64::from(valid_days));
let issuer = self.signing_handle()?;
let certificate = params
.signed_by(&key_pair, &issuer)
.map_err(|err| Error::Pki(format!("signing the server certificate: {err}")))?;
Ok(self.issued(
common_name,
certificate.pem(),
certificate.der().to_vec(),
Some(key_pair.serialize_pem()),
))
}
fn issued(
&self,
common_name: &str,
cert_pem: String,
cert_der: Vec<u8>,
key_pem: Option<String>,
) -> Issued {
Issued {
principal_id: common_name.to_owned(),
common_name: common_name.to_owned(),
fingerprint: fingerprint_of_certificate(&cert_der),
cert_pem,
cert_der,
key_pem,
}
}
/// The issuer certificate and its private key, ready to sign with.
///
/// The certificate is rebuilt from the stored PEM rather than kept in
/// memory: `signed_by` reads the issuer's distinguished name, key usages and
/// key-identifier method off it, and those must come from what is on disk —
/// otherwise a certificate issued after a restart could chain to something
/// slightly different from what the CA list hands out.
fn signing_handle(&self) -> Result<rcgen::Issuer<'static, KeyPair>> {
let issuer = self.issuer()?.ok_or(Error::NoIssuer)?;
let key_pem = self
.secrets
.get(ISSUER_PRIVATE_KEY)
.map_err(Error::from)?
.ok_or_else(|| {
Error::Pki(format!(
"this server has an issuer certificate ({}) but no private key for it at \
{ISSUER_PRIVATE_KEY}. It can verify certificates and issue none",
issuer.subject
))
})?;
let key_pem = String::from_utf8(key_pem)
.map_err(|_| Error::Pki("the stored issuer key is not utf-8 PEM".to_owned()))?;
let key_pair = parse_key(&key_pem)?;
let der = certificate_der(issuer.cert_pem.as_bytes())?;
if subject_public_key_info(&der)?
!= rcgen::PublicKeyData::subject_public_key_info(&key_pair)
{
return Err(Error::Pki(
"the stored issuer certificate and the stored issuer key do not go together"
.to_owned(),
));
}
rcgen::Issuer::from_ca_cert_pem(&issuer.cert_pem, key_pair)
.map_err(|err| Error::Pki(format!("reading the stored issuer certificate: {err}")))
}
/// The issuer's private key, minting one on first use.
///
/// Atomic across racing callers, exactly as
/// [`crate::host_key::ensure_host_key`] is: two `issuer create` calls at
/// once produce one key, not two.
fn ensure_issuer_key(&self) -> Result<String> {
let bytes = self
.secrets
.get_or_create_with(ISSUER_PRIVATE_KEY, || {
tracing::info!(
key = ISSUER_PRIVATE_KEY,
"no issuer private key stored; minting one. It is sealed under the KEK and \
never enters the control database"
);
let pair = KeyPair::generate()
.map_err(|err| gunnar_store::Error::backend(format!("{err}")))?;
Ok(pair.serialize_pem().into_bytes())
})
.map_err(Error::from)?;
String::from_utf8(bytes)
.map_err(|_| Error::Pki("the stored issuer key is not utf-8 PEM".to_owned()))
}
}
/// The secret store [`Pki::read_only`] installs: it refuses everything.
///
/// Not a store that answers `None`. `None` means *no issuer key is stored*,
/// which a caller would reasonably act on by minting one — and a `create` that
/// silently succeeded against a store that discards writes would leave a server
/// holding a certificate it can never sign with.
struct NoSecrets;
impl NoSecrets {
fn refuse() -> gunnar_store::Error {
gunnar_store::Error::backend(
"this handle can read the issuer certificate and the trusted-CA list and cannot \
reach a private key. Issuing needs the sealed secret store, and this handle was \
built without one on purpose",
)
}
}
impl SecretStore for NoSecrets {
fn get(&self, _key: &str) -> gunnar_store::Result<Option<Vec<u8>>> {
Err(Self::refuse())
}
fn put(&self, _key: &str, _value: &[u8]) -> gunnar_store::Result<()> {
Err(Self::refuse())
}
fn delete(&self, _key: &str) -> gunnar_store::Result<()> {
Err(Self::refuse())
}
fn get_or_create(
&self,
_key: &str,
_mk: &mut dyn FnMut() -> gunnar_store::Result<Vec<u8>>,
) -> gunnar_store::Result<Vec<u8>> {
Err(Self::refuse())
}
}
// ── the parts that decide what a certificate says ────────────────────────────
/// Overwrite everything a CSR may have asked for except the public key.
///
/// This is the whole of "the server decides". Every field set here is one an
/// unchecked CSR could otherwise have chosen for itself:
///
/// * **the subject** becomes `common_name`, so a CSR claiming `CN=admin` is
/// issued as whoever the operator named;
/// * **subject alternative names are dropped**, because a `dNSName` SAN is how a
/// client certificate becomes usable as a *server* certificate for a host it
/// does not own;
/// * **`is_ca` is forced off** — a CSR carrying `basicConstraints CA:TRUE` would
/// otherwise ask for the right to mint users;
/// * **key usage** is digital signature and client authentication and nothing
/// else, so a stolen client certificate cannot be presented in another role.
fn harden(params: &mut CertificateParams, common_name: &str, valid_days: u32) -> Result<()> {
if common_name.is_empty() {
return Err(Error::Pki(
"a certificate needs a common name; the certificate-identity scheme \
takes the identity from it"
.to_owned(),
));
}
params.distinguished_name = distinguished_name(common_name);
params.subject_alt_names.clear();
params.is_ca = IsCa::ExplicitNoCa;
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ClientAuth];
params.name_constraints = None;
params.custom_extensions.clear();
params.crl_distribution_points.clear();
params.use_authority_key_identifier_extension = true;
params.serial_number = Some(random_serial()?);
let (not_before, not_after) = validity(valid_days)?;
params.not_before = not_before;
params.not_after = not_after;
Ok(())
}
/// A distinguished name carrying one common name, and nothing else.
fn distinguished_name(common_name: &str) -> DistinguishedName {
let mut dn = DistinguishedName::new();
dn.push(DnType::CommonName, common_name);
dn
}
/// The common name out of a distinguished name, if it has one.
///
/// Public because the *claimed* name in a CSR is a thing a caller may want to
/// log or refuse on — `gunnar-cli` prints it when it differs from the principal
/// being issued, so an operator can see that a request asked for something else.
pub fn common_name(dn: &DistinguishedName) -> Option<String> {
match dn.get(&DnType::CommonName)? {
DnValue::Utf8String(text) => Some(text.clone()),
DnValue::PrintableString(text) => Some(text.as_str().to_owned()),
DnValue::Ia5String(text) => Some(text.as_str().to_owned()),
DnValue::TeletexString(text) => Some(String::from_utf8_lossy(text.as_bytes()).into_owned()),
// BMPString and UniversalString are UCS-2 and UTF-32; gunnar never
// writes either, and a principal id is not expressible in them without
// a transcoding step that would be a second definition of "the name".
// `None` here means "this certificate does not name a principal gunnar
// can match", which is the refusing direction.
_ => None,
}
}
/// The common name a certificate signing request claims, for the caller that
/// wants to report it. Never what gets issued — see [`harden`].
pub fn csr_claimed_common_name(csr_pem: &str) -> Result<Option<String>> {
let request = CertificateSigningRequestParams::from_pem(csr_pem)
.map_err(|err| Error::Pki(format!("this is not a usable request: {err}")))?;
Ok(common_name(&request.params.distinguished_name))
}
/// `[now, now + days)`, from the wall clock.
/// How far back a certificate's `not_before` is set (see
/// `not_before_tests`): a day, so a box whose clock is stepped back after it
/// minted still holds valid certificates.
pub const NOT_BEFORE_SLACK: Duration = Duration::hours(24);
fn validity(valid_days: u32) -> Result<(OffsetDateTime, OffsetDateTime)> {
if valid_days == 0 {
return Err(Error::Pki(
"a certificate valid for zero days is expired the moment it is signed".to_owned(),
));
}
let now = OffsetDateTime::now_utc();
let until = now
.checked_add(Duration::days(i64::from(valid_days)))
.ok_or_else(|| Error::Pki(format!("{valid_days} days from now is not a date")))?;
Ok((now - NOT_BEFORE_SLACK, until))
}
/// 20 bytes from the system CSPRNG.
///
/// rcgen's default is the SHA-256 of the subject public key, which is stable
/// **per key** — so re-issuing the same principal from the same CSR would
/// produce two certificates sharing one serial, and a serial is what a CRL
/// names. Random is what makes revocation able to name one of them.
fn random_serial() -> Result<SerialNumber> {
use aws_lc_rs::rand::{SecureRandom as _, SystemRandom};
let mut bytes = [0u8; SERIAL_LEN];
SystemRandom::new().fill(&mut bytes).map_err(|_| {
Error::Pki("the system CSPRNG refused to produce a serial number".to_owned())
})?;
// The most significant bit clear, so it encodes as a positive 20-byte
// integer rather than as 21 bytes with a leading zero (RFC 5280 §4.1.2.2
// caps a serial at 20 octets).
bytes[0] &= 0x7f;
Ok(SerialNumber::from_slice(&bytes))
}
fn parse_key(pem: &str) -> Result<KeyPair> {
KeyPair::from_pem(pem)
.map_err(|err| Error::Pki(format!("this is not a usable private key: {err}")))
}
/// The common name a certificate's subject carries (DER), if any.
fn certificate_common_name(der: &[u8]) -> Result<Option<String>> {
let (_rest, cert) = x509_parser::parse_x509_certificate(der)
.map_err(|err| Error::Pki(format!("this is not a usable X.509 certificate: {err}")))?;
let name = cert
.subject()
.iter_common_name()
.next()
.and_then(|cn| cn.as_str().ok())
.map(str::to_owned);
Ok(name)
}
/// The SubjectPublicKeyInfo DER out of a certificate, byte-for-byte as it was
/// encoded, so it can be compared with a key's (`rcgen::PublicKeyData`).
fn subject_public_key_info(der: &[u8]) -> Result<Vec<u8>> {
let (_rest, cert) = x509_parser::parse_x509_certificate(der)
.map_err(|err| Error::Pki(format!("this is not a usable X.509 certificate: {err}")))?;
Ok(cert.tbs_certificate.subject_pki.raw.to_vec())
}
/// **Mint a key pair and a certificate signing request for it, HERE.**
///
/// The other half of [`Pki::issue_from_csr`], for a box that has to obtain a
/// client certificate from ANOTHER box's issuer — the twin, which is installed
/// from the same medium as its primary and so carries nothing per box: its
/// certificate has to be minted at pairing, and its private key has to be born
/// on the machine that will use it and never cross the wire. What crosses is
/// this request, which carries the public half and a self-signature that
/// proves possession; `issue_from_csr` verifies that signature and overrides
/// the subject, so `common_name` is a courtesy and never an authority.
///
/// Returns `(private key PEM, request PEM)`. ECDSA P-256 — rcgen's default and
/// what the issuer's own leaves use, so the two ends negotiate one signature
/// scheme.
pub fn mint_client_request(common_name: &str) -> Result<(String, String)> {
let key =
KeyPair::generate().map_err(|err| Error::Pki(format!("minting a key pair: {err}")))?;
let mut params = CertificateParams::new(Vec::<String>::new())
.map_err(|err| Error::Pki(format!("building a signing request: {err}")))?;
params.distinguished_name = DistinguishedName::new();
params
.distinguished_name
.push(DnType::CommonName, common_name);
let request = params
.serialize_request(&key)
.map_err(|err| Error::Pki(format!("signing the request with its own key: {err}")))?;
let pem = request
.pem()
.map_err(|err| Error::Pki(format!("armouring the signing request: {err}")))?;
Ok((key.serialize_pem(), pem))
}
/// The SubjectPublicKeyInfo of a PEM private key, DER — to prove a certificate
/// handed to this box was issued for THIS box's key.
pub fn public_key_info_of_key(key_pem: &str) -> Result<Vec<u8>> {
Ok(rcgen::PublicKeyData::subject_public_key_info(&parse_key(
key_pem,
)?))
}
/// The SubjectPublicKeyInfo a PEM certificate carries, DER.
pub fn public_key_info_of_cert(cert_pem: &str) -> Result<Vec<u8>> {
let (_rest, block) = x509_parser::pem::parse_x509_pem(cert_pem.as_bytes())
.map_err(|err| Error::Pki(format!("this is not a usable PEM certificate: {err}")))?;
subject_public_key_info(&block.contents)
}
/// The SubjectPublicKeyInfo a DER certificate carries, DER — which KEY a
/// registered certificate row (`AuthTable`'s `x509` rows keep the DER) was
/// issued for.
pub fn public_key_info_of_der(cert_der: &[u8]) -> Result<Vec<u8>> {
subject_public_key_info(cert_der)
}
/// The DER inside the first PEM block of `pem`.
pub fn pem_to_der(pem: &str) -> Result<Vec<u8>> {
let (_rest, block) = x509_parser::pem::parse_x509_pem(pem.as_bytes())
.map_err(|err| Error::Pki(format!("this is not a usable PEM certificate: {err}")))?;
Ok(block.contents)
}
/// Did the CA in `ca_pem` issue the certificate in `cert_pem`, **by name**?
///
/// A name comparison, not a signature check: it exists to refuse a CA/leaf
/// pair that was handed over mismatched, which would otherwise surface months
/// later as a handshake failure naming neither. The signature is checked where
/// it matters — by rustls, at every handshake, against the pinned CA.
/// `Err` carries both names so the refusal can print them.
pub fn issued_by(
cert_pem: &str,
ca_pem: &str,
) -> Result<std::result::Result<(), (String, String)>> {
let (cert_der, ca_der) = (pem_to_der(cert_pem)?, pem_to_der(ca_pem)?);
let parse = |der: &[u8], what: &str| {
x509_parser::parse_x509_certificate(der)
.map(|(_, c)| {
(
c.issuer().as_raw().to_vec(),
c.issuer().to_string(),
c.subject().as_raw().to_vec(),
c.subject().to_string(),
)
})
.map_err(|err| Error::Pki(format!("{what} is not a usable X.509 certificate: {err}")))
};
let (leaf_issuer, leaf_issuer_name, _, _) = parse(&cert_der, "the certificate")?;
let (_, _, ca_subject, ca_subject_name) = parse(&ca_der, "the issuer")?;
Ok(if leaf_issuer == ca_subject {
Ok(())
} else {
Err((leaf_issuer_name, ca_subject_name))
})
}
/// `notAfter` of a DER certificate, Unix milliseconds.
pub fn not_after_ms(der: &[u8]) -> Result<UnixMillis> {
let (_rest, cert) = x509_parser::parse_x509_certificate(der)
.map_err(|err| Error::Pki(format!("this is not a usable X.509 certificate: {err}")))?;
Ok(cert.validity().not_after.timestamp() * 1000)
}
#[cfg(test)]
mod not_before_tests {
use super::*;
/// **An estate's boxes mint their issuer and server identity at FIRST
/// OPEN, before the estate clock steps them** (MEASURED on mock-upcloud-kvm
/// 2026-09-29, topic recharge: guests boot +2 h like UpCloud without
/// kvmclock; main minted its issuer at 10:33, the front's clock stepped it
/// to 08:34, and the twin refused the primary's replication listener
/// `certificate not valid yet` for good). A certificate is valid from a day
/// before it is signed — the lifetime is unchanged — as Skidbladnir
/// nordisk-twin's NOT_BEFORE_SLACK (4613616) does for holger.
#[test]
fn a_certificate_minted_before_the_clock_stepped_back_is_already_valid() {
let (nb, na) = validity(1).unwrap();
let now = OffsetDateTime::now_utc();
assert!(
nb <= now - Duration::hours(23),
"not_before {nb} is not a day back from {now}"
);
assert!(
na >= now + Duration::hours(23),
"the lifetime is unchanged: {na}"
);
assert_eq!(NOT_BEFORE_SLACK, Duration::hours(24));
}
}