Make the crate do what its documentation already claimed

Review from ai-app's session, acted on.

**A test that asserted nothing.** `a_missing_tunnel_explains_itself`
returned early whenever `wg0` was up -- which it is on this VM and on the
host, so it passed everywhere and never once checked the message. The same
shape of test that let the file-permissions bug survive three codebases.
`netif` now splits the lookup from the decision: `wg_address_among` and
`addresses_among` take the interfaces, so the failure is reachable by
handing in an empty list rather than by hoping the machine has no tunnel.
Four tests where there were two, including that an IPv6-only `wg0` is not
an answer.

**Two modules the docs promised and the crate did not have.** `certs`,
which is the piece where being written twice is worst -- a trust anchor
built two ways can be built differently two ways, and the difference
reaches a phone as an opaque handshake failure. And `format`, the RON
house rules, which were byte-identical in both projects and so the
clearest thing in the evidence table. `product` names the certificate and
is the whole of what is per-project; the test decodes the DER and looks
for it there rather than trusting what was passed in.

**The README title still said wg-server-app**, three commits after
everything else was renamed.

Two judgement calls promoted from silent to written down, both of which
would otherwise be inherited rather than chosen: `WG_INTERFACE` is a
constant because *these* projects have one tunnel, which is the first
thing a third user should expect to change; and `local_addresses` puts
the emulator's host alias in every certificate, a SAN for an address the
machine does not own.

And a review heuristic the day kept proving: when you find a rule stated,
grep for its siblings. Three bugs today were the correct rule already
written down and applied to one member of a set.
This commit is contained in:
iris committed 2026-08-28 14:34:48 -04:00
1 parent 592114bfc9
commit 841a3a8372
7 files changed
+1055 -40

No files matched your search

+239
View File
@@ -0,0 +1,239 @@
//! The TLS certificates a server presents, generated in process on first
//! start, from a CA the app pins.
//!
//! Shared because it existed twice and differed by an organisation name.
//! It is also the piece where being written twice is worst: a trust
//! anchor built two ways can be built differently two ways, and the
//! difference would surface as an opaque handshake failure on a phone.
//!
//! `product` names the certificate's organisation and common name, and is
//! the whole of what is per-project.
//!
//! 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 updater 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`): a CA private key readable by another machine is one it
//! can sign with, and a certificate signed by a pinned CA is accepted
//! without question.
use std::net::IpAddr;
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(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
crate::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(product)?;
crate::private::write_file(&ca_key_path, key.as_bytes())?;
crate::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(product, &ca_pem, &ca_key_pem, addresses)?;
let leaf_cert = dir.join("leaf.pem");
let leaf_key = dir.join("leaf-key.pem");
crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
Ok(Certificates {
leaf_cert,
leaf_key,
ca_is_new,
})
}
fn generate_ca(product: &str) -> Result<(String, String)> {
let key = KeyPair::generate().context("generate CA key")?;
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::OrganizationName, format!("{product} dev"));
params
.distinguished_name
.push(DnType::CommonName, format!("{product} 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(
product: &str,
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, format!("{product} dev"));
params.distinguished_name.push(
DnType::CommonName,
addresses
.first()
.map(|a| a.to_string())
.unwrap_or_else(|| product.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 std::os::unix::fs::PermissionsExt;
use super::*;
fn decode_pem(pem: &str) -> Vec<u8> {
use base64::Engine;
let body: String = pem
.lines()
.filter(|line| !line.starts_with("-----"))
.collect();
base64::engine::general_purpose::STANDARD
.decode(body)
.expect("base64")
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn addresses() -> Vec<IpAddr> {
vec![
"192.168.1.168".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("demo", 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("demo", dir.path(), &addresses()).expect("regenerate");
// The CA is the pinned one: replacing it strands 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 a
// restart away rather than a reinstall.
assert_ne!(
leaf,
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
);
}
/// Private keys are rewritten on every start, which is the case that
/// makes the mode matter: `private` narrows a file that already
/// exists, and this is the caller that depends on it.
#[test]
fn everything_is_owner_only_even_on_the_second_start() {
let dir = tempfile::tempdir().expect("tempdir");
ensure("demo", dir.path(), &addresses()).expect("generate");
// What an older version could have left behind.
for file in ["leaf.pem", "leaf-key.pem"] {
std::fs::set_permissions(
dir.path().join(file),
std::fs::Permissions::from_mode(0o644),
)
.expect("chmod");
}
ensure("demo", dir.path(), &addresses()).expect("reissue");
let mode = |path: std::path::PathBuf| {
std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777
};
assert_eq!(mode(dir.path().to_path_buf()), 0o700);
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
assert_eq!(
mode(dir.path().join(file)),
0o600,
"{file} is not owner-only"
);
}
}
/// The product name is the whole of what is per-project, so it has to
/// reach the certificate rather than being decoration.
#[test]
fn the_product_names_the_certificate() {
let dir = tempfile::tempdir().expect("tempdir");
ensure("dev-updater", dir.path(), &addresses()).expect("generate");
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
// Decoded rather than searched as base64: a distinguished name is
// stored in the DER as literal bytes, so this checks the value
// that actually reaches a phone rather than the value we passed in.
let der = decode_pem(&ca);
assert!(
contains(&der, b"dev-updater dev CA"),
"the CA's common name"
);
assert!(contains(&der, b"dev-updater dev"), "the organisation");
}
}