Skip to main content

agentmux_srv/backend/viewer/
cert.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The viewer listener's certificate: self-signed ECDSA P-256, made once and
5//! kept in this instance's data directory, so its fingerprint (the one a
6//! paired device pinned from the QR) survives restarts. No CA is involved and
7//! nothing about the certificate itself is trusted; only the fingerprint is.
8
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use sha2::{Digest, Sha256};
13
14/// Where the certificate and its key live under the data directory.
15const DIR_NAME: &str = "viewer";
16const CERT_FILE: &str = "cert.der";
17const KEY_FILE: &str = "key.der";
18
19/// The certificate in use, its key and its fingerprint.
20pub struct ViewerTls {
21    pub cert_der: Vec<u8>,
22    key_der: Vec<u8>,
23    /// Lowercase hex SHA-256 of `cert_der`, as the QR carries it.
24    pub fingerprint: String,
25}
26
27impl ViewerTls {
28    /// A rustls server configuration for this certificate, HTTP/1.1 only.
29    pub fn server_config(&self) -> Result<Arc<rustls::ServerConfig>, String> {
30        use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
31        let provider = Arc::new(rustls::crypto::ring::default_provider());
32        let mut config = rustls::ServerConfig::builder_with_provider(provider)
33            .with_safe_default_protocol_versions()
34            .map_err(|e| e.to_string())?
35            .with_no_client_auth()
36            .with_single_cert(
37                vec![CertificateDer::from(self.cert_der.clone())],
38                PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(self.key_der.clone())),
39            )
40            .map_err(|e| e.to_string())?;
41        config.alpn_protocols = vec![b"http/1.1".to_vec()];
42        Ok(Arc::new(config))
43    }
44}
45
46/// Lowercase hex SHA-256 of a certificate's DER bytes.
47pub fn fingerprint(cert_der: &[u8]) -> String {
48    hex::encode(Sha256::digest(cert_der))
49}
50
51/// The certificate directory under `data_dir`.
52pub fn cert_dir(data_dir: &Path) -> PathBuf {
53    data_dir.join(DIR_NAME)
54}
55
56/// Load the certificate kept under `data_dir`, or make and keep a new one when
57/// there is none or what is there can't be used.
58pub fn load_or_create(data_dir: &Path) -> Result<ViewerTls, String> {
59    let dir = cert_dir(data_dir);
60    match load(&dir) {
61        Ok(Some(tls)) => return Ok(tls),
62        Ok(None) => {}
63        Err(e) => tracing::warn!(error = %e, "viewer certificate unreadable; making a new one (paired devices must pair again)"),
64    }
65    let tls = generate()?;
66    store(&dir, &tls).map_err(|e| format!("could not keep the viewer certificate: {e}"))?;
67    tracing::info!(fingerprint = %tls.fingerprint, "viewer certificate created");
68    Ok(tls)
69}
70
71fn load(dir: &Path) -> Result<Option<ViewerTls>, String> {
72    let (cert, key) = (dir.join(CERT_FILE), dir.join(KEY_FILE));
73    if !cert.exists() && !key.exists() {
74        return Ok(None);
75    }
76    let cert_der = std::fs::read(&cert).map_err(|e| e.to_string())?;
77    let key_der = std::fs::read(&key).map_err(|e| e.to_string())?;
78    let tls = ViewerTls { fingerprint: fingerprint(&cert_der), cert_der, key_der };
79    // A pair that doesn't make a working configuration (truncated file, a key
80    // from another certificate) is as good as none.
81    tls.server_config()?;
82    Ok(Some(tls))
83}
84
85fn generate() -> Result<ViewerTls, String> {
86    let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).map_err(|e| e.to_string())?;
87    let mut params = rcgen::CertificateParams::new(vec!["agentmux-viewer".to_string()]).map_err(|e| e.to_string())?;
88    params.distinguished_name = rcgen::DistinguishedName::new();
89    params.distinguished_name.push(rcgen::DnType::CommonName, "AgentMux viewer");
90    let cert = params.self_signed(&key).map_err(|e| e.to_string())?;
91    let cert_der = cert.der().to_vec();
92    Ok(ViewerTls { fingerprint: fingerprint(&cert_der), cert_der, key_der: key.serialize_der() })
93}
94
95/// Write both files through a temporary name, the key readable by this user
96/// only where the platform has modes.
97fn store(dir: &Path, tls: &ViewerTls) -> std::io::Result<()> {
98    std::fs::create_dir_all(dir)?;
99    write_atomic(&dir.join(KEY_FILE), &tls.key_der, true)?;
100    write_atomic(&dir.join(CERT_FILE), &tls.cert_der, false)
101}
102
103fn write_atomic(path: &Path, bytes: &[u8], private: bool) -> std::io::Result<()> {
104    let tmp = path.with_extension("tmp");
105    {
106        let mut opts = std::fs::OpenOptions::new();
107        opts.write(true).create(true).truncate(true);
108        #[cfg(unix)]
109        if private {
110            use std::os::unix::fs::OpenOptionsExt;
111            opts.mode(0o600);
112        }
113        #[cfg(not(unix))]
114        let _ = private;
115        use std::io::Write;
116        let mut f = opts.open(&tmp)?;
117        f.write_all(bytes)?;
118        f.sync_all()?;
119    }
120    std::fs::rename(&tmp, path)
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn the_fingerprint_is_lowercase_hex_sha256_of_the_der() {
129        let fp = fingerprint(b"abc");
130        assert_eq!(fp, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
131        assert_eq!(fp.len(), 64);
132        assert!(fp.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
133    }
134
135    #[test]
136    fn a_certificate_is_made_once_and_reused() {
137        let dir = tempfile::tempdir().unwrap();
138        let first = load_or_create(dir.path()).unwrap();
139        assert_eq!(first.fingerprint, fingerprint(&first.cert_der));
140        assert!(cert_dir(dir.path()).join(CERT_FILE).exists());
141        assert!(cert_dir(dir.path()).join(KEY_FILE).exists());
142        first.server_config().expect("a usable rustls configuration");
143
144        let second = load_or_create(dir.path()).unwrap();
145        assert_eq!(second.fingerprint, first.fingerprint, "a restart keeps the pinned certificate");
146        assert_eq!(second.cert_der, first.cert_der);
147    }
148
149    #[test]
150    fn a_damaged_certificate_is_replaced() {
151        let dir = tempfile::tempdir().unwrap();
152        let first = load_or_create(dir.path()).unwrap();
153        std::fs::write(cert_dir(dir.path()).join(KEY_FILE), b"not a key").unwrap();
154        let second = load_or_create(dir.path()).unwrap();
155        assert_ne!(second.fingerprint, first.fingerprint);
156        second.server_config().unwrap();
157    }
158
159    #[test]
160    fn the_certificate_is_ecdsa_p256() {
161        let tls = generate().unwrap();
162        // id-ecPublicKey (1.2.840.10045.2.1) and prime256v1 (1.2.840.10045.3.1.7).
163        let ec_public_key = [0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
164        let p256 = [0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
165        let has = |needle: &[u8]| tls.cert_der.windows(needle.len()).any(|w| w == needle);
166        assert!(has(&ec_public_key) && has(&p256));
167    }
168}