agentmux_srv/backend/viewer/
cert.rs1use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use sha2::{Digest, Sha256};
13
14const DIR_NAME: &str = "viewer";
16const CERT_FILE: &str = "cert.der";
17const KEY_FILE: &str = "key.der";
18
19pub struct ViewerTls {
21 pub cert_der: Vec<u8>,
22 key_der: Vec<u8>,
23 pub fingerprint: String,
25}
26
27impl ViewerTls {
28 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
46pub fn fingerprint(cert_der: &[u8]) -> String {
48 hex::encode(Sha256::digest(cert_der))
49}
50
51pub fn cert_dir(data_dir: &Path) -> PathBuf {
53 data_dir.join(DIR_NAME)
54}
55
56pub 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 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
95fn 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 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}