Skip to main content

structured_pkix/ocsp/
retention.rs

1//! Status retention and the extended revoked definition (RFC6960 §§2.2, 4.4.4, 4.4.8).
2
3use super::{BasicResponse, RecordedStatus, SingleResponse, end};
4use crate::crl::Reason;
5use crate::error::{Error, ErrorCode, Located, Result};
6use crate::extension::Extension;
7use crate::oid;
8use crate::temporal::{Instant, Reading};
9use structured_asn1::memory::Work;
10use structured_asn1::{Tag, universal};
11
12const CUTOFF: &str = "ocspResponse.singleResponse.archiveCutoff";
13const EXTENDED: &str = "ocspResponse.extendedRevoke";
14
15// RFC6960 §4.4.4: `ArchiveCutoff ::= GeneralizedTime`, with no criticality
16// requirement, so either marking is accepted.
17// https://www.rfc-editor.org/rfc/rfc6960#section-4.4.4
18pub(super) fn archive_cutoff<W: Work>(extension: &Extension<'_>, execution: &W) -> Result<Reading> {
19    let mut reader = extension.reader_scoped(execution).field(CUTOFF)?;
20    let value = reader
21        .expect(Tag::primitive(universal::GENERALIZED_TIME))
22        .field(CUTOFF)?;
23    end(&reader, CUTOFF)?;
24    crate::time::read_generalized_in(&reader, &value, CUTOFF)
25}
26
27// RFC6960 §4.4.8: "The value of the extension SHALL be NULL. This extension
28// MUST NOT be marked critical."
29// https://www.rfc-editor.org/rfc/rfc6960#section-4.4.8
30pub(super) fn extended_revoke<W: Work>(extension: &Extension<'_>, execution: &W) -> Result<()> {
31    if extension.is_critical() {
32        return Err(Error::new(
33            ErrorCode::MalformedValue,
34            EXTENDED,
35            extension.span().start,
36        ));
37    }
38    let mut reader = extension.reader_scoped(execution).field(EXTENDED)?;
39    let value = reader
40        .expect(Tag::primitive(universal::NULL))
41        .field(EXTENDED)?;
42    reader.null(&value).field(EXTENDED)?;
43    end(&reader, EXTENDED)
44}
45
46// RFC6960 §4.4.8: the extension "MUST be placed in responseExtensions, and it
47// MUST NOT appear in singleExtensions".
48pub(super) fn misplaced(extension: &Extension<'_>) -> Error {
49    Error::new(
50        ErrorCode::MalformedValue,
51        "ocspResponse.singleResponse.extendedRevoke",
52        extension.span().start,
53    )
54}
55
56// RFC6960 §2.2: a "revoked" answer for a non-issued certificate specifies
57// certificateHold and revocationTime January 1, 1970, and includes neither CRL
58// references (§4.4.2) nor CRL entry extensions (§4.4.5). Only that exact
59// assertion, under a response carrying the extended revoked definition, is
60// read as non-issued; any other hold stays a hold.
61// https://www.rfc-editor.org/rfc/rfc6960#section-2.2
62pub(super) fn not_issued<W: Work>(assertion: &SingleResponse<'_>, execution: &W) -> Result<bool> {
63    let RecordedStatus::Revoked {
64        time,
65        reason: Some(Reason::CertificateHold),
66        ..
67    } = assertion.status
68    else {
69        return Ok(false);
70    };
71    if time.instant() != Instant::whole(0) {
72        return Ok(false);
73    }
74    for kind in [
75        oid::OCSP_CRL,
76        oid::CRL_REASON,
77        oid::INVALIDITY_DATE,
78        oid::CERTIFICATE_ISSUER,
79    ] {
80        if assertion.extensions.get(kind, execution)?.is_some() {
81            return Ok(false);
82        }
83    }
84    Ok(true)
85}
86
87impl BasicResponse<'_> {
88    /// Whether the responder applies the extended revoked definition, so that a
89    /// matching "revoked" assertion can name a certificate that was never issued.
90    pub fn extended_revoke<W: Work>(&self, execution: &W) -> Result<bool> {
91        match self.extensions.get(oid::OCSP_EXTENDED_REVOKE, execution)? {
92            Some(extension) => extended_revoke(extension, execution).map(|()| true),
93            None => Ok(false),
94        }
95    }
96
97    /// Status of `responses()[index]` under this response's extended revoked
98    /// definition: [`super::Status::NotIssued`] for the exact RFC6960 §2.2
99    /// non-issued answer, otherwise the same reading as
100    /// [`SingleResponse::status`]. `None` when there is no such assertion.
101    ///
102    /// The assertion is named by position so that it always belongs to this
103    /// response: §4.4.8 scopes the definition to the response that carries it.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use std::sync::Arc;
109    /// use structured_asn1::Parser;
110    /// use structured_asn1::memory::{Memory, MemoryAccount};
111    /// use structured_pkix::ocsp::{Response, Status};
112    ///
113    /// fn tlv(tag: u8, contents: &[u8]) -> Vec<u8> {
114    ///     let length = contents.len() as u8;
115    ///     let header: &[u8] = if length < 128 { &[tag, length] } else { &[tag, 0x81, length] };
116    ///     [header, contents].concat()
117    /// }
118    /// let algorithm = tlv(0x30, &[6, 3, 0x2a, 3, 4]);
119    /// let id = tlv(0x30, &[algorithm.clone(), tlv(4, &[1; 20]), tlv(4, &[2; 20]), vec![2, 1, 1]].concat());
120    /// let date = tlv(24, b"20261012120000Z");
121    /// // revoked, certificateHold, 1970-01-01T00:00:00Z
122    /// let hold = tlv(0xa1, &[tlv(24, b"19700101000000Z"), tlv(0xa0, &[10, 1, 6])].concat());
123    /// let single = tlv(0x30, &[id, hold, date.clone()].concat());
124    /// let extended = tlv(0x30, &[tlv(6, &[0x2b, 6, 1, 5, 5, 7, 0x30, 1, 9]), tlv(4, &[5, 0])].concat());
125    /// let data = tlv(0x30, &[
126    ///     tlv(0xa2, &tlv(4, &[3; 20])), date, tlv(0x30, &single), tlv(0xa1, &tlv(0x30, &extended)),
127    /// ].concat());
128    /// let basic = tlv(0x30, &[data, algorithm, vec![3, 2, 0, 0xab]].concat());
129    /// let bytes = tlv(0x30, &[tlv(6, &[0x2b, 6, 1, 5, 5, 7, 0x30, 1, 1]), tlv(4, &basic)].concat());
130    /// let envelope = tlv(0x30, &[&[10, 1, 0][..], &tlv(0xa0, &bytes)].concat());
131    ///
132    /// let execution = Memory::new(Arc::new(MemoryAccount::new(65536, 65536)));
133    /// let parser = Parser::der(execution.clone());
134    /// let Response::Successful(payload) = Response::read(&parser, &envelope, 0).unwrap() else {
135    ///     unreachable!()
136    /// };
137    /// let response = payload.into_basic(&execution).unwrap().unwrap();
138    /// assert!(matches!(response.responses()[0].status(), Status::Revoked { .. }));
139    /// assert_eq!(response.status_of(0, &execution).unwrap(), Some(Status::NotIssued));
140    /// assert_eq!(response.status_of(1, &execution).unwrap(), None);
141    /// ```
142    ///
143    /// # Errors
144    ///
145    /// A malformed response-wide extended revoked definition, a misplaced
146    /// marker on the selected assertion, or a spent work ceiling. Other
147    /// extension syntax and critical demands still require `check_extensions`.
148    pub fn status_of<W: Work>(&self, index: usize, execution: &W) -> Result<Option<super::Status>> {
149        let Some(assertion) = self.responses.get(index) else {
150            return Ok(None);
151        };
152        // RFC6960 §4.4.8 forbids this marker in singleExtensions. Check its
153        // placement even when another critical demand is unsupported; this
154        // status-only reading must not bless a malformed not-issued answer.
155        // https://www.rfc-editor.org/rfc/rfc6960#section-4.4.8
156        if let Some(extension) = assertion
157            .extensions
158            .get(oid::OCSP_EXTENDED_REVOKE, execution)?
159        {
160            return Err(misplaced(extension));
161        }
162        let extended = self.extended_revoke(execution)?;
163        status(assertion, extended, execution).map(Some)
164    }
165}
166
167pub(super) fn status<W: Work>(
168    assertion: &SingleResponse<'_>,
169    extended: bool,
170    execution: &W,
171) -> Result<super::Status> {
172    if extended && not_issued(assertion, execution)? {
173        return Ok(super::Status::NotIssued);
174    }
175    Ok(assertion.status())
176}
177
178impl SingleResponse<'_> {
179    /// The responder's archive cutoff for this assertion: status is retained for
180    /// certificates whose validity ended at or after it, so a certificate with
181    /// `notAfter >= cutoff` stays covered after its expiry.
182    /// Decimal fractions retain their subnanosecond remainder; comparing an
183    /// expiry to `cutoff.start()` alone must not discard that remainder.
184    pub fn archive_cutoff<W: Work>(&self, execution: &W) -> Result<Option<Reading>> {
185        match self.extensions.get(oid::OCSP_ARCHIVE_CUTOFF, execution)? {
186            Some(extension) => archive_cutoff(extension, execution).map(Some),
187            None => Ok(None),
188        }
189    }
190}