diff --git a/x509-ocsp/tests/builder.rs b/x509-ocsp/tests/builder.rs index 6c91b94a5..0164ee4f4 100644 --- a/x509-ocsp/tests/builder.rs +++ b/x509-ocsp/tests/builder.rs @@ -1,13 +1,17 @@ #![cfg(feature = "builder")] //! ocsp builder tests +use const_oid::AssociatedOid; use der::{DateTime, Decode, Encode}; use hex_literal::hex; use lazy_static::lazy_static; use rsa::{RsaPrivateKey, pkcs1v15::SigningKey, pkcs8::DecodePrivateKey}; use sha1::Sha1; use sha2::{Sha224, Sha256, Sha384, Sha512}; -use x509_cert::{Certificate, name::Name, serial_number::SerialNumber}; +use x509_cert::{ + Certificate, crl::CertificateList, ext::pkix::CrlReason, name::Name, + serial_number::SerialNumber, +}; use x509_ocsp::builder::*; use x509_ocsp::{ext::*, *}; @@ -434,3 +438,130 @@ fn encode_ocsp_resp_revoked_delegated() { .unwrap(); assert_eq!(&resp.to_der().unwrap(), &resp_der); } + +/// Use an Actalis CRL to test `SingleResponse::from_crl`, including: entries with a reason code, +/// entries without one, and serials it does not list. +mod from_crl { + use super::*; + + fn actalis() -> (Certificate, CertificateList) { + let ca = Certificate::from_der(&std::fs::read("tests/examples/actalis-ca.der").unwrap()) + .unwrap(); + let crl = + CertificateList::from_der(&std::fs::read("tests/examples/actalis-ca-crl.der").unwrap()) + .unwrap(); + (ca, crl) + } + + /// The `thisUpdate` and `nextUpdate` of every response built this way come from the CRL, not + /// from the clock: the assertion a response can make is only as fresh as the list it was read + /// from, and saying otherwise would overstate it. + fn crl_validity() -> (OcspGeneralizedTime, OcspGeneralizedTime) { + ( + OcspGeneralizedTime::from(DateTime::new(2026, 8, 20, 21, 41, 55).unwrap()), + OcspGeneralizedTime::from(DateTime::new(2026, 8, 21, 21, 41, 54).unwrap()), + ) + } + + #[test] + fn a_revoked_serial_carries_its_reason() { + let (ca, crl) = actalis(); + let serial = SerialNumber::new(&hex!("7E2DFAE636120B5FA416DE66F16D1F85")).unwrap(); + let res = SingleResponse::from_crl::(&ca, &crl, serial.clone()).unwrap(); + + let CertStatus::Revoked(info) = res.cert_status else { + panic!("expected revoked, got {:?}", res.cert_status); + }; + assert_eq!( + info.revocation_time, + OcspGeneralizedTime::from(DateTime::new(2025, 9, 15, 14, 2, 17).unwrap()) + ); + assert_eq!(info.revocation_reason, Some(CrlReason::Superseded)); + + // The CertID is built from the issuer rather than copied from anywhere, so the serial asked + // about is the serial answered about. + assert_eq!(res.cert_id.serial_number, serial); + assert_eq!(res.cert_id.hash_algorithm.oid, Sha1::OID); + + let (this_update, next_update) = crl_validity(); + assert_eq!(res.this_update, this_update); + assert_eq!(res.next_update, Some(next_update)); + } + + /// A `reasonCode` entry extension is optional, test an entry that carries no extensions at all. + /// The other way it can be absent -- extensions present with no `reasonCode` among them -- is + /// not reachable here: of this CRL's 132 entries, the 45 with extensions all carry a reason. + #[test] + fn a_revoked_serial_with_no_entry_extensions_reports_no_reason() { + let (ca, crl) = actalis(); + let serial = SerialNumber::new(&hex!("54912434F3363EB4082BB15FF928104A")).unwrap(); + let res = SingleResponse::from_crl::(&ca, &crl, serial.clone()).unwrap(); + + // The premise, stated rather than assumed. + let entry = crl + .tbs_cert_list + .revoked_certificates + .as_ref() + .unwrap() + .iter() + .find(|rc| rc.serial_number == serial) + .unwrap(); + assert!(entry.crl_entry_extensions.is_none()); + + let CertStatus::Revoked(info) = res.cert_status else { + panic!("expected revoked, got {:?}", res.cert_status); + }; + assert_eq!( + info.revocation_time, + OcspGeneralizedTime::from(DateTime::new(2026, 4, 16, 7, 41, 20).unwrap()) + ); + assert_eq!(info.revocation_reason, None); + } + + /// Absent from the list means `good`, per [RFC 2560] but not [RFC 6960]. The + /// serial below was never issued by this CA, and a CRL cannot distinguish that from one it + /// issued and has not revoked. The method's own documentation says so; this pins the behavior + /// that documentation describes. + /// + /// [RFC 2560]: https://datatracker.ietf.org/doc/html/rfc2560#section-2.2 + /// [RFC 6960]: https://datatracker.ietf.org/doc/html/rfc6960#section-2.2 + #[test] + fn a_serial_the_crl_does_not_list_is_good() { + let (ca, crl) = actalis(); + let serial = SerialNumber::new(&hex!("7FDEADBEEF7FDEADBEEF7FDEADBEEF7F")).unwrap(); + let res = SingleResponse::from_crl::(&ca, &crl, serial).unwrap(); + assert_eq!(res.cert_status, CertStatus::good()); + } + + /// Test a CRL that revokes nothing, i.e., it omits `revokedCertificates`. + #[test] + fn a_crl_that_lists_nothing_is_good() { + let ca = Certificate::from_der(&std::fs::read("tests/examples/isrg-root-ye.der").unwrap()) + .unwrap(); + let crl = CertificateList::from_der( + &std::fs::read("tests/examples/isrg-root-ye-crl.der").unwrap(), + ) + .unwrap(); + assert!( + crl.tbs_cert_list.revoked_certificates.is_none(), + "this CRL is expected to have no entries" + ); + + let serial = SerialNumber::new(&hex!("7FDEADBEEF7FDEADBEEF7FDEADBEEF7F")).unwrap(); + let res = SingleResponse::from_crl::(&ca, &crl, serial).unwrap(); + assert_eq!(res.cert_status, CertStatus::good()); + + // Both times reduce from the CRL's `UTCTime`, which is the conversion `OcspGeneralizedTime` + // exists for: OCSP has no UTCTime, and every other X.509 structure still uses it. + assert_eq!( + res.this_update, + OcspGeneralizedTime::from(DateTime::new(2026, 5, 13, 18, 0, 0).unwrap()) + ); + assert_eq!( + res.next_update, + Some(OcspGeneralizedTime::from( + DateTime::new(2027, 5, 11, 23, 59, 59).unwrap() + )) + ); + } +} diff --git a/x509-ocsp/tests/examples/actalis-ca-crl.der b/x509-ocsp/tests/examples/actalis-ca-crl.der new file mode 100644 index 000000000..b422d28ff Binary files /dev/null and b/x509-ocsp/tests/examples/actalis-ca-crl.der differ diff --git a/x509-ocsp/tests/examples/actalis-ca.der b/x509-ocsp/tests/examples/actalis-ca.der new file mode 100644 index 000000000..2c27e6763 Binary files /dev/null and b/x509-ocsp/tests/examples/actalis-ca.der differ diff --git a/x509-ocsp/tests/examples/actalis-unknown-res.der b/x509-ocsp/tests/examples/actalis-unknown-res.der new file mode 100644 index 000000000..d29e2a9e5 Binary files /dev/null and b/x509-ocsp/tests/examples/actalis-unknown-res.der differ diff --git a/x509-ocsp/tests/examples/globalsign-ecdsa-res.der b/x509-ocsp/tests/examples/globalsign-ecdsa-res.der new file mode 100644 index 000000000..7ff219c05 Binary files /dev/null and b/x509-ocsp/tests/examples/globalsign-ecdsa-res.der differ diff --git a/x509-ocsp/tests/examples/isrg-root-ye-crl.der b/x509-ocsp/tests/examples/isrg-root-ye-crl.der new file mode 100644 index 000000000..c0ee7beb1 Binary files /dev/null and b/x509-ocsp/tests/examples/isrg-root-ye-crl.der differ diff --git a/x509-ocsp/tests/examples/isrg-root-ye.der b/x509-ocsp/tests/examples/isrg-root-ye.der new file mode 100644 index 000000000..7354d4281 Binary files /dev/null and b/x509-ocsp/tests/examples/isrg-root-ye.der differ diff --git a/x509-ocsp/tests/responses.rs b/x509-ocsp/tests/responses.rs index 4c0108a5e..c3947f228 100644 --- a/x509-ocsp/tests/responses.rs +++ b/x509-ocsp/tests/responses.rs @@ -24,6 +24,8 @@ const ID_PKIX_OCSP_ARCHIVE_CUTOFF: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.48.1.6"); const SHA_256_WITH_RSA_ENCRYPTION: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"); +const ECDSA_WITH_SHA_384: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3"); +const ID_EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); lazy_static! { // PrintableString: CN = rsa-2048-sha256-ocsp-crt @@ -566,3 +568,139 @@ fn decode_ocsp_resp_revoked_response() { }, } } + +/// Test a response with an `unknown` status. +#[test] +fn decode_ocsp_resp_unknown_status() { + let data = std::fs::read("tests/examples/actalis-unknown-res.der").unwrap(); + let res = OcspResponse::from_der(&data[..]).unwrap(); + let res = assert_ocsp_response(&res); + + let data = &res.tbs_response_data; + assert_eq!(data.version, Version::V1); + assert!(matches!(data.responder_id, ResponderId::ByName(_))); + assert_eq!( + data.produced_at, + OcspGeneralizedTime::from(DateTime::new(2026, 8, 21, 14, 28, 19).unwrap()) + ); + assert_eq!(data.responses.len(), 1); + + let single = &data.responses[0]; + assert_eq!( + single.cert_id, + CertId { + hash_algorithm: AlgorithmIdentifierOwned { + oid: ID_SHA1, + parameters: None, + }, + issuer_name_hash: OctetString::new( + &hex!("B22C736151182FB02D1C254C449D4F57552A5955")[..] + ) + .unwrap(), + issuer_key_hash: OctetString::new( + &hex!("AF2CA406CBF08A03146513A88D60F9C8D31B414C")[..] + ) + .unwrap(), + serial_number: SerialNumber::new(&hex!("7FDEADBEEF7FDEADBEEF7FDEADBEEF7F")[..]) + .unwrap(), + } + ); + assert_single_response( + single, + CertStatus::unknown(), + &OcspGeneralizedTime::from(DateTime::new(2026, 8, 21, 14, 11, 55).unwrap()), + Some(&OcspGeneralizedTime::from( + DateTime::new(2026, 8, 22, 14, 11, 54).unwrap(), + )), + ); + + // A responder answering `unknown` still signs, and still delivers the certificates that let the + // answer be checked -- an unknown status is an assertion, not a refusal to make one. + assert_eq!(res.signature_algorithm.oid, SHA_256_WITH_RSA_ENCRYPTION); + assert_eq!(res.certs.as_ref().unwrap().len(), 2); +} + +/// Every other response here is RSA-signed, so nothing exercised an ECDSA `AlgorithmIdentifier` -- +/// which differs from the RSA one in a way that a decoder can get wrong without any test noticing: +/// its `parameters` are **absent** where RSA's carry an explicit NULL. +#[test] +fn decode_ocsp_resp_ecdsa_signature() { + let data = std::fs::read("tests/examples/globalsign-ecdsa-res.der").unwrap(); + let res = OcspResponse::from_der(&data[..]).unwrap(); + let res = assert_ocsp_response(&res); + + assert_eq!(res.signature_algorithm.oid, ECDSA_WITH_SHA_384); + assert_eq!(res.signature_algorithm.parameters, None); + + // A delegated responder, named by the hash of its key rather than by its name, and carrying the + // certificate that key belongs to. + let data = &res.tbs_response_data; + let ResponderId::ByKey(key_hash) = &data.responder_id else { + panic!("expected byKey, got {:?}", data.responder_id); + }; + assert_eq!( + key_hash.as_bytes(), + &hex!("3524B43B9D81571C570182FF8721FC51E033FA73")[..] + ); + let certs = res.certs.as_ref().unwrap(); + assert_eq!(certs.len(), 1); + assert_eq!( + certs[0] + .tbs_certificate() + .subject_public_key_info() + .algorithm + .oid, + ID_EC_PUBLIC_KEY + ); + + // The CertID is SHA-1 whatever the signature is: the two choices are unrelated, and a responder + // signing with P-384 still answers about a certificate identified the way the request asked. + let single = &data.responses[0]; + assert_eq!(single.cert_id.hash_algorithm.oid, ID_SHA1); + assert_eq!(single.cert_id.hash_algorithm.parameters, None); + assert_eq!( + single.cert_id.serial_number, + SerialNumber::new(&hex!("1C93F561EB7BD2391D393423")[..]).unwrap() + ); + assert_single_response( + single, + CertStatus::good(), + &OcspGeneralizedTime::from(DateTime::new(2026, 8, 21, 13, 3, 13).unwrap()), + Some(&OcspGeneralizedTime::from( + DateTime::new(2026, 8, 25, 13, 3, 12).unwrap(), + )), + ); +} + +/// Test invalidate `OCSPResponseStatus` value. +#[test] +fn decode_ocsp_resp_rejects_a_status_outside_the_enumeration() { + let data = std::fs::read("tests/examples/ocsp-try-later.der").unwrap(); + let last = data.len() - 1; + assert_eq!(data[last], 3, "expected tryLater(3)"); + + for reserved in [4u8, 7u8] { + let mut broken = data.clone(); + broken[last] = reserved; + assert!( + OcspResponse::from_der(&broken[..]).is_err(), + "responseStatus {reserved} decoded, and it has no meaning" + ); + } +} + +/// Truncation and trailing data are the two ways a caller's buffer can be the wrong length, and +/// they are different faults: one is a message that was cut off, the other a message with something +/// after it. Both must be refused -- accepting either would let a response be read from a buffer +/// nobody has established the bounds of. +#[test] +fn decode_ocsp_resp_rejects_truncation_and_trailing_data() { + let data = std::fs::read("tests/examples/sha1-certid-ocsp-res.der").unwrap(); + assert!(OcspResponse::from_der(&data[..]).is_ok()); + + assert!(OcspResponse::from_der(&data[..data.len() - 1]).is_err()); + + let mut with_trailer = data.clone(); + with_trailer.push(0); + assert!(OcspResponse::from_der(&with_trailer[..]).is_err()); +} diff --git a/x509-ocsp/tests/roundtrip.rs b/x509-ocsp/tests/roundtrip.rs new file mode 100644 index 000000000..23e5dc391 --- /dev/null +++ b/x509-ocsp/tests/roundtrip.rs @@ -0,0 +1,60 @@ +//! Test re-encoding of all sample requests and responses. +//! +//! Files that are not OCSP messages — certificates, keys, CRLs — are skipped by failing to decode +//! as either. + +use der::{Decode, Encode}; +use std::fs; +use x509_ocsp::{OcspRequest, OcspResponse}; + +const REQUESTS: usize = 9; +const RESPONSES: usize = 16; + +#[test] +fn every_message_reencodes_to_itself() { + let mut requests = 0usize; + let mut responses = 0usize; + let mut skipped = 0usize; + + let mut names = fs::read_dir("tests/examples") + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().to_string()) + .collect::>(); + names.sort(); + + for name in &names { + let bytes = fs::read(format!("tests/examples/{name}")).unwrap(); + + if let Ok(response) = OcspResponse::from_der(&bytes[..]) { + assert_eq!( + response.to_der().unwrap(), + bytes, + "{name} does not re-encode to itself" + ); + responses += 1; + continue; + } + let parsed: Result = OcspRequest::from_der(&bytes[..]); + if let Ok(request) = parsed { + assert_eq!( + request.to_der().unwrap(), + bytes, + "{name} does not re-encode to itself" + ); + requests += 1; + continue; + } + skipped += 1; + } + + println!("{requests} requests, {responses} responses, {skipped} not messages"); + + // A sweep that decoded nothing would satisfy every assertion above this one. + assert_eq!( + (requests, responses), + (REQUESTS, RESPONSES), + "expected {REQUESTS} requests and {RESPONSES} responses, got {requests} and {responses} \ + ({skipped} files decoded as neither). A message that stops decoding is skipped rather \ + than failed, so a shortfall here is the symptom worth chasing." + ); +}