Skip to main content

macula_rust/
cert_chain.rs

1//! Direct-dial dual-trust (Slice 7c Direction B) — X.509 cert chain.
2//!
3//! Managed realms root trust in the realm CA, not in the (keyless) realm
4//! tag. A provider embeds its own service-cert chain (leaf ++ org CA, PEM)
5//! in its `procedure_advertisement`; a verifying consumer chains it to the
6//! realm CA it received at its own issuance. No publisher records, no live
7//! authority — the trust material already travels with the advertisement.
8//!
9//! Ported from `macula_record.erl`'s `verify_advertisement_cert_chain/3`
10//! (and its `cert_chain_step_*`/`pem_cert_ders`/`cert_subject_pubkey`/
11//! `validate_path`/`cert_org` helpers, in `src/record/macula_record.erl`)
12//! — same algorithm, using `rustls-webpki`'s native path validation instead
13//! of hand-rolling `pkix_path_validation`, and `x509-parser` (already used
14//! by [`crate::cert`] for the unrelated TLS pubkey-pinning tier) for field
15//! extraction. Cross-checked against `macula-go`'s own port
16//! (`dht/cert_chain.go`), which uses `crypto/x509`'s native path validation
17//! the same way. Opt-in: this has no effect on plain (non-cert-chain)
18//! direct-dial, which remains exactly as it was.
19//!
20//! **Not the same trust tier as [`crate::cert`].** That module verifies a
21//! TLS pubkey pin for dialing a KNOWN station — no CA chain, no org
22//! semantics, the pubkey IS the identity. This module verifies a resolved
23//! advertisement's embedded X.509 chain proves its signer belongs to a
24//! specific org, authorized by a realm CA the caller already trusts — a
25//! higher, separate, opt-in tier that only matters once direct-dial itself
26//! exists.
27
28use rustls::pki_types::{CertificateDer, UnixTime};
29
30use crate::dht::{self, Record};
31
32/// Mirrors `macula_record.erl`'s six `cert_chain_step_*` failure atoms
33/// (`advertisement_bad_signature`, `no_cert_chain`, `cert_chain_undecodable`,
34/// `cert_key_mismatch`, `cert_chain_untrusted`/`{bad_cert, _}`,
35/// `cert_org_mismatch`) as distinguishable variants (test with
36/// `matches!`/`==`) — never silently treat an unauthorized advertisement as
37/// trusted.
38#[derive(Debug, PartialEq, Eq)]
39pub enum CertChainError {
40    /// The advertisement's own Ed25519 envelope signature does not verify —
41    /// checked BEFORE the cert chain is even examined, since nothing in an
42    /// unverified record can be trusted. Also covers the (practically
43    /// unreachable once the envelope verifies) case of a structurally
44    /// malformed `procedure_advertisement` payload — `macula_record.erl`
45    /// itself has no distinct atom for that case either, since it can't
46    /// occur without the signer having signed garbage in the first place.
47    BadSignature,
48    /// `cert_chain` is absent — the common, unmanaged-realm case. Not
49    /// itself a sign of tampering; callers that require managed-realm
50    /// authorization should treat this as "not authorized," not as
51    /// evidence of an attack.
52    Absent,
53    /// `cert_chain` is present but not a decodable PEM bundle containing at
54    /// least one certificate.
55    Undecodable,
56    /// The leaf certificate's Ed25519 subject public key does not match the
57    /// advertisement's own signing key — the chain does not actually belong
58    /// to whoever signed this record.
59    KeyMismatch,
60    /// The chain does not validate to the given realm CA (expired, wrong
61    /// issuer, broken path, etc.).
62    Untrusted,
63    /// The chain validates, but the leaf certificate's Organization (O)
64    /// does not match the procedure's expected org segment — a
65    /// validly-signed cert for the WRONG org, i.e. a squat.
66    OrgMismatch,
67}
68
69impl std::fmt::Display for CertChainError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        let msg = match self {
72            CertChainError::BadSignature => "advertisement signature does not verify",
73            CertChainError::Absent => "advertisement carries no cert_chain",
74            CertChainError::Undecodable => "cert_chain is not a decodable PEM certificate bundle",
75            CertChainError::KeyMismatch => {
76                "leaf cert public key does not match the advertisement's signer"
77            }
78            CertChainError::Untrusted => "cert chain does not validate to the trusted realm CA",
79            CertChainError::OrgMismatch => "leaf cert organization does not match the expected org",
80        };
81        write!(f, "cert_chain: {msg}")
82    }
83}
84
85impl std::error::Error for CertChainError {}
86
87/// Verifies a resolved `procedure_advertisement` record's embedded X.509
88/// service-cert chain against a trusted realm CA, for Slice 7c Direction B
89/// managed-realm authorization.
90///
91/// `realm_ca_pem` is the realm CA the caller already trusts (obtained at
92/// its own issuance, out of band — never resolved from the mesh itself).
93/// `rec` is a resolved `procedure_advertisement`. `expected_org` is the
94/// `<org>` segment of the procedure URI the caller intended to reach.
95///
96/// Passes (returns `Ok(())`) only when: `rec`'s own envelope signature
97/// verifies; `rec` carries a `cert_chain`; the chain decodes to at least
98/// one certificate; the leaf certificate's Ed25519 subject public key
99/// equals `rec`'s signing key (`rec.key`); the leaf chains to
100/// `realm_ca_pem`; and the leaf's Organization RDN equals `expected_org`.
101pub fn verify_advertisement_cert_chain(
102    realm_ca_pem: &[u8],
103    rec: &Record,
104    expected_org: &str,
105) -> Result<(), CertChainError> {
106    dht::verify(rec).map_err(|_| CertChainError::BadSignature)?;
107    let adv = dht::read_procedure_advertisement(rec).map_err(|_| CertChainError::BadSignature)?;
108    let Some(chain_pem) = adv.cert_chain else {
109        return Err(CertChainError::Absent);
110    };
111
112    let chain_der = decode_cert_chain(&chain_pem)?;
113    let leaf_der = &chain_der[0];
114
115    let leaf_key =
116        crate::cert::ed25519_pubkey_from_cert(leaf_der).map_err(|_| CertChainError::KeyMismatch)?;
117    if leaf_key != rec.key {
118        return Err(CertChainError::KeyMismatch);
119    }
120
121    validate_cert_path(realm_ca_pem, &chain_der)?;
122
123    let leaf_org = leaf_organization(leaf_der).ok_or(CertChainError::OrgMismatch)?;
124    if leaf_org != expected_org {
125        return Err(CertChainError::OrgMismatch);
126    }
127    Ok(())
128}
129
130/// Decodes a leaf-first PEM bundle (as embedded: leaf ++ org CA ++ ...)
131/// into DER certificates, leaf-first, matching `macula_record`'s
132/// `pem_cert_ders/1`.
133fn decode_cert_chain(cert_chain_pem: &[u8]) -> Result<Vec<Vec<u8>>, CertChainError> {
134    let ders: Vec<Vec<u8>> = x509_parser::pem::Pem::iter_from_buffer(cert_chain_pem)
135        .filter_map(Result::ok)
136        .filter(|pem| pem.label == "CERTIFICATE")
137        .map(|pem| pem.contents)
138        .collect();
139    if ders.is_empty() {
140        return Err(CertChainError::Undecodable);
141    }
142    Ok(ders)
143}
144
145/// The Organization (O) RDN of a leaf cert's Subject, or `None` if absent
146/// or unreadable as a string.
147fn leaf_organization(der: &[u8]) -> Option<String> {
148    let (_, cert) = x509_parser::parse_x509_certificate(der).ok()?;
149    let org = cert
150        .subject()
151        .iter_organization()
152        .next()
153        .and_then(|attr| attr.as_str().ok())
154        .map(str::to_owned);
155    org
156}
157
158/// Validates `chain` (leaf-first: `[leaf, org_ca, ...]`) to `realm_ca_pem`
159/// as trust anchor, with no hostname/SAN check (unlike `crate::cert`'s
160/// `ServerCertVerifier` machinery) — matches `macula_record`'s
161/// `validate_path/2`, which hands Erlang's `pkix_path_validation` the same
162/// leaf..anchor chain and no name constraint of its own either.
163fn validate_cert_path(realm_ca_pem: &[u8], chain: &[Vec<u8>]) -> Result<(), CertChainError> {
164    let anchor_ders = decode_cert_chain(realm_ca_pem).map_err(|_| CertChainError::Untrusted)?;
165    let anchor_der = CertificateDer::from(anchor_ders[0].clone());
166    let anchor =
167        webpki::anchor_from_trusted_cert(&anchor_der).map_err(|_| CertChainError::Untrusted)?;
168
169    let leaf_der = CertificateDer::from(chain[0].clone());
170    let end_entity =
171        webpki::EndEntityCert::try_from(&leaf_der).map_err(|_| CertChainError::Untrusted)?;
172    let intermediates: Vec<CertificateDer> = chain[1..]
173        .iter()
174        .map(|der| CertificateDer::from(der.clone()))
175        .collect();
176
177    // KeyUsage::server_auth() is `required_if_present` — it does not
178    // require the leaf to carry an EKU extension at all (macula's
179    // self-issued service certs typically don't), it only rejects a leaf
180    // that declares an EKU set excluding server_auth.
181    end_entity
182        .verify_for_usage(
183            &[webpki::ring::ED25519],
184            std::slice::from_ref(&anchor),
185            &intermediates,
186            UnixTime::now(),
187            webpki::KeyUsage::server_auth(),
188            None,
189            None,
190        )
191        .map_err(|_| CertChainError::Untrusted)?;
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use std::time::Duration;
198
199    use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair as RcgenKeyPair};
200
201    use super::*;
202    use crate::dht;
203    use crate::identity::KeyPair;
204
205    fn test_ca() -> (Vec<u8>, rcgen::Issuer<'static, RcgenKeyPair>) {
206        let key_pair = RcgenKeyPair::generate_for(&rcgen::PKCS_ED25519).expect("ca keygen");
207        let mut params = CertificateParams::new(Vec::<String>::new()).expect("ca params");
208        let mut dn = DistinguishedName::new();
209        dn.push(DnType::CommonName, "Test Realm CA");
210        dn.push(DnType::OrganizationName, "Test Realm CA");
211        params.distinguished_name = dn;
212        params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
213        params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
214        params.not_after = time::OffsetDateTime::now_utc() + time::Duration::hours(24);
215        let cert = params.self_signed(&key_pair).expect("ca self-sign");
216        let pem = cert.pem().into_bytes();
217        (pem, rcgen::Issuer::new(params, key_pair))
218    }
219
220    fn test_leaf(
221        ca_issuer: &rcgen::Issuer<'static, RcgenKeyPair>,
222        advertiser_pub: [u8; 32],
223        org: &str,
224        not_after: time::OffsetDateTime,
225    ) -> Vec<u8> {
226        let subject_spki = rcgen::SubjectPublicKeyInfo::from_der(&ed25519_spki_der(advertiser_pub))
227            .expect("advertiser SPKI");
228        let mut params = CertificateParams::new(Vec::<String>::new()).expect("leaf params");
229        let mut dn = DistinguishedName::new();
230        dn.push(DnType::CommonName, "test-service");
231        dn.push(DnType::OrganizationName, org);
232        params.distinguished_name = dn;
233        params.not_before = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
234        params.not_after = not_after;
235        let cert = params
236            .signed_by(&subject_spki, ca_issuer)
237            .expect("leaf signed_by");
238        cert.der().to_vec()
239    }
240
241    /// DER-encodes a raw 32-byte Ed25519 public key as a SubjectPublicKeyInfo
242    /// structure (RFC 8410): `SEQUENCE { SEQUENCE { OID 1.3.101.112 },
243    /// BIT STRING key }`. rcgen needs this to build a leaf cert whose
244    /// subject key is a SPECIFIC pre-existing key (the advertiser's own
245    /// node identity), not a freshly rcgen-generated one.
246    fn ed25519_spki_der(pubkey: [u8; 32]) -> Vec<u8> {
247        let mut der = vec![
248            0x30, 0x2a, // SEQUENCE, 42 bytes
249            0x30, 0x05, // SEQUENCE, 5 bytes (AlgorithmIdentifier)
250            0x06, 0x03, 0x2b, 0x65, 0x70, // OID 1.3.101.112 (Ed25519)
251            0x03, 0x21, 0x00, // BIT STRING, 33 bytes, 0 unused bits
252        ];
253        der.extend_from_slice(&pubkey);
254        der
255    }
256
257    fn pem_bundle(ders: &[Vec<u8>]) -> Vec<u8> {
258        let mut out = Vec::new();
259        for der in ders {
260            let b64 = base64_std_encode(der);
261            out.extend_from_slice(b"-----BEGIN CERTIFICATE-----\n");
262            for chunk in b64.as_bytes().chunks(64) {
263                out.extend_from_slice(chunk);
264                out.push(b'\n');
265            }
266            out.extend_from_slice(b"-----END CERTIFICATE-----\n");
267        }
268        out
269    }
270
271    fn base64_std_encode(data: &[u8]) -> String {
272        use base64::Engine;
273        base64::engine::general_purpose::STANDARD.encode(data)
274    }
275
276    fn advertiser_and_station() -> (KeyPair, KeyPair) {
277        (KeyPair::generate(), KeyPair::generate())
278    }
279
280    #[test]
281    fn valid_chain_verifies_and_authorizes() {
282        let (ca_pem, ca_issuer) = test_ca();
283        let (advertiser, station) = advertiser_and_station();
284        let leaf_der = test_leaf(
285            &ca_issuer,
286            advertiser.node_id(),
287            "acme-corp",
288            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
289        );
290
291        let rec = dht::new_procedure_advertisement_with_cert_chain(
292            advertiser.node_id(),
293            "0000/acme-corp/widget.build_v1",
294            station.node_id(),
295            Duration::from_secs(3600),
296            pem_bundle(&[leaf_der]),
297        );
298        let rec = dht::sign(rec, &advertiser);
299
300        assert_eq!(
301            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
302            Ok(())
303        );
304    }
305
306    #[test]
307    fn absent_chain_is_reported_distinctly() {
308        let (advertiser, station) = advertiser_and_station();
309        let rec = dht::new_procedure_advertisement(
310            advertiser.node_id(),
311            "0000/acme-corp/widget.build_v1",
312            station.node_id(),
313            Duration::from_secs(3600),
314        );
315        let rec = dht::sign(rec, &advertiser);
316        let (ca_pem, _) = test_ca();
317
318        assert_eq!(
319            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
320            Err(CertChainError::Absent)
321        );
322    }
323
324    #[test]
325    fn bad_envelope_signature_is_checked_before_the_chain() {
326        let (ca_pem, ca_issuer) = test_ca();
327        let (advertiser, station) = advertiser_and_station();
328        let leaf_der = test_leaf(
329            &ca_issuer,
330            advertiser.node_id(),
331            "acme-corp",
332            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
333        );
334        let rec = dht::new_procedure_advertisement_with_cert_chain(
335            advertiser.node_id(),
336            "0000/acme-corp/widget.build_v1",
337            station.node_id(),
338            Duration::from_secs(3600),
339            pem_bundle(&[leaf_der]),
340        );
341        let mut rec = dht::sign(rec, &advertiser);
342        rec.signature[0] ^= 0xFF;
343
344        assert_eq!(
345            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
346            Err(CertChainError::BadSignature)
347        );
348    }
349
350    #[test]
351    fn leaf_key_not_matching_the_signer_is_rejected() {
352        let (ca_pem, ca_issuer) = test_ca();
353        let (advertiser, station) = advertiser_and_station();
354        let other = KeyPair::generate();
355        // Leaf binds `other`'s key, but the advertisement is signed by
356        // `advertiser` -- the chain does not belong to this record's signer.
357        let leaf_der = test_leaf(
358            &ca_issuer,
359            other.node_id(),
360            "acme-corp",
361            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
362        );
363        let rec = dht::new_procedure_advertisement_with_cert_chain(
364            advertiser.node_id(),
365            "0000/acme-corp/widget.build_v1",
366            station.node_id(),
367            Duration::from_secs(3600),
368            pem_bundle(&[leaf_der]),
369        );
370        let rec = dht::sign(rec, &advertiser);
371
372        assert_eq!(
373            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
374            Err(CertChainError::KeyMismatch)
375        );
376    }
377
378    #[test]
379    fn wrong_org_is_rejected_after_a_valid_chain() {
380        let (ca_pem, ca_issuer) = test_ca();
381        let (advertiser, station) = advertiser_and_station();
382        let leaf_der = test_leaf(
383            &ca_issuer,
384            advertiser.node_id(),
385            "acme-corp",
386            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
387        );
388        let rec = dht::new_procedure_advertisement_with_cert_chain(
389            advertiser.node_id(),
390            "0000/other-org/widget.build_v1",
391            station.node_id(),
392            Duration::from_secs(3600),
393            pem_bundle(&[leaf_der]),
394        );
395        let rec = dht::sign(rec, &advertiser);
396
397        assert_eq!(
398            verify_advertisement_cert_chain(&ca_pem, &rec, "other-org"),
399            Err(CertChainError::OrgMismatch)
400        );
401    }
402
403    #[test]
404    fn expired_leaf_is_untrusted() {
405        let (ca_pem, ca_issuer) = test_ca();
406        let (advertiser, station) = advertiser_and_station();
407        let leaf_der = test_leaf(
408            &ca_issuer,
409            advertiser.node_id(),
410            "acme-corp",
411            time::OffsetDateTime::now_utc() - time::Duration::hours(1),
412        );
413        let rec = dht::new_procedure_advertisement_with_cert_chain(
414            advertiser.node_id(),
415            "0000/acme-corp/widget.build_v1",
416            station.node_id(),
417            Duration::from_secs(3600),
418            pem_bundle(&[leaf_der]),
419        );
420        let rec = dht::sign(rec, &advertiser);
421
422        assert_eq!(
423            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
424            Err(CertChainError::Untrusted)
425        );
426    }
427
428    #[test]
429    fn chain_signed_by_a_different_ca_is_untrusted() {
430        let (_, ca_issuer) = test_ca();
431        let (other_ca_pem, _) = test_ca();
432        let (advertiser, station) = advertiser_and_station();
433        let leaf_der = test_leaf(
434            &ca_issuer,
435            advertiser.node_id(),
436            "acme-corp",
437            time::OffsetDateTime::now_utc() + time::Duration::hours(1),
438        );
439        let rec = dht::new_procedure_advertisement_with_cert_chain(
440            advertiser.node_id(),
441            "0000/acme-corp/widget.build_v1",
442            station.node_id(),
443            Duration::from_secs(3600),
444            pem_bundle(&[leaf_der]),
445        );
446        let rec = dht::sign(rec, &advertiser);
447
448        assert_eq!(
449            verify_advertisement_cert_chain(&other_ca_pem, &rec, "acme-corp"),
450            Err(CertChainError::Untrusted)
451        );
452    }
453
454    #[test]
455    fn undecodable_chain_is_reported_distinctly() {
456        let (ca_pem, _) = test_ca();
457        let (advertiser, station) = advertiser_and_station();
458        let rec = dht::new_procedure_advertisement_with_cert_chain(
459            advertiser.node_id(),
460            "0000/acme-corp/widget.build_v1",
461            station.node_id(),
462            Duration::from_secs(3600),
463            b"not a pem cert bundle".to_vec(),
464        );
465        let rec = dht::sign(rec, &advertiser);
466
467        assert_eq!(
468            verify_advertisement_cert_chain(&ca_pem, &rec, "acme-corp"),
469            Err(CertChainError::Undecodable)
470        );
471    }
472}