1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use ed25519_dalek::SigningKey;
4use ed25519_dalek::pkcs8::EncodePrivateKey;
5use hkdf::Hkdf;
6use rcgen::{
7 BasicConstraints, Certificate, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa,
8 Issuer, KeyPair, KeyUsagePurpose, PKCS_ED25519, SanType,
9};
10use rustls_pki_types::PrivatePkcs8KeyDer;
11use sha2::{Digest, Sha256};
12use std::fmt;
13use thiserror::Error;
14use time::{Duration, OffsetDateTime};
15
16const CONTEXT_DOMAIN: &[u8] = b"pbox.cwd.dev/context/v1";
17const CONTEXT_INFO: &[u8] = b"pbox control CA";
18
19#[derive(Clone, PartialEq, Eq)]
20pub struct ContextSeed([u8; 32]);
21
22impl fmt::Debug for ContextSeed {
23 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24 formatter.write_str("<redacted context seed>")
25 }
26}
27
28impl AsRef<[u8]> for ContextSeed {
29 fn as_ref(&self) -> &[u8] {
30 &self.0
31 }
32}
33
34pub fn derive_context_seed(token_id: &str, token_secret: &str) -> ContextSeed {
35 let mut salt_input = Vec::with_capacity(CONTEXT_DOMAIN.len() + token_id.len());
36 salt_input.extend_from_slice(CONTEXT_DOMAIN);
37 salt_input.extend_from_slice(token_id.as_bytes());
38 let salt = Sha256::digest(&salt_input);
39 let hkdf = Hkdf::<Sha256>::new(Some(&salt), token_secret.as_bytes());
40 let mut seed = [0u8; 32];
41 hkdf.expand(CONTEXT_INFO, &mut seed)
42 .expect("32-byte HKDF output is valid for SHA-256");
43 ContextSeed(seed)
44}
45
46pub fn context_fingerprint(seed: &ContextSeed) -> String {
47 let digest = Sha256::digest(seed.as_ref());
48 URL_SAFE_NO_PAD.encode(&digest[..16])
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum CertificatePurpose {
53 Client,
54 Server,
55}
56
57#[derive(Clone)]
58pub struct CertificateMaterial {
59 pub certificate_pem: String,
60 pub certificate_der: Vec<u8>,
61 pub private_key_pem: String,
62 pub private_key_der: Vec<u8>,
63 pub chain_pem: Option<String>,
64}
65
66impl fmt::Debug for CertificateMaterial {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 formatter
69 .debug_struct("CertificateMaterial")
70 .field("certificate_pem", &"<redacted certificate>")
71 .field("certificate_der", &"<redacted certificate>")
72 .field("private_key_pem", &"<redacted private key>")
73 .field("private_key_der", &"<redacted private key>")
74 .field(
75 "chain_pem",
76 &self.chain_pem.as_ref().map(|_| "<redacted chain>"),
77 )
78 .finish()
79 }
80}
81
82pub fn generate_context_ca(seed: &ContextSeed) -> Result<CertificateMaterial, CryptoError> {
83 let signing_key = SigningKey::from_bytes(seed.as_ref().try_into().expect("fixed seed length"));
84 let pkcs8 = signing_key
85 .to_pkcs8_der()
86 .map_err(|error| CryptoError::KeyEncoding(error.to_string()))?;
87 let private_key = PrivatePkcs8KeyDer::from(pkcs8.as_bytes());
88 let key_pair = KeyPair::from_pkcs8_der_and_sign_algo(&private_key, &PKCS_ED25519)
89 .map_err(|error| CryptoError::Certificate(error.to_string()))?;
90
91 let mut params = CertificateParams::default();
92 params
93 .distinguished_name
94 .push(DnType::CommonName, "pbox context CA");
95 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
96 params.key_usages = vec![
97 KeyUsagePurpose::KeyCertSign,
98 KeyUsagePurpose::DigitalSignature,
99 ];
100 let certificate = params
101 .self_signed(&key_pair)
102 .map_err(|error| CryptoError::Certificate(error.to_string()))?;
103 Ok(material_from_certificate(certificate, key_pair, None))
104}
105fn context_ca_params() -> CertificateParams {
106 let mut params = CertificateParams::default();
107 params
108 .distinguished_name
109 .push(DnType::CommonName, "pbox context CA");
110 params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
111 params.key_usages = vec![
112 KeyUsagePurpose::KeyCertSign,
113 KeyUsagePurpose::DigitalSignature,
114 ];
115 params
116}
117
118pub fn issue_certificate(
119 ca: &CertificateMaterial,
120 subject: &str,
121 purpose: CertificatePurpose,
122) -> Result<CertificateMaterial, CryptoError> {
123 let ca_key = KeyPair::from_pem(&ca.private_key_pem)
124 .map_err(|error| CryptoError::Certificate(error.to_string()))?;
125 let issuer = Issuer::new(context_ca_params(), ca_key);
126 let leaf_key = KeyPair::generate_for(&PKCS_ED25519)
127 .map_err(|error| CryptoError::Certificate(error.to_string()))?;
128 let mut params = CertificateParams::default();
129 let now = OffsetDateTime::now_utc();
130 params.not_before = now - Duration::minutes(1);
131 params.not_after = match purpose {
132 CertificatePurpose::Client => now + Duration::minutes(5),
133 CertificatePurpose::Server => now + Duration::days(365),
134 };
135 params.distinguished_name.push(DnType::CommonName, subject);
136 params.subject_alt_names.push(SanType::URI(
137 subject
138 .try_into()
139 .map_err(|error| CryptoError::Certificate(format!("invalid SAN: {error}")))?,
140 ));
141 if let Some(box_id) = subject.strip_prefix("pbox.cwd.dev/box/") {
142 params.subject_alt_names.push(SanType::DnsName(
143 format!("pbox-{box_id}")
144 .try_into()
145 .map_err(|error| CryptoError::Certificate(format!("invalid DNS SAN: {error}")))?,
146 ));
147 }
148 params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
149 params.extended_key_usages = vec![match purpose {
150 CertificatePurpose::Client => ExtendedKeyUsagePurpose::ClientAuth,
151 CertificatePurpose::Server => ExtendedKeyUsagePurpose::ServerAuth,
152 }];
153 let certificate = params
154 .signed_by(&leaf_key, &issuer)
155 .map_err(|error| CryptoError::Certificate(error.to_string()))?;
156 Ok(material_from_certificate(
157 certificate,
158 leaf_key,
159 Some(ca.certificate_pem.clone()),
160 ))
161}
162
163pub fn server_subject(box_id: &str) -> Result<String, CryptoError> {
164 if !box_id.starts_with("pbx_") || box_id.len() != 12 || !box_id[4..].bytes().all(is_id_byte) {
165 return Err(CryptoError::InvalidBoxId(box_id.to_owned()));
166 }
167 Ok(format!("pbox.cwd.dev/box/{box_id}"))
168}
169pub fn server_dns_name(box_id: &str) -> Result<String, CryptoError> {
170 server_subject(box_id).map(|_| format!("pbox-{box_id}"))
171}
172pub fn client_subject(seed: &ContextSeed) -> String {
174 format!("pbox.cwd.dev/context/{}/client", context_fingerprint(seed))
175}
176
177pub fn certificate_has_dns_name(
179 certificate_pem: &str,
180 expected_dns_name: &str,
181) -> Result<bool, CryptoError> {
182 let pem = pem::parse(certificate_pem)
183 .map_err(|error| CryptoError::Certificate(format!("parse certificate PEM: {error}")))?;
184 if pem.tag() != "CERTIFICATE" {
185 return Err(CryptoError::Certificate(
186 "certificate PEM has an unexpected tag".to_owned(),
187 ));
188 }
189 let (_, certificate) = x509_parser::parse_x509_certificate(pem.contents())
190 .map_err(|error| CryptoError::Certificate(format!("parse certificate DER: {error}")))?;
191 let subject_alternative_name = certificate
192 .tbs_certificate
193 .subject_alternative_name()
194 .map_err(|error| CryptoError::Certificate(format!("parse certificate SAN: {error}")))?;
195 Ok(subject_alternative_name.is_some_and(|extension| {
196 extension.value.general_names.iter().any(|name| match name {
197 x509_parser::extensions::GeneralName::DNSName(name) => {
198 name.eq_ignore_ascii_case(expected_dns_name)
199 }
200 _ => false,
201 })
202 }))
203}
204
205fn is_id_byte(byte: u8) -> bool {
206 byte.is_ascii_lowercase() || byte.is_ascii_digit()
207}
208
209fn material_from_certificate(
210 certificate: Certificate,
211 key_pair: KeyPair,
212 chain_pem: Option<String>,
213) -> CertificateMaterial {
214 CertificateMaterial {
215 certificate_pem: certificate.pem(),
216 certificate_der: certificate.der().as_ref().to_vec(),
217 private_key_pem: key_pair.serialize_pem(),
218 private_key_der: key_pair.serialize_der(),
219 chain_pem,
220 }
221}
222
223#[derive(Debug, Error)]
224pub enum CryptoError {
225 #[error("certificate operation failed: {0}")]
226 Certificate(String),
227 #[error("private key encoding failed: {0}")]
228 KeyEncoding(String),
229 #[error("invalid pbox box id: {0}")]
230 InvalidBoxId(String),
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn context_derivation_is_deterministic_and_url_independent() {
239 let first = derive_context_seed("pbox@pve!cli", "secret");
240 let second = derive_context_seed("pbox@pve!cli", "secret");
241 let other = derive_context_seed("pbox@pve!cli", "other");
242 assert_eq!(first, second);
243 assert_ne!(first, other);
244 assert_eq!(context_fingerprint(&first), context_fingerprint(&second));
245 }
246
247 #[test]
248 fn context_derivation_binds_token_id_as_well_as_secret() {
249 let first = derive_context_seed("pbox@pve!cli", "secret");
250 let other = derive_context_seed("pbox@other!cli", "secret");
251
252 assert_ne!(first, other);
253 assert_ne!(context_fingerprint(&first), context_fingerprint(&other));
254 }
255 #[test]
256 fn client_subject_is_bound_to_context_seed() {
257 let seed = derive_context_seed("pbox@pve!cli", "secret");
258 assert_eq!(
259 client_subject(&seed),
260 format!("pbox.cwd.dev/context/{}/client", context_fingerprint(&seed))
261 );
262 }
263
264 #[test]
265 fn context_seed_debug_does_not_expose_bytes() {
266 let seed = derive_context_seed("pbox@pve!cli", "secret");
267 let rendered = format!("{seed:?}");
268 assert!(!rendered.contains("secret"));
269 assert!(rendered.contains("redacted"));
270 }
271
272 #[test]
273 fn issued_certificates_have_distinct_server_identity_material() {
274 let seed = derive_context_seed("pbox@pve!cli", "secret");
275 let ca = generate_context_ca(&seed).unwrap();
276 let first = issue_certificate(
277 &ca,
278 &server_subject("pbx_t3yzd9y3").unwrap(),
279 CertificatePurpose::Server,
280 )
281 .unwrap();
282 let second = issue_certificate(
283 &ca,
284 &server_subject("pbx_91mk2aa7").unwrap(),
285 CertificatePurpose::Server,
286 )
287 .unwrap();
288 assert_ne!(first.private_key_der, second.private_key_der);
289 assert_ne!(first.certificate_der, second.certificate_der);
290 assert!(first.chain_pem.is_some());
291 }
292
293 #[test]
294 fn certificate_dns_name_check_reads_server_san() {
295 let seed = derive_context_seed("pbox@pve!cli", "secret");
296 let ca = generate_context_ca(&seed).unwrap();
297 let certificate = issue_certificate(
298 &ca,
299 &server_subject("pbx_t3yzd9y3").unwrap(),
300 CertificatePurpose::Server,
301 )
302 .unwrap();
303
304 assert!(
305 certificate_has_dns_name(&certificate.certificate_pem, "pbox-pbx_t3yzd9y3").unwrap()
306 );
307 assert!(!certificate_has_dns_name(&certificate.certificate_pem, "pbox-pbx_other").unwrap());
308 }
309
310 #[test]
311 fn server_subject_rejects_invalid_ids() {
312 assert!(server_subject("pbx_bad!").is_err());
313 assert!(server_subject("pbx_t3yzd9y3").is_ok());
314 }
315
316 #[test]
317 fn server_dns_name_matches_box_identity() {
318 assert_eq!(
319 server_dns_name("pbx_t3yzd9y3").unwrap(),
320 "pbox-pbx_t3yzd9y3"
321 );
322 assert!(server_dns_name("invalid").is_err());
323 }
324}