use std::collections::HashSet;
use std::time::Duration;
use x509_cert::Certificate;
use super::{cert, expiry};
use crate::cms::{extract_cms_from_byterange, find_byteranges};
use crate::net::SigningTransport;
use crate::{Result, RevenantError};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CertInfo {
pub name: Option<String>,
pub email: Option<String>,
pub organization: Option<String>,
pub dn: Option<String>,
pub not_before: Option<String>,
pub not_after: Option<String>,
}
impl CertInfo {
pub fn from_x509_der(cert_der: &[u8]) -> Result<Self> {
let cert = cert::parse_der(cert_der)?;
Ok(cert_info_of(&cert))
}
pub fn from_cms(cms_der: &[u8]) -> Result<Self> {
let signer = crate::cms::signer_certificate(cms_der).ok_or_else(|| {
RevenantError::Certificate(
"CMS blob does not name exactly one signer certificate.".to_owned(),
)
})?;
Ok(cert_info_of(&signer))
}
pub fn all_from_pdf(pdf_bytes: &[u8]) -> Result<Vec<Self>> {
let byteranges = find_byteranges(pdf_bytes)?;
if byteranges.is_empty() {
return Err(RevenantError::Certificate(
"No embedded signature found in this PDF.".to_owned(),
));
}
let mut results = Vec::new();
let mut seen_dns: HashSet<String> = HashSet::new();
for br in &byteranges {
let info = match extract_cms_from_byterange(pdf_bytes, br.len1, br.off2)
.and_then(|cms| Self::from_cms(&cms))
{
Ok(info) => info,
Err(e) => {
log::debug!("Skipping signature (extraction failed): {e}");
continue;
}
};
if let Some(dn) = info.dn.clone().filter(|d| !d.is_empty()) {
if seen_dns.insert(dn) {
results.push(info);
}
}
}
if results.is_empty() {
return Err(RevenantError::Certificate(
"Could not extract any certificate info from PDF signatures.".to_owned(),
));
}
Ok(results)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CmsCertSummary {
pub subject: String,
pub issuer: String,
pub serial: String,
pub not_before: Option<String>,
pub not_after: Option<String>,
}
pub fn summarize_cms_certificates(cms_der: &[u8]) -> Result<Vec<CmsCertSummary>> {
let certs = cert::all_certs_from_cms(cms_der)?;
Ok(certs
.iter()
.map(|cert| CmsCertSummary {
subject: cert::subject_dn(cert),
issuer: cert::issuer_dn(cert),
serial: cert::serial_decimal(cert),
not_before: cert::not_before_iso(cert),
not_after: cert::not_after_iso(cert),
})
.collect())
}
fn cert_info_of(cert: &Certificate) -> CertInfo {
let not_before = cert::not_before_iso(cert);
let not_after = cert::not_after_iso(cert);
warn_on_validity(not_before.as_deref(), not_after.as_deref());
CertInfo {
name: cert::common_name(cert),
email: cert::email(cert),
organization: cert::organization(cert),
dn: Some(cert::subject_dn(cert)),
not_before,
not_after,
}
}
pub fn discover_identity_from_server(
transport: &dyn SigningTransport,
username: &str,
password: &str,
timeout: Duration,
) -> Result<CertInfo> {
match transport.enum_certificates(username, password, timeout) {
Ok(certs) => {
if let Some(first) = certs.first() {
log::debug!("Identity discovered via enum-certificates");
return CertInfo::from_x509_der(first);
}
log::debug!("enum-certificates returned no certificates");
}
Err(e) => {
if matches!(e, RevenantError::Auth(_)) {
return Err(e);
}
log::debug!("enum-certificates unavailable, falling back to dummy-hash: {e}");
}
}
log::debug!("Discovering identity via dummy-hash signing");
let dummy_hash = [0u8; crate::constants::SHA1_DIGEST_SIZE];
let cms_der = transport.sign_hash(&dummy_hash, username, password, timeout)?;
CertInfo::from_cms(&cms_der)
}
fn warn_on_validity(not_before: Option<&str>, not_after: Option<&str>) {
if let Some(nb) = not_before {
if expiry::not_yet_valid(nb) == Some(true) {
log::warn!("Certificate is not yet valid (notBefore: {nb})");
return;
}
}
if let Some(na) = not_after {
if expiry::days_remaining(na).is_some_and(|days| days < 0) {
log::warn!("Certificate has expired (notAfter: {na})");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ROOT_DER: &[u8] = include_bytes!("testdata/root.der");
const CMS_LEAF_DIRECT: &[u8] = include_bytes!("testdata/cms_leaf_direct.der");
#[test]
fn from_x509_reads_identity() {
let info = CertInfo::from_x509_der(ROOT_DER).unwrap();
assert_eq!(info.name.as_deref(), Some("Test Root CA"));
assert_eq!(info.dn.as_deref(), Some("CN=Test Root CA"));
assert!(info.not_before.is_some());
assert!(info.not_after.is_some());
}
#[test]
fn from_cms_reads_signer() {
let info = CertInfo::from_cms(CMS_LEAF_DIRECT).unwrap();
assert_eq!(info.name.as_deref(), Some("Test Signer Direct"));
}
#[test]
fn from_cms_reports_the_signer_not_whoever_is_listed_first() {
const CMS_TRUSTED_FIRST: &[u8] =
include_bytes!("testdata/cms_trusted_cert_listed_first.der");
let info = CertInfo::from_cms(CMS_TRUSTED_FIRST).unwrap();
assert_eq!(info.name.as_deref(), Some("Untrusted Attacker Signer"));
}
#[test]
fn summarize_reads_subject_issuer_serial_validity() {
let summaries = summarize_cms_certificates(CMS_LEAF_DIRECT).unwrap();
let first = summaries.first().expect("at least one certificate");
assert!(first.subject.contains("Test Signer Direct"), "{first:?}");
assert!(first.issuer.contains("Test Root CA"), "{first:?}");
assert!(!first.serial.is_empty());
assert!(first.serial.bytes().all(|b| b.is_ascii_digit()));
assert!(first.not_before.is_some());
assert!(first.not_after.is_some());
}
#[test]
fn summarize_rejects_garbage() {
let err = summarize_cms_certificates(b"not a cms").unwrap_err();
assert!(matches!(err, RevenantError::Certificate(_)));
}
#[test]
fn extract_all_from_pdf_errors_without_signature() {
let err = CertInfo::all_from_pdf(b"%PDF-1.4\n%%EOF\n").unwrap_err();
assert!(matches!(err, RevenantError::Certificate(_)));
assert!(err.to_string().contains("No embedded signature"));
}
#[test]
fn from_x509_rejects_garbage() {
let err = CertInfo::from_x509_der(b"not a cert").unwrap_err();
assert!(matches!(err, RevenantError::Certificate(_)));
}
#[test]
fn from_cms_rejects_garbage() {
let err = CertInfo::from_cms(b"not a cms blob").unwrap_err();
assert!(matches!(err, RevenantError::Certificate(_)));
}
enum EnumBehavior {
Certs(Vec<Vec<u8>>),
AuthError,
ServerError,
}
struct MockTransport {
enum_behavior: EnumBehavior,
sign_hash_cms: Vec<u8>,
}
impl SigningTransport for MockTransport {
fn sign_data(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
unreachable!("discovery never calls sign_data")
}
fn sign_hash(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
Ok(self.sign_hash_cms.clone())
}
fn sign_pdf_detached(&self, _: &[u8], _: &str, _: &str, _: Duration) -> Result<Vec<u8>> {
unreachable!("discovery never calls sign_pdf_detached")
}
fn enum_certificates(&self, _: &str, _: &str, _: Duration) -> Result<Vec<Vec<u8>>> {
match &self.enum_behavior {
EnumBehavior::Certs(certs) => Ok(certs.clone()),
EnumBehavior::AuthError => Err(RevenantError::Auth("bad password".to_owned())),
EnumBehavior::ServerError => {
Err(RevenantError::Server("enum not supported".to_owned()))
}
}
}
}
fn dummy_timeout() -> Duration {
Duration::from_secs(5)
}
#[test]
fn discover_prefers_enum_certificates() {
let transport = MockTransport {
enum_behavior: EnumBehavior::Certs(vec![ROOT_DER.to_vec()]),
sign_hash_cms: Vec::new(), };
let info = discover_identity_from_server(&transport, "u", "p", dummy_timeout()).unwrap();
assert_eq!(info.name.as_deref(), Some("Test Root CA"));
}
#[test]
fn discover_falls_back_to_dummy_hash_when_enum_empty() {
let transport = MockTransport {
enum_behavior: EnumBehavior::Certs(Vec::new()),
sign_hash_cms: CMS_LEAF_DIRECT.to_vec(),
};
let info = discover_identity_from_server(&transport, "u", "p", dummy_timeout()).unwrap();
assert_eq!(info.name.as_deref(), Some("Test Signer Direct"));
}
#[test]
fn discover_falls_back_when_enum_errors_nonauth() {
let transport = MockTransport {
enum_behavior: EnumBehavior::ServerError,
sign_hash_cms: CMS_LEAF_DIRECT.to_vec(),
};
let info = discover_identity_from_server(&transport, "u", "p", dummy_timeout()).unwrap();
assert_eq!(info.name.as_deref(), Some("Test Signer Direct"));
}
#[test]
fn discover_propagates_auth_error_from_enum() {
let transport = MockTransport {
enum_behavior: EnumBehavior::AuthError,
sign_hash_cms: CMS_LEAF_DIRECT.to_vec(),
};
let err = discover_identity_from_server(&transport, "u", "p", dummy_timeout()).unwrap_err();
assert!(matches!(err, RevenantError::Auth(_)));
}
}