Skip to main content

structured_xades/material/
exclusivity.rs

1//! Exclusive CA material in ordinary CertificateValues/RevocationValues.
2
3use super::{Index, Occurrence, Placement};
4use crate::Xades;
5use crate::xml::{Access, Snapshot, Xml};
6use core::fmt;
7use structured_asn1::memory::{MemoryError, OwnedMap, OwnedVec, Work};
8use structured_ltv::material::{ArtifactRef, ArtifactUses, IndexError};
9use structured_ltv::pki::Uses;
10use structured_ltv::timestamp::TimestampScope;
11use structured_pkix::{Certificate, Version};
12
13/// Result for one ordinary-container occurrence of actually used material.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum ExclusiveFindingKind {
16    /// A CA certificate/status value is exclusively for credentials or TSAs.
17    ForbiddenCa,
18    /// An old certificate supplies no CA classification. This cannot establish
19    /// that an exclusive value is allowed or that it violates the prohibition.
20    UnstatedCa,
21}
22
23/// One original occurrence and the certificate whose CA fact matters. For a
24/// CRL/OCSP value this is its actual target, not its authentication signer.
25pub struct ExclusiveFinding<'certificate, 'document, N, D, G> {
26    artifact: ArtifactRef<'certificate, 'document>,
27    certificate: &'certificate Certificate<'document>,
28    occurrence: Occurrence<N, D, G>,
29    kind: ExclusiveFindingKind,
30}
31
32impl<'certificate, 'document, N: Copy, D: Copy, G: Copy>
33    ExclusiveFinding<'certificate, 'document, N, D, G>
34{
35    /// Exact original certificate/whole CRL/basic-response identity.
36    #[must_use]
37    pub fn artifact(&self) -> ArtifactRef<'certificate, 'document> {
38        self.artifact
39    }
40    /// Original subject certificate, preserving its own PKIX source coordinates.
41    #[must_use]
42    pub fn certificate(&self) -> &'certificate Certificate<'document> {
43        self.certificate
44    }
45    /// Original offending/indeterminate ordinary-container copy.
46    #[must_use]
47    pub fn occurrence(&self) -> Occurrence<N, D, G> {
48        self.occurrence
49    }
50    /// Prohibition versus insufficient CA evidence, never a resource fallback.
51    #[must_use]
52    pub fn kind(&self) -> ExclusiveFindingKind {
53        self.kind
54    }
55}
56
57/// Checks ONLY EN319132-1 ยงยง5.4.2(5)/5.4.3(4) on actually used X.509 material.
58/// Empty findings do not prove other presence/placement, usable status, unknown
59/// credential-format data, timestamp coverage, chronology or a baseline grade.
60pub struct ExclusivePlacement<'certificate, 'document, N, D, G> {
61    snapshot: Snapshot<D, G>,
62    signature: N,
63    findings: OwnedVec<ExclusiveFinding<'certificate, 'document, N, D, G>>,
64}
65
66impl<'certificate, 'document, N: Copy, D: Copy, G: Copy>
67    ExclusivePlacement<'certificate, 'document, N, D, G>
68{
69    /// Same immutable source generation used for the complete actor census.
70    #[must_use]
71    pub fn snapshot(&self) -> Snapshot<D, G> {
72        self.snapshot
73    }
74    /// Original selected signature, not any signature in the document.
75    #[must_use]
76    pub fn signature(&self) -> N {
77        self.signature
78    }
79    /// Every relevant ordinary copy, in artifact/occurrence order. Unstated CA
80    /// facts remain explicit rather than disappearing as successful absence.
81    #[must_use]
82    pub fn findings(&self) -> &[ExclusiveFinding<'certificate, 'document, N, D, G>] {
83        &self.findings
84    }
85}
86
87/// Source/current-operation and PKIX errors are terminal, not findings.
88pub enum ExclusivityError<'certificate, 'document, N> {
89    /// Whole-signature requirements are needed; a preceding-archive subset
90    /// cannot classify all original incorporated material.
91    Scope {
92        /// Actual enclosing scope node.
93        node: N,
94    },
95    /// Original index/generation or current execution refusal.
96    Index(IndexError<N>),
97    /// Current traversal, cache or retained report capacity was not admitted.
98    Resource {
99        /// Original enclosing selected signature.
100        node: N,
101        /// Original admission cause.
102        cause: MemoryError,
103    },
104    /// Original certificate query failed; offsets stay in THAT certificate's
105    /// binary source, not fabricated XML coordinates. Includes resource and
106    /// processing causes such as `ForeignReader` without converting them to No.
107    Certificate {
108        /// Actual original subject whose CA query refused.
109        certificate: &'certificate Certificate<'document>,
110        /// Unchanged shared PKIX error.
111        cause: structured_pkix::Error,
112    },
113}
114
115impl<N: fmt::Debug> fmt::Debug for ExclusivityError<'_, '_, N> {
116    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::Scope { node } => formatter.debug_struct("Scope").field("node", node).finish(),
119            Self::Index(cause) => formatter.debug_tuple("Index").field(cause).finish(),
120            Self::Resource { node, cause } => formatter
121                .debug_struct("Resource")
122                .field("node", node)
123                .field("cause", cause)
124                .finish(),
125            // The original certificate is available programmatically; diagnostic
126            // formatting must not traverse/dump its untrusted parsed catalogues.
127            Self::Certificate { cause, .. } => formatter
128                .debug_struct("Certificate")
129                .field("cause", cause)
130                .finish_non_exhaustive(),
131        }
132    }
133}
134
135/// Result using this owner's unchanged XML/source identities and causes.
136pub type ExclusivityResult<'certificate, 'document, X> = Result<
137    ExclusivePlacement<
138        'certificate,
139        'document,
140        <X as Xml>::Id,
141        <X as Xml>::Document,
142        <X as Xml>::Generation,
143    >,
144    ExclusivityError<'certificate, 'document, <X as Xml>::Id>,
145>;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub(crate) enum CaFact {
149    Asserted,
150    NotAsserted,
151    Unstated,
152}
153
154pub(crate) enum CaError<'certificate, 'document> {
155    Resource(MemoryError),
156    Certificate(&'certificate Certificate<'document>, structured_pkix::Error),
157}
158
159impl fmt::Debug for CaError<'_, '_> {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::Resource(cause) => formatter.debug_tuple("Resource").field(cause).finish(),
163            Self::Certificate(_, cause) => formatter
164                .debug_struct("Certificate")
165                .field("cause", cause)
166                .finish_non_exhaustive(),
167        }
168    }
169}
170
171fn exclusive(uses: Uses) -> bool {
172    if uses.signature() {
173        return false;
174    }
175    uses.credential() || uses.timestamp()
176}
177
178// Annex A.1.1 forbids exclusively credential CA references. Unlike the
179// ordinary value-container rule, TSA-only and shared credential/TSA uses are
180// not forbidden here. Keep the two normative predicates distinct.
181// https://www.etsi.org/deliver/etsi_en/319100_319199/31913201/01.03.01_60/en_31913201v010301p.pdf
182fn exclusive_credential(uses: Uses) -> bool {
183    uses.credential() && !uses.signature() && !uses.timestamp()
184}
185
186fn certificate_ca<W: Work>(
187    certificate: &Certificate<'_>,
188    work: &W,
189) -> Result<CaFact, structured_pkix::Error> {
190    let basic = certificate.tbs().extensions().basic_constraints(work)?;
191    // RFC5280 4.2.1.9 defines cA/default FALSE and absent BC in V3.
192    // 6.1.4(k) requires out-of-band CA evidence for V1/V2: absence alone
193    // cannot establish that an old certificate is not a CA.
194    // https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.9
195    // https://www.rfc-editor.org/rfc/rfc5280#section-6.1.4
196    Ok(match basic {
197        Some(basic) if basic.is_authority() => CaFact::Asserted,
198        Some(_) => CaFact::NotAsserted,
199        None if certificate.tbs().version() == Version::V3 => CaFact::NotAsserted,
200        None => CaFact::Unstated,
201    })
202}
203
204pub(crate) struct ReferenceCa<'certificate, 'document> {
205    pub(crate) certificate: &'certificate Certificate<'document>,
206    pub(crate) fact: CaFact,
207}
208
209pub(crate) struct ReferenceStatus<'certificate, 'document> {
210    pub(crate) artifact: ArtifactRef<'certificate, 'document>,
211    pub(crate) certificate: &'certificate Certificate<'document>,
212    pub(crate) fact: CaFact,
213}
214
215pub(crate) fn reference_status_candidates<'certificate, 'document, N, D, G, I, W: Work>(
216    artifacts: &ArtifactUses<'_, '_, '_, 'certificate, 'document, N, D, G, I>,
217    work: &W,
218) -> Result<OwnedVec<ReferenceStatus<'certificate, 'document>, W>, CaError<'certificate, 'document>>
219{
220    let mut candidates = work.vector();
221    let mut scan = CaScan {
222        artifacts,
223        cache: work.map(),
224        work,
225    };
226    for (ordinal, entry) in artifacts.entries().iter().enumerate() {
227        work.charge_work(1)?;
228        if !exclusive_credential(entry.uses())
229            || matches!(entry.artifact(), ArtifactRef::Certificate(_))
230        {
231            continue;
232        }
233        // Annex A.1.2(5) concerns the revocation TARGET CA, not the
234        // authentication signer. The same target/cache kernel serves values.
235        // https://www.etsi.org/deliver/etsi_en/319100_319199/31913201/01.03.01_60/en_31913201v010301p.pdf
236        if let Some((fact, target)) = scan.witness(ordinal, exclusive_credential)? {
237            candidates.try_push(ReferenceStatus {
238                artifact: entry.artifact(),
239                certificate: scan.certificate(target),
240                fact,
241            })?;
242        }
243    }
244    Ok(candidates)
245}
246
247pub(crate) fn reference_certificate_candidates<'certificate, 'document, N, D, G, I, W: Work>(
248    artifacts: &ArtifactUses<'_, '_, '_, 'certificate, 'document, N, D, G, I>,
249    work: &W,
250) -> Result<OwnedVec<ReferenceCa<'certificate, 'document>, W>, CaError<'certificate, 'document>> {
251    let mut candidates = work.vector();
252    for entry in artifacts.entries() {
253        work.charge_work(1)?;
254        if !exclusive_credential(entry.uses()) {
255            continue;
256        }
257        let ArtifactRef::Certificate(certificate) = entry.artifact() else {
258            continue;
259        };
260        let fact = certificate_ca(certificate, work)
261            .map_err(|cause| CaError::Certificate(certificate, cause))?;
262        if fact != CaFact::NotAsserted {
263            candidates.try_push(ReferenceCa { certificate, fact })?;
264        }
265    }
266    Ok(candidates)
267}
268
269impl From<MemoryError> for CaError<'_, '_> {
270    fn from(cause: MemoryError) -> Self {
271        Self::Resource(cause)
272    }
273}
274
275// The artifact census a CA scan reads, by artifact ordinal.
276trait Census<'certificate, 'document> {
277    fn artifact(&self, ordinal: usize) -> ArtifactRef<'certificate, 'document>;
278    fn uses(&self, ordinal: usize) -> Uses;
279    fn targets(&self, ordinal: usize) -> impl Iterator<Item = usize>;
280}
281
282impl<'certificate, 'document, N, D, G, I> Census<'certificate, 'document>
283    for ArtifactUses<'_, '_, '_, 'certificate, 'document, N, D, G, I>
284{
285    fn artifact(&self, ordinal: usize) -> ArtifactRef<'certificate, 'document> {
286        self.entries()[ordinal].artifact()
287    }
288    fn uses(&self, ordinal: usize) -> Uses {
289        self.entries()[ordinal].uses()
290    }
291    fn targets(&self, ordinal: usize) -> impl Iterator<Item = usize> {
292        ArtifactUses::targets(self, ordinal)
293    }
294}
295
296struct CaScan<'query, C, W: Work> {
297    artifacts: &'query C,
298    cache: OwnedMap<usize, CaFact, W>,
299    work: &'query W,
300}
301
302impl<'certificate, 'document, C: Census<'certificate, 'document>, W: Work> CaScan<'_, C, W> {
303    fn certificate(&self, index: usize) -> &'certificate Certificate<'document> {
304        let ArtifactRef::Certificate(certificate) = self.artifacts.artifact(index) else {
305            unreachable!("a status target is an admitted certificate ordinal")
306        };
307        certificate
308    }
309
310    fn class(&mut self, index: usize) -> Result<CaFact, CaError<'certificate, 'document>> {
311        self.work.charge_work(1)?;
312        if let Some(&fact) = self.cache.get(&index, self.work)? {
313            return Ok(fact);
314        }
315        let certificate = self.certificate(index);
316        let fact = certificate_ca(certificate, self.work)
317            .map_err(|cause| CaError::Certificate(certificate, cause))?;
318        self.cache.try_insert(index, fact)?;
319        Ok(fact)
320    }
321
322    fn witness(
323        &mut self,
324        index: usize,
325        accepts: impl Fn(Uses) -> bool,
326    ) -> Result<Option<(CaFact, usize)>, CaError<'certificate, 'document>> {
327        if matches!(self.artifacts.artifact(index), ArtifactRef::Certificate(_)) {
328            return match self.class(index)? {
329                CaFact::NotAsserted => Ok(None),
330                fact => Ok(Some((fact, index))),
331            };
332        }
333        let mut unstated = None;
334        let artifacts = self.artifacts;
335        for certificate in artifacts.targets(index) {
336            self.work.charge_work(1)?;
337            let uses = artifacts.uses(certificate);
338            if !accepts(uses) {
339                continue;
340            }
341            match self.class(certificate)? {
342                CaFact::Asserted => return Ok(Some((CaFact::Asserted, certificate))),
343                CaFact::Unstated => unstated = Some((CaFact::Unstated, certificate)),
344                CaFact::NotAsserted => {}
345            }
346        }
347        Ok(unstated)
348    }
349}
350
351/// Enforce the two explicit CA exclusivity prohibitions after ALL actor uses
352/// have been unioned by exact original artifact identity. Shared ordinary use
353/// prevents an artifact being called exclusive to credentials/TSAs. EE values
354/// and other containers are not covered by these prohibitions; duplicate
355/// avoidance SHOULDs are not converted into extra bans. Embedded OCSP signer
356/// certificates are not standalone `CertificateValues` children.
357/// <https://www.etsi.org/deliver/etsi_en/319100_319199/31913201/01.03.01_60/en_31913201v010301p.pdf>
358///
359/// Queries use current Work and the existing lazy PKIX decoder/provenance;
360/// scratch classifications are cached once per original certificate. A late
361/// refusal drops the entire report. This is a component check, not a level.
362///
363/// # Panics
364/// Only for a violated private census invariant: certificate/linked-target
365/// ordinals are recorded before publication and the catalogue is immutable.
366pub fn exclusivity<'certificate, 'document, X: Xml, W: Work, Q: Work>(
367    artifacts: &ArtifactUses<
368        '_,
369        '_,
370        '_,
371        'certificate,
372        'document,
373        X::Id,
374        X::Document,
375        X::Generation,
376        Xades,
377    >,
378    index: &Index<'_, X::Id, X::Document, X::Generation, W>,
379    access: &Access<'_, X, Q>,
380) -> ExclusivityResult<'certificate, 'document, X> {
381    let required = artifacts.inputs().requirements();
382    let scope = required.scope();
383    if !matches!(scope, TimestampScope::All(_)) || scope.location().snapshot != access.snapshot() {
384        return Err(ExclusivityError::Scope {
385            node: scope.location().node,
386        });
387    }
388    let signature = required.signature();
389    index
390        .require_signature(signature, access)
391        .map_err(ExclusivityError::Index)?;
392    let work = access.execution();
393    let resource = |cause| ExclusivityError::Resource {
394        node: signature,
395        cause,
396    };
397    let mut findings = work.vector();
398    let mut scan = CaScan {
399        artifacts,
400        cache: work.map(),
401        work,
402    };
403    for (ordinal, entry) in artifacts.entries().iter().enumerate() {
404        work.charge_work(1).map_err(resource)?;
405        let uses = entry.uses();
406        if !exclusive(uses) {
407            continue;
408        }
409        let mut classified = false;
410        let mut witness = None;
411        for copy in index
412            .artifact(entry.artifact(), access)
413            .map_err(ExclusivityError::Index)?
414        {
415            let copy = copy.map_err(ExclusivityError::Index)?;
416            if !matches!(copy.placement(), Placement::Signature(_))
417                || copy.embedded_certificate().is_some()
418            {
419                continue;
420            }
421            if !classified {
422                witness = scan
423                    .witness(ordinal, exclusive)
424                    .map_err(|cause| match cause {
425                        CaError::Resource(cause) => resource(cause),
426                        CaError::Certificate(certificate, cause) => {
427                            ExclusivityError::Certificate { certificate, cause }
428                        }
429                    })?;
430                classified = true;
431            }
432            if let Some((fact, certificate)) = witness {
433                let kind = match fact {
434                    CaFact::Asserted => ExclusiveFindingKind::ForbiddenCa,
435                    CaFact::Unstated => ExclusiveFindingKind::UnstatedCa,
436                    CaFact::NotAsserted => unreachable!("non-CA produces no witness"),
437                };
438                findings
439                    .try_push(ExclusiveFinding {
440                        artifact: entry.artifact(),
441                        certificate: scan.certificate(certificate),
442                        occurrence: copy,
443                        kind,
444                    })
445                    .map_err(resource)?;
446            }
447        }
448    }
449    Ok(ExclusivePlacement {
450        snapshot: access.snapshot(),
451        signature,
452        findings: findings.into_owned(),
453    })
454}
455
456#[cfg(test)]
457mod tests;