Skip to main content

ahu/
seal.rs

1//! CMS seal verification for ahu bundles.
2//!
3//! Verifies the detached CMS SignedData seal over the manifest bytes.
4//! This confirms the bundle was produced by a specific signer and has
5//! not been tampered with.
6
7#[cfg(feature = "seal-verify")]
8mod verify_impl {
9    use cms::content_info::ContentInfo;
10    use cms::signed_data::SignedData;
11    use der::{Decode, Encode};
12    use sha2::{Digest, Sha256};
13
14    use crate::error::{AhuError, Result};
15
16    const ID_SIGNED_DATA: der::asn1::ObjectIdentifier =
17        der::asn1::ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.2");
18    const ID_MESSAGE_DIGEST: der::asn1::ObjectIdentifier =
19        der::asn1::ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.4");
20
21    /// Result of seal verification.
22    #[derive(Debug)]
23    pub struct SealVerification {
24        pub signature_valid: bool,
25        pub digest_matches: bool,
26        pub signer_subject: String,
27        pub signer_sha256: String,
28    }
29
30    /// Verify a CMS seal against the manifest bytes.
31    ///
32    /// Checks:
33    /// 1. Parses as valid CMS SignedData
34    /// 2. The message-digest signed attribute matches SHA-256(manifest_bytes)
35    /// 3. The signature over the signed attributes is valid (ECDSA P-256)
36    pub fn verify_seal(manifest_bytes: &[u8], seal_bytes: &[u8]) -> Result<SealVerification> {
37        if seal_bytes.is_empty() {
38            return Err(AhuError::SealInvalid("seal section is empty".into()));
39        }
40
41        // Parse ContentInfo
42        let content_info = ContentInfo::from_der(seal_bytes)
43            .map_err(|e| AhuError::SealInvalid(format!("parse ContentInfo: {e}")))?;
44
45        if content_info.content_type != ID_SIGNED_DATA {
46            return Err(AhuError::SealInvalid(format!(
47                "unexpected content type: {}",
48                content_info.content_type
49            )));
50        }
51
52        // Parse SignedData
53        let signed_data = content_info
54            .content
55            .decode_as::<SignedData>()
56            .map_err(|e| AhuError::SealInvalid(format!("parse SignedData: {e}")))?;
57
58        // Must have exactly one signer
59        let signer_infos = &signed_data.signer_infos.0;
60        if signer_infos.len() != 1 {
61            return Err(AhuError::SealInvalid(
62                "exactly one signer is required".into(),
63            ));
64        }
65        let signer_info = &signer_infos.as_slice()[0];
66        let sha256 = der::asn1::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.2.1");
67        if signer_info.digest_alg.oid != sha256
68            || signed_data.digest_algorithms.len() != 1
69            || signed_data.digest_algorithms.as_slice()[0].oid != sha256
70        {
71            return Err(AhuError::SealInvalid(
72                "only SHA-256 digest attributes are supported".into(),
73            ));
74        }
75        let data_oid = der::asn1::ObjectIdentifier::new_unwrap("1.2.840.113549.1.7.1");
76        if signed_data.encap_content_info.econtent_type != data_oid
77            || signed_data.encap_content_info.econtent.is_some()
78        {
79            return Err(AhuError::SealInvalid(
80                "expected detached CMS data content".into(),
81            ));
82        }
83        let content_oid = der::asn1::ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.3");
84        let attrs = signer_info
85            .signed_attrs
86            .as_ref()
87            .ok_or_else(|| AhuError::SealInvalid("missing signed attributes".into()))?;
88        let content_attrs: Vec<_> = attrs.iter().filter(|a| a.oid == content_oid).collect();
89        if content_attrs.len() != 1
90            || content_attrs[0].values.len() != 1
91            || content_attrs[0].values.as_slice()[0]
92                .decode_as::<der::asn1::ObjectIdentifier>()
93                .ok()
94                != Some(data_oid)
95        {
96            return Err(AhuError::SealInvalid(
97                "invalid content-type attribute".into(),
98            ));
99        }
100
101        // Verify message-digest attribute matches SHA-256(manifest)
102        let manifest_digest = Sha256::digest(manifest_bytes);
103        let digest_matches = verify_message_digest(signer_info, &manifest_digest)?;
104
105        if !digest_matches {
106            return Err(AhuError::SealInvalid(
107                "message-digest attribute does not match manifest hash".into(),
108            ));
109        }
110
111        // Extract signer certificate
112        let cert = extract_signer_cert(&signed_data)?;
113
114        let signer_subject = format!("{}", cert.tbs_certificate().subject());
115
116        // Verify signature over signed attributes
117        let signature_valid = verify_signature(signer_info, cert)?;
118        if !signature_valid {
119            return Err(AhuError::SealInvalid("invalid CMS signature".into()));
120        }
121        let signer_sha256 = hex::encode(Sha256::digest(
122            cert.to_der()
123                .map_err(|e| AhuError::SealInvalid(e.to_string()))?,
124        ));
125
126        Ok(SealVerification {
127            signature_valid,
128            digest_matches,
129            signer_subject,
130            signer_sha256,
131        })
132    }
133
134    fn verify_message_digest(
135        signer_info: &cms::signed_data::SignerInfo,
136        expected_digest: &[u8],
137    ) -> Result<bool> {
138        let attrs = signer_info
139            .signed_attrs
140            .as_ref()
141            .ok_or_else(|| AhuError::SealInvalid("no signed attributes".into()))?;
142
143        let matches: Vec<_> = attrs
144            .iter()
145            .filter(|a| a.oid == ID_MESSAGE_DIGEST)
146            .collect();
147        if matches.len() != 1 || matches[0].values.len() != 1 {
148            return Err(AhuError::SealInvalid(
149                "exactly one message-digest value required".into(),
150            ));
151        }
152        let octet = matches[0].values.as_slice()[0]
153            .decode_as::<der::asn1::OctetString>()
154            .map_err(|e| AhuError::SealInvalid(format!("invalid digest attribute: {e}")))?;
155        Ok(octet.as_bytes() == expected_digest)
156    }
157
158    fn extract_signer_cert(
159        signed_data: &SignedData,
160    ) -> Result<&x509_cert::certificate::Certificate> {
161        use cms::signed_data::SignerIdentifier;
162        use x509_cert::ext::pkix::SubjectKeyIdentifier;
163        let signer = signed_data
164            .signer_infos
165            .0
166            .as_slice()
167            .first()
168            .ok_or_else(|| AhuError::SealInvalid("missing signer".into()))?;
169        let certs = signed_data
170            .certificates
171            .as_ref()
172            .ok_or_else(|| AhuError::SealInvalid("missing certificates".into()))?;
173        let mut found = None;
174        for choice in certs.0.iter() {
175            if let cms::cert::CertificateChoices::Certificate(cert) = choice {
176                let tbs = cert.tbs_certificate();
177                let matches = match &signer.sid {
178                    SignerIdentifier::IssuerAndSerialNumber(id) => {
179                        &id.issuer == tbs.issuer() && &id.serial_number == tbs.serial_number()
180                    }
181                    SignerIdentifier::SubjectKeyIdentifier(id) => tbs
182                        .get_extension::<SubjectKeyIdentifier>()
183                        .ok()
184                        .flatten()
185                        .is_some_and(|(_, ski)| &ski == id),
186                };
187                if matches {
188                    if found.is_some() {
189                        return Err(AhuError::SealInvalid("ambiguous signer certificate".into()));
190                    }
191                    found = Some(cert);
192                }
193            }
194        }
195        found.ok_or_else(|| {
196            AhuError::SealInvalid("signer identifier has no matching certificate".into())
197        })
198    }
199
200    /// Authenticate a seal with a deliberately bounded certificate profile:
201    /// ECDSA-P256 or ML-DSA signer directly issued by a configured CA anchor,
202    /// or the configured CA itself. Intermediate paths and extensions whose
203    /// semantics are not implemented fail closed. This is not general PKIX.
204    pub fn verify_seal_with_anchors(
205        manifest: &[u8],
206        seal: &[u8],
207        anchors_der: &[Vec<u8>],
208        now: u64,
209    ) -> Result<SealVerification> {
210        use x509_cert::certificate::Certificate;
211        let verification = verify_seal(manifest, seal)?;
212        let ci = ContentInfo::from_der(seal).map_err(|e| AhuError::SealInvalid(e.to_string()))?;
213        let sd = ci
214            .content
215            .decode_as::<SignedData>()
216            .map_err(|e| AhuError::SealInvalid(e.to_string()))?;
217        let signer = extract_signer_cert(&sd)?;
218        validate_cert_profile(signer, now, false)?;
219        if anchors_der.is_empty() {
220            return Err(AhuError::SealInvalid("no trust anchors".into()));
221        }
222        let anchors = anchors_der
223            .iter()
224            .map(|bytes| {
225                Certificate::from_der(bytes)
226                    .map_err(|e| AhuError::SealInvalid(format!("invalid trust anchor: {e}")))
227            })
228            .collect::<Result<Vec<_>>>()?;
229        for anchor in &anchors {
230            validate_cert_profile(anchor, now, true)?;
231        }
232        for anchor in &anchors {
233            if signer == anchor {
234                return Ok(verification);
235            }
236            if signer.tbs_certificate().issuer() != anchor.tbs_certificate().subject() {
237                continue;
238            }
239            if signer.signature_algorithm() != signer.tbs_certificate().signature() {
240                return Err(AhuError::SealInvalid(
241                    "certificate signature algorithms disagree".into(),
242                ));
243            }
244            let bytes = signer
245                .tbs_certificate()
246                .to_der()
247                .map_err(|e| AhuError::SealInvalid(e.to_string()))?;
248            let sig = signer.signature().as_bytes().ok_or_else(|| {
249                AhuError::SealInvalid("invalid certificate signature bits".into())
250            })?;
251            if verify_bytes(&bytes, sig, signer.signature_algorithm().oid, anchor)? {
252                return Ok(verification);
253            }
254        }
255        Err(AhuError::SealInvalid("signer is not directly issued by a configured CA trust anchor (intermediate paths unsupported)".into()))
256    }
257
258    /// Authenticate an explicitly pinned end-entity signing certificate.
259    /// Pins are exact DER certificate matches, not CA trust anchors.
260    pub fn verify_seal_with_pins(
261        manifest: &[u8],
262        seal: &[u8],
263        pins_der: &[Vec<u8>],
264        now: u64,
265    ) -> Result<SealVerification> {
266        let verification = verify_seal(manifest, seal)?;
267        let ci = ContentInfo::from_der(seal).map_err(|e| AhuError::SealInvalid(e.to_string()))?;
268        let sd = ci
269            .content
270            .decode_as::<SignedData>()
271            .map_err(|e| AhuError::SealInvalid(e.to_string()))?;
272        let signer = extract_signer_cert(&sd)?;
273        validate_cert_profile(signer, now, false)?;
274        let signer_der = signer
275            .to_der()
276            .map_err(|e| AhuError::SealInvalid(e.to_string()))?;
277        // Decode and canonicalize configured certificates as CMS does. The
278        // X.509 codec normalizes Time encodings (including legacy demo certs).
279        let pins = pins_der
280            .iter()
281            .map(|pin| {
282                x509_cert::certificate::Certificate::from_der(pin)
283                    .and_then(|cert| cert.to_der())
284                    .map_err(|e| AhuError::SealInvalid(format!("invalid signer pin: {e}")))
285            })
286            .collect::<Result<Vec<_>>>()?;
287        if !pins.iter().any(|pin| pin == &signer_der) {
288            return Err(AhuError::SealInvalid(
289                "signer certificate does not match an explicit pin".into(),
290            ));
291        }
292        Ok(verification)
293    }
294
295    fn validate_cert_profile(
296        cert: &x509_cert::certificate::Certificate,
297        now: u64,
298        ca: bool,
299    ) -> Result<()> {
300        use x509_cert::ext::pkix::{BasicConstraints, KeyUsage};
301        let tbs = cert.tbs_certificate();
302        let validity = tbs.validity();
303        if now < validity.not_before.to_unix_duration().as_secs()
304            || now >= validity.not_after.to_unix_duration().as_secs()
305        {
306            return Err(AhuError::SealInvalid(
307                "certificate outside validity period".into(),
308            ));
309        }
310        if let Some(exts) = tbs.extensions() {
311            let mut seen = std::collections::HashSet::new();
312            for ext in exts {
313                let oid = ext.extn_id.to_string();
314                if !seen.insert(oid.clone()) {
315                    return Err(AhuError::SealInvalid(
316                        "duplicate certificate extension".into(),
317                    ));
318                }
319                // Reject constraints/EKU/policy extensions even when marked noncritical:
320                // ignoring their scope could expand authorization.
321                if !matches!(
322                    oid.as_str(),
323                    "2.5.29.14" | "2.5.29.15" | "2.5.29.19" | "2.5.29.35"
324                ) {
325                    return Err(AhuError::SealInvalid(format!(
326                        "unsupported seal certificate extension {oid}"
327                    )));
328                }
329            }
330        }
331        let usage = tbs
332            .get_extension::<KeyUsage>()
333            .map_err(|e| AhuError::SealInvalid(e.to_string()))?;
334        if usage.is_some_and(|(_, ku)| {
335            if ca {
336                !ku.key_cert_sign()
337            } else {
338                !ku.digital_signature()
339            }
340        }) {
341            return Err(AhuError::SealInvalid(
342                "certificate key usage does not permit operation".into(),
343            ));
344        }
345        if ca
346            && !tbs
347                .get_extension::<BasicConstraints>()
348                .map_err(|e| AhuError::SealInvalid(e.to_string()))?
349                .is_some_and(|(_, bc)| bc.ca)
350        {
351            return Err(AhuError::SealInvalid(
352                "trust anchor must be a CA certificate; signer pins are a separate policy".into(),
353            ));
354        }
355        Ok(())
356    }
357
358    const ID_ECDSA_SHA256_V: der::asn1::ObjectIdentifier =
359        der::asn1::ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2");
360    const ID_ML_DSA_44_V: der::asn1::ObjectIdentifier =
361        der::asn1::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.17");
362    const ID_ML_DSA_65_V: der::asn1::ObjectIdentifier =
363        der::asn1::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.18");
364    const ID_ML_DSA_87_V: der::asn1::ObjectIdentifier =
365        der::asn1::ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.19");
366
367    fn verify_signature(
368        signer_info: &cms::signed_data::SignerInfo,
369        cert: &x509_cert::certificate::Certificate,
370    ) -> Result<bool> {
371        let signed_attrs = signer_info
372            .signed_attrs
373            .as_ref()
374            .ok_or_else(|| AhuError::SealInvalid("no signed attrs for verification".into()))?;
375
376        let attrs_der = signed_attrs
377            .to_der()
378            .map_err(|e| AhuError::SealInvalid(format!("encode signed attrs: {e}")))?;
379
380        verify_bytes(
381            &attrs_der,
382            signer_info.signature.as_bytes(),
383            signer_info.signature_algorithm.oid,
384            cert,
385        )
386    }
387
388    fn verify_bytes(
389        bytes: &[u8],
390        sig_bytes: &[u8],
391        sig_alg_oid: der::asn1::ObjectIdentifier,
392        cert: &x509_cert::certificate::Certificate,
393    ) -> Result<bool> {
394        let spki = cert.tbs_certificate().subject_public_key_info();
395        let pub_key_bytes = spki
396            .subject_public_key
397            .as_bytes()
398            .ok_or_else(|| AhuError::SealInvalid("no public key bits".into()))?;
399        if sig_alg_oid == ID_ECDSA_SHA256_V {
400            let ec = der::asn1::ObjectIdentifier::new_unwrap("1.2.840.10045.2.1");
401            let curve = der::asn1::ObjectIdentifier::new_unwrap("1.2.840.10045.3.1.7");
402            if spki.algorithm.oid != ec
403                || spki
404                    .algorithm
405                    .parameters
406                    .as_ref()
407                    .and_then(|p| p.decode_as::<der::asn1::ObjectIdentifier>().ok())
408                    != Some(curve)
409            {
410                return Err(AhuError::SealInvalid("expected P-256 key algorithm".into()));
411            }
412            verify_ecdsa_seal(bytes, sig_bytes, pub_key_bytes)
413        } else if sig_alg_oid == ID_ML_DSA_44_V && spki.algorithm.oid == sig_alg_oid {
414            verify_ml_dsa_seal::<ml_dsa::MlDsa44>(bytes, sig_bytes, pub_key_bytes)
415        } else if sig_alg_oid == ID_ML_DSA_65_V && spki.algorithm.oid == sig_alg_oid {
416            verify_ml_dsa_seal::<ml_dsa::MlDsa65>(bytes, sig_bytes, pub_key_bytes)
417        } else if sig_alg_oid == ID_ML_DSA_87_V && spki.algorithm.oid == sig_alg_oid {
418            verify_ml_dsa_seal::<ml_dsa::MlDsa87>(bytes, sig_bytes, pub_key_bytes)
419        } else {
420            Err(AhuError::SealInvalid(format!(
421                "unsupported/mismatched signature algorithm {sig_alg_oid}"
422            )))
423        }
424    }
425
426    fn verify_ecdsa_seal(attrs_der: &[u8], sig_bytes: &[u8], pub_key_bytes: &[u8]) -> Result<bool> {
427        let attrs_hash = Sha256::digest(attrs_der);
428
429        let verifying_key = p256::ecdsa::VerifyingKey::from_sec1_bytes(pub_key_bytes)
430            .map_err(|e| AhuError::SealInvalid(format!("parse P-256 public key: {e}")))?;
431
432        let signature = p256::ecdsa::DerSignature::from_bytes(sig_bytes)
433            .map_err(|e| AhuError::SealInvalid(format!("parse ECDSA signature: {e}")))?;
434
435        use p256::ecdsa::signature::hazmat::PrehashVerifier;
436        match verifying_key.verify_prehash(&attrs_hash, &signature) {
437            Ok(()) => Ok(true),
438            Err(_) => Ok(false),
439        }
440    }
441
442    fn verify_ml_dsa_seal<P>(
443        attrs_der: &[u8],
444        sig_bytes: &[u8],
445        pub_key_bytes: &[u8],
446    ) -> Result<bool>
447    where
448        P: ml_dsa::MlDsaParams,
449    {
450        let encoded = ml_dsa::EncodedVerifyingKey::<P>::try_from(pub_key_bytes)
451            .map_err(|_| AhuError::SealInvalid("invalid ML-DSA public key size".into()))?;
452        let vk = ml_dsa::VerifyingKey::<P>::decode(&encoded);
453
454        let sig = ml_dsa::Signature::<P>::try_from(sig_bytes)
455            .map_err(|_| AhuError::SealInvalid("invalid ML-DSA signature".into()))?;
456
457        use ml_dsa::Verifier;
458        match vk.verify(attrs_der, &sig) {
459            Ok(()) => Ok(true),
460            Err(_) => Ok(false),
461        }
462    }
463}
464
465#[cfg(feature = "seal-verify")]
466pub use verify_impl::{
467    SealVerification, verify_seal, verify_seal_with_anchors, verify_seal_with_pins,
468};