wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
This commit is contained in:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -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.5".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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user