Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 132 additions & 1 deletion x509-ocsp/tests/builder.rs
Original file line number Diff line number Diff line change
@@ -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::*, *};

Expand Down Expand Up @@ -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::<Sha1>(&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::<Sha1>(&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::<Sha1>(&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::<Sha1>(&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()
))
);
}
}
Binary file added x509-ocsp/tests/examples/actalis-ca-crl.der
Binary file not shown.
Binary file added x509-ocsp/tests/examples/actalis-ca.der
Binary file not shown.
Binary file added x509-ocsp/tests/examples/actalis-unknown-res.der
Binary file not shown.
Binary file added x509-ocsp/tests/examples/globalsign-ecdsa-res.der
Binary file not shown.
Binary file added x509-ocsp/tests/examples/isrg-root-ye-crl.der
Binary file not shown.
Binary file added x509-ocsp/tests/examples/isrg-root-ye.der
Binary file not shown.
138 changes: 138 additions & 0 deletions x509-ocsp/tests/responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
60 changes: 60 additions & 0 deletions x509-ocsp/tests/roundtrip.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<String>>();
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, _> = 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."
);
}