Skip to main content

pbox_crypto/
lib.rs

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}
172/// Build the URI subject used for short-lived client certificates.
173pub fn client_subject(seed: &ContextSeed) -> String {
174    format!("pbox.cwd.dev/context/{}/client", context_fingerprint(seed))
175}
176
177/// Check that a PEM certificate contains the expected DNS subject alternative name.
178pub 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}