The code was hand-formatted -- close to rustfmt's output but not it, mostly in keeping chains and call arguments on one line where the formatter would break them. That is a per-line decision every future change has to make again, and reproducing it would mean a config whose only job is to preserve how the code already looks. So this is `cargo fmt` at its defaults, with no rustfmt.toml, which is where the sibling dev-updater checkout already sits: it is clean at the defaults today, so the two repos now agree on layout without either of them configuring it. Formatting only -- no behaviour, no renames, nothing reordered. Verified after: cargo test (35 pass), cargo clippy --all-targets clean, cargo fmt --check clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
199 lines
7.9 KiB
Rust
199 lines
7.9 KiB
Rust
//! The TLS certificates this server presents, generated in process on
|
|
//! first start.
|
|
//!
|
|
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
|
|
//! setup step to remember, a second place for the "which SANs?" answer to
|
|
//! live, and a dependency on whatever openssl was installed. Doing it here
|
|
//! means the server can simply ensure its own certificates exist, with the
|
|
//! file modes and extensions it wants, and with the address it is actually
|
|
//! about to bind already in the leaf.
|
|
//!
|
|
//! The split that matters is between the two:
|
|
//!
|
|
//! - The **CA** is generated once and then left alone. The app
|
|
//! pins it, so replacing it strands every installed copy -- recovery is
|
|
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
|
|
//! here that is a one-way door.
|
|
//! - The **leaf** is cheap and reissued on every start, signed by that
|
|
//! same unchanged CA. Nothing pins it, so covering a new address is just
|
|
//! a restart rather than anything the phone has to be told about.
|
|
//!
|
|
//! Everything is written owner-only into a directory outside the repo (see
|
|
//! `config_home`): the repo is a mount shared with a VM that is not
|
|
//! trusted, and a CA private key that VM can read is one it can sign with
|
|
//! -- a certificate signed by a pinned CA is accepted without question,
|
|
//! which is exactly the attack pinning exists to stop.
|
|
|
|
use std::net::IpAddr;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
use rcgen::{
|
|
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
|
};
|
|
|
|
use crate::private;
|
|
|
|
/// Where the leaf lives, for handing to the TLS listener.
|
|
pub struct Certificates {
|
|
pub leaf_cert: PathBuf,
|
|
pub leaf_key: PathBuf,
|
|
/// True when the CA was created just now, i.e. anything already
|
|
/// installed pins the wrong one and has to be reinstalled.
|
|
pub ca_is_new: bool,
|
|
}
|
|
|
|
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
|
|
/// is missing. Safe to call on every start.
|
|
pub fn ensure(dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
|
private::create_dir(dir)?;
|
|
|
|
let ca_cert_path = dir.join("ca.pem");
|
|
let ca_key_path = dir.join("ca-key.pem");
|
|
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
|
|
|
|
let (ca_pem, ca_key_pem) = if ca_is_new {
|
|
let (pem, key) = generate_ca()?;
|
|
private::write_file(&ca_key_path, key.as_bytes())?;
|
|
private::write_file(&ca_cert_path, pem.as_bytes())?;
|
|
tracing::info!("generated a new CA in {}", dir.display());
|
|
(pem, key)
|
|
} else {
|
|
(
|
|
std::fs::read_to_string(&ca_cert_path)
|
|
.with_context(|| format!("read {}", ca_cert_path.display()))?,
|
|
std::fs::read_to_string(&ca_key_path)
|
|
.with_context(|| format!("read {}", ca_key_path.display()))?,
|
|
)
|
|
};
|
|
|
|
let (leaf_pem, leaf_key_pem) = generate_leaf(&ca_pem, &ca_key_pem, addresses)?;
|
|
let leaf_cert = dir.join("leaf.pem");
|
|
let leaf_key = dir.join("leaf-key.pem");
|
|
private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
|
private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
|
|
|
Ok(Certificates {
|
|
leaf_cert,
|
|
leaf_key,
|
|
ca_is_new,
|
|
})
|
|
}
|
|
|
|
fn generate_ca() -> Result<(String, String)> {
|
|
let key = KeyPair::generate().context("generate CA key")?;
|
|
let mut params = CertificateParams::default();
|
|
params
|
|
.distinguished_name
|
|
.push(DnType::OrganizationName, "ai-app dev");
|
|
params
|
|
.distinguished_name
|
|
.push(DnType::CommonName, "ai-app dev CA");
|
|
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
|
// Explicit, because strict verifiers reject a CA without them -- and
|
|
// that rejection surfaces as an opaque handshake failure on a phone.
|
|
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
|
let certificate = params.self_signed(&key).context("self-sign CA")?;
|
|
Ok((certificate.pem(), key.serialize_pem()))
|
|
}
|
|
|
|
fn generate_leaf(ca_pem: &str, ca_key_pem: &str, addresses: &[IpAddr]) -> Result<(String, String)> {
|
|
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
|
|
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
|
|
|
|
let key = KeyPair::generate().context("generate leaf key")?;
|
|
let mut params = CertificateParams::default();
|
|
params
|
|
.distinguished_name
|
|
.push(DnType::OrganizationName, "ai-app dev");
|
|
params.distinguished_name.push(
|
|
DnType::CommonName,
|
|
addresses
|
|
.first()
|
|
.map(|a| a.to_string())
|
|
.unwrap_or_else(|| "ai-app".to_string()),
|
|
);
|
|
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
|
params.is_ca = IsCa::ExplicitNoCa;
|
|
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
|
params.use_authority_key_identifier_extension = true;
|
|
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
|
|
Ok((certificate.pem(), key.serialize_pem()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
fn addresses() -> Vec<IpAddr> {
|
|
vec!["10.66.0.1".parse().unwrap(), "127.0.0.1".parse().unwrap()]
|
|
}
|
|
|
|
#[test]
|
|
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
let first = ensure(dir.path(), &addresses()).expect("generate");
|
|
assert!(first.ca_is_new);
|
|
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
|
|
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
|
|
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
|
|
|
|
let second = ensure(dir.path(), &addresses()).expect("regenerate");
|
|
// The CA is the pinned one: replacing it would strand every
|
|
// installed app, so it must survive a restart untouched.
|
|
assert!(!second.ca_is_new);
|
|
assert_eq!(
|
|
ca,
|
|
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
|
|
);
|
|
// The leaf is not pinned, and is reissued so a new address is just
|
|
// a restart away.
|
|
assert_ne!(
|
|
leaf,
|
|
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn everything_is_owner_only() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
|
assert_eq!(
|
|
std::fs::metadata(dir.path())
|
|
.expect("dir")
|
|
.permissions()
|
|
.mode()
|
|
& 0o777,
|
|
0o700,
|
|
);
|
|
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
|
let mode = std::fs::metadata(dir.path().join(file))
|
|
.expect(file)
|
|
.permissions()
|
|
.mode();
|
|
assert_eq!(mode & 0o777, 0o600, "{file} is not owner-only");
|
|
}
|
|
assert!(certs.leaf_key.is_file());
|
|
}
|
|
|
|
/// The pair has to be loadable by the TLS stack that will actually
|
|
/// serve it -- a "file exists" check wouldn't catch a key that doesn't
|
|
/// match its certificate, which fails at the first handshake instead.
|
|
#[tokio::test]
|
|
async fn the_leaf_loads_into_the_real_tls_config() {
|
|
// main() installs this; tests don't run main. Both rustls crypto
|
|
// providers are in the graph (ureq brings ring, axum-server
|
|
// aws-lc-rs), so rustls refuses to pick one on its own. Ignoring
|
|
// the result because another test may have installed it first.
|
|
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let certs = ensure(dir.path(), &addresses()).expect("generate");
|
|
axum_server::tls_rustls::RustlsConfig::from_pem_file(&certs.leaf_cert, &certs.leaf_key)
|
|
.await
|
|
.expect("the generated leaf and key should load as a TLS identity");
|
|
}
|
|
}
|