Skip to main content

structured_pkix/crl/
scope.rs

1//! CRL issuing-distribution-point grammar shares certificate DP name parsing.
2
3use crate::access::{self, DistributionPoint, PointName};
4use crate::error::{Error, ErrorCode, Located, Result};
5use crate::extension::{self, Extension};
6use crate::general_name::GeneralName;
7use crate::name::{self, Name, RelativeName};
8use crate::{Certificate, Version, oid};
9use structured_asn1::BitString;
10use structured_asn1::memory::Work;
11
12/// The eight revocation reasons in RFC 5280 §6.3.2, excluding unspecified
13/// and removeFromCRL. `ReasonFlags` unused(0) never contributes coverage.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Reasons(u16);
16
17impl Reasons {
18    /// Every reason required by the standard CRL-processing algorithm.
19    pub const ALL: Self = Self(0x1fe);
20    /// No authenticated reason coverage.
21    pub const NONE: Self = Self(0);
22    /// Union of already established coverage facts.
23    #[must_use]
24    pub const fn union(self, other: Self) -> Self {
25        Self(self.0 | other.0)
26    }
27    /// Whether the coverage includes all eight reasons.
28    #[must_use]
29    pub const fn is_complete(self) -> bool {
30        self.0 == Self::ALL.0
31    }
32    /// Whether coverage is empty.
33    #[must_use]
34    pub const fn is_empty(self) -> bool {
35        self.0 == 0
36    }
37    /// Original `ReasonFlags` positions (1 through 8), without unused(0).
38    #[must_use]
39    pub const fn bits(self) -> u16 {
40        self.0
41    }
42}
43
44/// Geometric issuer/name/category scope mismatch, not a signature verdict.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum ScopeFailure {
47    /// Candidate CRL does not name the applicable issuer.
48    Issuer,
49    /// A DP naming a separate CRL issuer requires an indirect CRL.
50    IndirectRequired,
51    /// Expanded distribution-point names do not match.
52    DistributionName,
53    /// Category does not cover this public-key certificate.
54    CertificateKind,
55    /// A user-only/CA-only IDP cannot cover a v1/v2 certificate.
56    CertificateVersion,
57    /// The reason intersection covers none of the legal reasons.
58    NoReasons,
59}
60
61/// Applicable scope says nothing about freshness or unknown critical demands.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ScopeOutcome {
64    /// Reason coverage for which this CRL's issuer and DP scope match.
65    Applicable(Reasons),
66    /// This source does not apply under the attempted distribution scope.
67    NotApplicable(ScopeFailure),
68}
69
70impl<'a> super::Authenticated<'_, 'a> {
71    /// Derive applicability from the target's own signed DP extension. Relative
72    /// names are compared as borrowed composed RDN views, never encoded again.
73    /// If no explicit DP applies, also consider the RFC 5280 §6.3.3 issuer-name
74    /// fallback over supplied CRLs; this performs no fetching or discovery.
75    pub fn scope<W: Work>(&self, target: &Certificate<'a>, execution: &W) -> Result<ScopeOutcome> {
76        const FIELD: &str = "certificateList.scope";
77        let crl = self.crl();
78        execution
79            .charge_work(1)
80            .map_err(|reason| Error::resource(reason, FIELD, crl.span().start))?;
81        let idp = crl
82            .tbs()
83            .extensions()
84            .get(oid::ISSUING_DISTRIBUTION_POINT, execution)?
85            .map(|extension| IssuingDistributionPoint::read(extension, execution))
86            .transpose()?;
87        if let Some(idp) = &idp {
88            match idp.kind {
89                CertificateKind::Attribute => {
90                    return Ok(ScopeOutcome::NotApplicable(ScopeFailure::CertificateKind));
91                }
92                CertificateKind::EndEntity | CertificateKind::Authority => {
93                    // RFC5280 §5.2.5 excludes v1/v2 for BOTH user-only and
94                    // CA-only CRLs. The cA tests in §6.3.3(b)(2)(ii–iii) are
95                    // additional conditions, not an exception to that scope.
96                    // <https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5>
97                    if target.tbs().version() != Version::V3 {
98                        return Ok(ScopeOutcome::NotApplicable(
99                            ScopeFailure::CertificateVersion,
100                        ));
101                    }
102                    let ca = target
103                        .tbs()
104                        .extensions()
105                        .basic_constraints(execution)?
106                        .is_some_and(crate::constraints::BasicConstraints::is_authority);
107                    if ca != (idp.kind == CertificateKind::Authority) {
108                        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::CertificateKind));
109                    }
110                }
111                CertificateKind::All => (),
112            }
113        }
114        if let Some(outcome) = specified_scope(crl.tbs().issuer(), idp.as_ref(), target, execution)?
115        {
116            return Ok(outcome);
117        }
118        // RFC 5280 §6.3.3 final paragraph: synthetic DP names comprise the
119        // target issuer DN and issuerAltName, with no cRLIssuer or reasons.
120        // https://www.rfc-editor.org/rfc/rfc5280#section-6.3.3
121        if !crl
122            .tbs()
123            .issuer()
124            .matches(target.tbs().issuer(), execution)?
125        {
126            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
127        }
128        if let Some(idp) = &idp
129            && idp.name.is_some()
130        {
131            let mut matched = point_name_matches_directory(
132                idp,
133                crl.tbs().issuer(),
134                target.tbs().issuer(),
135                execution,
136            )?;
137            if !matched && let Some(names) = target.tbs().extensions().issuer_alt_name(execution)? {
138                matched =
139                    point_name_matches_names::<false>(idp, crl.tbs().issuer(), &names, execution)?;
140            }
141            if !matched {
142                return Ok(ScopeOutcome::NotApplicable(ScopeFailure::DistributionName));
143            }
144        }
145        Ok(ScopeOutcome::Applicable(reason_mask(
146            idp.as_ref().and_then(|idp| idp.reasons),
147            execution,
148            crl.span().start,
149        )?))
150    }
151}
152
153fn specified_scope<W: Work>(
154    issuer: &Name<'_>,
155    idp: Option<&IssuingDistributionPoint<'_>>,
156    target: &Certificate<'_>,
157    execution: &W,
158) -> Result<Option<ScopeOutcome>> {
159    let mut coverage = Reasons::NONE;
160    let mut failure = None;
161    if let Some(points) = target
162        .tbs()
163        .extensions()
164        .crl_distribution_points(execution)?
165    {
166        for point in points.points() {
167            execution.charge_work(1).map_err(|reason| {
168                Error::resource(reason, "certificateList.scope", target.span().start)
169            })?;
170            match point_scope(issuer, idp, target, point, execution)? {
171                ScopeOutcome::Applicable(reasons) => coverage = coverage.union(reasons),
172                ScopeOutcome::NotApplicable(ScopeFailure::NoReasons) => {
173                    failure = Some(ScopeFailure::NoReasons);
174                }
175                ScopeOutcome::NotApplicable(ScopeFailure::DistributionName)
176                    if failure.is_none()
177                        && idp.is_some_and(|idp| idp.name.is_some())
178                        && (!point.full_name().is_empty()
179                            || point.name_relative_to_issuer().is_some()) =>
180                {
181                    // Both fields are present: §5.2.5 requires an identical
182                    // selected name. The §6.3.3 synthetic fallback cannot
183                    // discard this explicit field or its reason restriction.
184                    failure = Some(ScopeFailure::DistributionName);
185                }
186                ScopeOutcome::NotApplicable(_) => (),
187            }
188        }
189    }
190    if !coverage.is_empty() {
191        return Ok(Some(ScopeOutcome::Applicable(coverage)));
192    }
193    Ok(failure.map(ScopeOutcome::NotApplicable))
194}
195
196fn reason_mask(flags: Option<BitString<'_>>, execution: &impl Work, at: u64) -> Result<Reasons> {
197    execution
198        .charge_work(9)
199        .map_err(|reason| Error::resource(reason, "certificateList.scope", at))?;
200    let Some(flags) = flags else {
201        return Ok(Reasons::ALL);
202    };
203    let mut mask = 0;
204    for bit in 1..=8 {
205        if flags.bit(bit) {
206            mask |= 1 << bit;
207        }
208    }
209    Ok(Reasons(mask))
210}
211
212fn point_scope(
213    issuer: &Name<'_>,
214    idp: Option<&IssuingDistributionPoint<'_>>,
215    target: &Certificate<'_>,
216    point: &DistributionPoint<'_>,
217    execution: &impl Work,
218) -> Result<ScopeOutcome> {
219    if !point.issuer().is_empty() {
220        if !idp.is_some_and(IssuingDistributionPoint::is_indirect) {
221            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::IndirectRequired));
222        }
223        let mut matches = false;
224        for name in point.issuer() {
225            execution.charge_work(1).map_err(|reason| {
226                Error::resource(reason, "certificateList.scope", target.span().start)
227            })?;
228            if let GeneralName::Directory(name) = name
229                && issuer_encoding_matches(issuer, name, execution)?
230            {
231                matches = true;
232                break;
233            }
234        }
235        if !matches {
236            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
237        }
238    } else if !issuer.matches(target.tbs().issuer(), execution)? {
239        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::Issuer));
240    }
241    if let Some(idp) = idp
242        && idp.name.is_some()
243    {
244        let matched = if let Some(relative) = point.name_relative_to_issuer() {
245            let base = if point.issuer().is_empty() {
246                target.tbs().issuer()
247            } else {
248                match &point.issuer()[0] {
249                    GeneralName::Directory(name) => name,
250                    _ => unreachable!("DP reader requires directory issuers"),
251                }
252            };
253            point_name_matches_composed::<true>(idp, issuer, base, Some(relative), execution)?
254        } else if !point.full_name().is_empty() {
255            // RFC5280 5.2.5 requires identical selected name encoding when
256            // both DP fields are present; semantic name equality is used only
257            // for issuer-only/synthetic fields in 6.3.3(b)(2)(i).
258            // <https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5>
259            point_name_matches_names::<true>(idp, issuer, point.full_name(), execution)?
260        } else {
261            point_name_matches_names::<false>(idp, issuer, point.issuer(), execution)?
262        };
263        if !matched {
264            return Ok(ScopeOutcome::NotApplicable(ScopeFailure::DistributionName));
265        }
266    }
267    let left = reason_mask(
268        idp.and_then(|idp| idp.reasons),
269        execution,
270        target.span().start,
271    )?;
272    let right = reason_mask(point.reasons(), execution, target.span().start)?;
273    let reasons = Reasons(left.0 & right.0);
274    if reasons.is_empty() {
275        return Ok(ScopeOutcome::NotApplicable(ScopeFailure::NoReasons));
276    }
277    Ok(ScopeOutcome::Applicable(reasons))
278}
279
280fn issuer_encoding_matches(
281    issuer: &Name<'_>,
282    named: &Name<'_>,
283    execution: &impl Work,
284) -> Result<bool> {
285    // RFC5280 §4.2.1.13 requires exactly the same encoding for explicit
286    // cRLIssuer; ordinary DN equality remains applicable to the default issuer.
287    // <https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.13>
288    let left = issuer.as_bytes();
289    let right = named.as_bytes();
290    let work = u64::try_from(left.len().max(right.len()))
291        .ok()
292        .and_then(|n| n.checked_mul(2))
293        .and_then(|n| n.checked_add(1))
294        .ok_or_else(|| named.resource(structured_asn1::memory::MemoryError::WorkExceeded))?;
295    execution
296        .charge_work(work)
297        .map_err(|reason| named.resource(reason))?;
298    Ok(left == right)
299}
300
301fn point_name_matches_directory(
302    idp: &IssuingDistributionPoint<'_>,
303    issuer: &Name<'_>,
304    directory: &Name<'_>,
305    execution: &impl Work,
306) -> Result<bool> {
307    point_name_matches_composed::<false>(idp, issuer, directory, None, execution)
308}
309
310fn point_name_matches_composed<const ENCODED: bool>(
311    idp: &IssuingDistributionPoint<'_>,
312    issuer: &Name<'_>,
313    base: &Name<'_>,
314    relative: Option<&RelativeName<'_>>,
315    execution: &impl Work,
316) -> Result<bool> {
317    match &idp.name {
318        Some(PointName::Relative(suffix)) => {
319            if ENCODED && relative.is_none() {
320                return Ok(false);
321            }
322            name::matches_composed::<ENCODED>(issuer, Some(suffix), base, relative, execution)
323        }
324        Some(PointName::Full(names)) => {
325            if ENCODED && relative.is_some() {
326                return Ok(false);
327            }
328            for name in names {
329                execution
330                    .charge_work(1)
331                    .map_err(|reason| base.resource(reason))?;
332                if let GeneralName::Directory(directory) = name
333                    && name::matches_composed::<ENCODED>(
334                        directory, None, base, relative, execution,
335                    )?
336                {
337                    return Ok(true);
338                }
339            }
340            Ok(false)
341        }
342        None => Ok(true),
343    }
344}
345
346fn point_name_matches_names<const ENCODED: bool>(
347    idp: &IssuingDistributionPoint<'_>,
348    issuer: &Name<'_>,
349    names: &[GeneralName<'_>],
350    execution: &impl Work,
351) -> Result<bool> {
352    for right in names {
353        execution
354            .charge_work(1)
355            .map_err(|reason| issuer.resource(reason))?;
356        match &idp.name {
357            Some(PointName::Relative(relative)) => {
358                if ENCODED {
359                    return Ok(false);
360                }
361                if let GeneralName::Directory(directory) = right
362                    && name::matches_composed::<ENCODED>(
363                        issuer,
364                        Some(relative),
365                        directory,
366                        None,
367                        execution,
368                    )?
369                {
370                    return Ok(true);
371                }
372            }
373            Some(PointName::Full(left)) => {
374                for left in left {
375                    execution
376                        .charge_work(1)
377                        .map_err(|reason| issuer.resource(reason))?;
378                    if names_match::<ENCODED>(left, right, issuer, execution)? {
379                        return Ok(true);
380                    }
381                }
382            }
383            None => return Ok(true),
384        }
385    }
386    Ok(false)
387}
388
389fn names_match<const ENCODED: bool>(
390    left: &GeneralName<'_>,
391    right: &GeneralName<'_>,
392    source: &Name<'_>,
393    execution: &impl Work,
394) -> Result<bool> {
395    if !ENCODED {
396        return crate::general_name::matches_at(
397            left,
398            right,
399            execution,
400            source.offset(),
401            source.field(),
402        );
403    }
404    let (left, right) = match (left, right) {
405        (GeneralName::Directory(left), GeneralName::Directory(right)) => {
406            return name::matches_composed::<ENCODED>(left, None, right, None, execution);
407        }
408        (GeneralName::Uri(left), GeneralName::Uri(right))
409        | (GeneralName::Dns(left), GeneralName::Dns(right))
410        | (GeneralName::Email(left), GeneralName::Email(right)) => {
411            (left.as_bytes(), right.as_bytes())
412        }
413        (GeneralName::X400(left), GeneralName::X400(right))
414        | (GeneralName::EdiParty(left), GeneralName::EdiParty(right))
415        | (GeneralName::IpAddress(left), GeneralName::IpAddress(right)) => (*left, *right),
416        (GeneralName::RegisteredId(left), GeneralName::RegisteredId(right)) => {
417            (left.as_bytes(), right.as_bytes())
418        }
419        (
420            GeneralName::Other {
421                type_id: left_kind,
422                value: left,
423            },
424            GeneralName::Other {
425                type_id: right_kind,
426                value: right,
427            },
428        ) => {
429            let work = (left_kind.as_bytes().len().max(right_kind.as_bytes().len()) as u64)
430                .checked_mul(2)
431                .ok_or_else(|| {
432                    source.resource(structured_asn1::memory::MemoryError::WorkExceeded)
433                })?;
434            execution
435                .charge_work(work)
436                .map_err(|reason| source.resource(reason))?;
437            if left_kind != right_kind {
438                return Ok(false);
439            }
440            (*left, *right)
441        }
442        _ => return Ok(false),
443    };
444    let work = (left.len().max(right.len()) as u64)
445        .checked_mul(2)
446        .and_then(|n| n.checked_add(1))
447        .ok_or_else(|| source.resource(structured_asn1::memory::MemoryError::WorkExceeded))?;
448    execution
449        .charge_work(work)
450        .map_err(|reason| source.resource(reason))?;
451    Ok(left == right)
452}
453
454/// Which certificate category an issuing distribution point covers.
455#[derive(Debug, Clone, Copy, PartialEq, Eq)]
456pub enum CertificateKind {
457    /// No category restriction was encoded.
458    All,
459    /// End entity public-key certificates only.
460    EndEntity,
461    /// Certification authority certificates only.
462    Authority,
463    /// Attribute certificates only, preserved separately from public-key scope.
464    Attribute,
465}
466
467/// Parsed CRL scope, without claiming applicability to a target certificate.
468#[derive(Debug)]
469pub struct IssuingDistributionPoint<'a> {
470    name: Option<PointName<'a>>,
471    kind: CertificateKind,
472    reasons: Option<BitString<'a>>,
473    indirect: bool,
474}
475
476impl<'a> IssuingDistributionPoint<'a> {
477    /// Full distribution-point names, if that alternative was written.
478    #[must_use]
479    pub fn full_name(&self) -> &[GeneralName<'a>] {
480        match &self.name {
481            Some(PointName::Full(names)) => names,
482            _ => &[],
483        }
484    }
485
486    /// Relative RDN, if that alternative was written.
487    #[must_use]
488    pub fn name_relative_to_issuer(&self) -> Option<&RelativeName<'a>> {
489        match &self.name {
490            Some(PointName::Relative(name)) => Some(name),
491            _ => None,
492        }
493    }
494
495    /// Category restriction; it does not classify any supplied certificate.
496    #[must_use]
497    pub fn certificate_kind(&self) -> CertificateKind {
498        self.kind
499    }
500
501    /// Encoded `ReasonFlags`. Absent means all reasons, not an empty mask.
502    #[must_use]
503    pub fn reasons(&self) -> Option<BitString<'a>> {
504        self.reasons
505    }
506
507    /// Whether entries may belong to issuers other than this CRL's issuer.
508    #[must_use]
509    pub fn is_indirect(&self) -> bool {
510        self.indirect
511    }
512
513    /// Decode through the extension's original parser with current execution.
514    pub fn read<W: Work>(extension: &Extension<'a>, execution: &W) -> Result<Self> {
515        const FIELD: &str = "extensions.issuingDistributionPoint";
516        let fail = |code, at| Error::new(code, FIELD, at);
517        let mut reader = extension::sequence(extension, FIELD, execution)?;
518        let mut result = Self {
519            name: None,
520            kind: CertificateKind::All,
521            reasons: None,
522            indirect: false,
523        };
524        let mut last = None;
525        while let Some(element) = reader.next_element().field(FIELD)? {
526            let (number, constructed) = extension::context_of(element.tag())
527                .ok_or_else(|| fail(ErrorCode::UnexpectedField, element.span().start))?;
528            if last.is_some_and(|previous| number <= previous) {
529                return Err(fail(ErrorCode::UnexpectedField, element.span().start));
530            }
531            last = Some(number);
532            match (number, constructed) {
533                (0, true) => {
534                    result.name = Some(access::read_distribution_name(&reader, &element, FIELD)?);
535                }
536                (3, false) => {
537                    let reasons = reader.bit_string(&element).field(FIELD)?;
538                    reader.charge(9).field(FIELD)?;
539                    // RFC 5280 §5.2.5 references ReasonFlags of §4.2.1.13;
540                    // X.690 §11.2.2 trims a DER named bit list to its final one.
541                    // https://www.rfc-editor.org/rfc/rfc5280#section-5.2.5
542                    if reasons.len() > 9
543                        || !(1..=8).any(|bit| reasons.bit(bit))
544                        || !reasons.bit(reasons.len() - 1)
545                    {
546                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
547                    }
548                    result.reasons = Some(reasons);
549                }
550                (1 | 2 | 4 | 5, false) => {
551                    // These BOOLEANs default to FALSE and DER omits defaults.
552                    if !reader.boolean(&element).field(FIELD)? {
553                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
554                    }
555                    if number == 4 {
556                        result.indirect = true;
557                        continue;
558                    }
559                    if result.kind != CertificateKind::All {
560                        return Err(fail(ErrorCode::MalformedValue, element.span().start));
561                    }
562                    result.kind = match number {
563                        1 => CertificateKind::EndEntity,
564                        2 => CertificateKind::Authority,
565                        _ => CertificateKind::Attribute,
566                    };
567                }
568                _ => return Err(fail(ErrorCode::UnexpectedField, element.span().start)),
569            }
570        }
571        // RFC 5280 §5.2.5 disallows an entirely empty IDP. Attribute scope is
572        // kept as its actual category, never treated as public-key coverage.
573        if result.name.is_none()
574            && result.kind == CertificateKind::All
575            && result.reasons.is_none()
576            && !result.indirect
577        {
578            return Err(fail(ErrorCode::EmptySequence, extension.span().start));
579        }
580        Ok(result)
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use crate::general_name::GeneralName::{Dns, Email};
587    #[test]
588    fn dns_and_mailbox_scope_follow_their_separate_case_rules() {
589        // RFC5280 §§7.2/7.5 require DNS and mail host case-insensitive
590        // identity, while the mailbox local-part remains an exact match.
591        let bytes = crate::fixtures::CertificateParts::default().build();
592        let parser = structured_asn1::Parser::der(crate::fixtures::memory());
593        let source = crate::Certificate::read(&parser, &bytes, 100).unwrap();
594        let execution = crate::fixtures::memory();
595        for (left, right, expected) in [
596            (Dns("EXAMPLE.com"), Dns("example.COM"), true),
597            (Email("user@EXAMPLE.com"), Email("user@example.COM"), true),
598            (Email("User@example.com"), Email("user@example.com"), false),
599            (
600                Email("\"a@b\"@EXAMPLE.com"),
601                Email("\"a@b\"@example.com"),
602                true,
603            ),
604        ] {
605            assert_eq!(
606                super::names_match::<false>(&left, &right, source.tbs().issuer(), &execution)
607                    .unwrap(),
608                expected
609            );
610        }
611    }
612
613    #[test]
614    fn uri_scope_compares_normalized_identity_without_folding_path_or_query() {
615        // RFC5280 §7.4 requires scheme/host case, percent, path-segment
616        // and recognized scheme normalization before exact comparison.
617        let bytes = crate::fixtures::CertificateParts::default().build();
618        let parser = structured_asn1::Parser::der(crate::fixtures::memory());
619        let source = crate::Certificate::read(&parser, &bytes, 100).unwrap();
620        let execution = crate::fixtures::memory();
621        for (left, right, expected) in [
622            (
623                "HTTP://Example.com:080/a/../%7e",
624                "http://example.com/~",
625                true,
626            ),
627            ("https://example.com:443", "https://example.com/", true),
628            (
629                "ldap://example.com/",
630                "ldap://EXAMPLE.com:389/??BASE?(objectClass=*)",
631                true,
632            ),
633            ("ftp://example.com:21", "ftp://example.com/", true),
634            (
635                "http://example.com/A?q=A",
636                "http://example.com/a?q=A",
637                false,
638            ),
639            (
640                "http://example.com/a?q=A",
641                "http://example.com/a?q=a",
642                false,
643            ),
644            ("http://example.com/%2f", "http://example.com//", false),
645            ("http://example.com/?", "http://example.com/", false),
646            (
647                "ftp://example.com/a;type=a",
648                "ftp://example.com/a;type=i",
649                false,
650            ),
651        ] {
652            assert_eq!(
653                super::names_match::<false>(
654                    &crate::general_name::GeneralName::Uri(left),
655                    &crate::general_name::GeneralName::Uri(right),
656                    source.tbs().issuer(),
657                    &execution
658                )
659                .unwrap(),
660                expected,
661                "{left} versus {right}"
662            );
663        }
664    }
665}