Generate the TLS certificates in process

gen-dev-cert.sh is gone. The server ensures its own certificates on start,
which removes a setup step to remember, a dependency on whatever openssl
was installed, and a second place for the "which addresses?" answer to
live -- the leaf now covers every local IPv4 plus loopback and the
emulator's host alias, so nobody maintains a hardcoded IP.

The split that mattered in the script is kept and now enforced by tests:
the CA is generated once and left alone, because the app pins it and
replacing it strands every installed copy; the leaf is cheap and reissued
every start, so covering a new address is a restart. Both are written
owner-only into a directory outside the repo.

Two things the tests caught. DirBuilder's mode applies only when the
directory is created, so a directory that already existed kept whatever
permissions it had while holding a private key -- the mode is now set
explicitly, in the session directories too. And loading the leaf into the
real RustlsConfig needs the crypto provider installed, which main does but
tests don't.

Verified end to end: deleted the certs, started the server, watched it
generate a CA and warn that installed apps now pin the wrong one, rebuilt
the APK against the new CA, and reinstalled -- the emulator connects over
a certificate that never existed as a pasted constant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-25 04:36:54 -04:00
1 parent eeb2dde4ab
commit 65743b899d
8 files changed
+550 -175

No files matched your search

+202
View File
@@ -0,0 +1,202 @@
//! 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::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rcgen::{
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
/// 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> {
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.with_context(|| format!("create {}", dir.display()))?;
// Set explicitly as well: `mode` applies only when the directory is
// created, so a directory that already existed -- made by hand, or by
// an older version -- would otherwise keep whatever permissions it had
// while holding a private key.
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.with_context(|| format!("restrict {}", dir.display()))?;
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()?;
write_private(&ca_key_path, &key)?;
write_private(&ca_cert_path, &pem)?;
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");
write_private(&leaf_key, &leaf_key_pem)?;
write_private(&leaf_cert, &leaf_pem)?;
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(|| "local-updater".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()))
}
/// Writes owner-readable only, from the moment the file exists rather than
/// a `chmod` afterwards.
fn write_private(path: &Path, contents: &str) -> Result<()> {
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("write {}", path.display()))?;
file.write_all(contents.as_bytes())
.with_context(|| format!("write {}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
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");
}
}