signalscreen-checker 0.3.0

Windows code-signing hygiene checker. Reads the Authenticode signature in a PE file and grades it A-F. Pure Rust, no Windows dependency.
Documentation
//! When a signature was countersigned, and by whom.
//!
//! Two dialects are in the wild. The modern one is an RFC 3161
//! token in a Microsoft unsigned attribute. The older one is a
//! PKCS#9 countersignature carrying `signingTime`.
//!
//! Signing tools pick one and never say which. Handle only the
//! modern form and you report "no timestamp" on a correctly
//! timestamped binary. A false accusation is worse than silence.
use anyhow::{anyhow, Result};
use cms::content_info::ContentInfo;
use cms::signed_data::{SignedData, SignerIdentifier, SignerInfo, SignerInfos};
use der::{Decode, Encode, Reader, SliceReader, Tag};
use crate::cert::extract_rdn;
use x509_cert::attr::Attributes;

/// Microsoft RFC 3161 token, an unsigned attribute.
pub const OID_MS_TIMESTAMP: &str = "1.3.6.1.4.1.311.3.3.1";
/// PKCS#9 countersignature, the older dialect.
pub const OID_COUNTERSIGNATURE: &str = "1.2.840.113549.1.9.6";
/// PKCS#9 signingTime, inside the countersignature's signed attributes.
const OID_SIGNING_TIME: &str = "1.2.840.113549.1.9.5";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimestampKind {
    /// Carries its own signed time and its own certificate.
    Rfc3161,
    /// Older. The time is an attribute, not a signed statement
    /// about a hash.
    Countersignature,
}

#[derive(Debug, Clone, PartialEq)]
pub struct TimestampInfo {
    pub signed_at_unix: i64,
    /// The authority, as its organisation name where it has one.
    /// `None` when the structure carries no usable identity,
    /// which happens and is not an error.
    pub authority: Option<String>,
    pub kind: TimestampKind,
}

/// Read whichever dialect is present.
///
/// A malformed token reads as absent rather than failing the
/// whole analysis. One bad countersignature should not cost a
/// user their report.
pub fn from_unsigned_attrs(attrs: Option<&Attributes>) -> Option<TimestampInfo> {
    let attrs = attrs?;
    for a in attrs.iter() {
        let oid = a.oid.to_string();
        let value = a.values.get(0)?.to_der().ok()?;
        let parsed = if oid == OID_MS_TIMESTAMP {
            from_rfc3161_token(&value)
        } else if oid == OID_COUNTERSIGNATURE {
            from_countersignature(&value)
        } else {
            continue;
        };
        if let Ok(info) = parsed {
            return Some(info);
        }
    }
    None
}

/// Parse the Microsoft attribute: a ContentInfo wrapping
/// SignedData whose encapsulated content is a TSTInfo.
pub fn from_rfc3161_token(der: &[u8]) -> Result<TimestampInfo> {
    let ci = ContentInfo::from_der(der)?;
    let sd = SignedData::from_der(&ci.content.to_der()?)?;
    let econtent = sd
        .encap_content_info
        .econtent
        .as_ref()
        .ok_or_else(|| anyhow!("timestamp token has no encapsulated content"))?;
    // `econtent.value()` already strips the OCTET STRING, so
    // these bytes are the TSTInfo SEQUENCE itself.
    let signed_at_unix = gen_time_from_tstinfo(econtent.value())?;
    Ok(TimestampInfo {
        signed_at_unix,
        authority: tsa_from_token(&sd),
        kind: TimestampKind::Rfc3161,
    })
}

/// Parse the PKCS#9 countersignature: a SignerInfo whose signed
/// attributes carry `signingTime`.
pub fn from_countersignature(der: &[u8]) -> Result<TimestampInfo> {
    let cs = SignerInfo::from_der(der)?;
    let attrs = cs
        .signed_attrs
        .as_ref()
        .ok_or_else(|| anyhow!("countersignature has no signed attributes"))?;
    let mut signed_at_unix = None;
    for a in attrs.iter() {
        if a.oid.to_string() == OID_SIGNING_TIME {
            let v = a
                .values
                .get(0)
                .ok_or_else(|| anyhow!("signingTime attribute is empty"))?
                .to_der()?;
            signed_at_unix = Some(parse_asn1_time(&v)?);
        }
    }
    let signed_at_unix =
        signed_at_unix.ok_or_else(|| anyhow!("countersignature carries no signingTime"))?;
    Ok(TimestampInfo {
        signed_at_unix,
        authority: org_from_sid(&cs.sid),
        kind: TimestampKind::Countersignature,
    })
}

/// TSTInfo ::= SEQUENCE { version, policy, messageImprint,
/// serialNumber, genTime, ... }.
///
/// Walks to the first GeneralizedTime. The fields before it are
/// fixed and the five after are optional, so decoding the whole
/// structure buys nothing.
fn gen_time_from_tstinfo(der: &[u8]) -> Result<i64> {
    let mut outer = SliceReader::new(der)?;
    let seq: der::asn1::AnyRef = der::asn1::AnyRef::decode(&mut outer)?;
    let mut r = SliceReader::new(seq.value())?;
    while !r.is_finished() {
        let header = r.peek_header()?;
        let field: der::asn1::AnyRef = der::asn1::AnyRef::decode(&mut r)?;
        if header.tag == Tag::GeneralizedTime {
            return parse_generalized(field.value());
        }
    }
    Err(anyhow!("TSTInfo has no genTime"))
}

/// The token is signed by the authority, so its own certificate
/// names it. Selected by SignerInfo.sid, for the same reason the
/// main signature is: the bag holds a chain and the first entry
/// is usually a CA.
fn tsa_from_token(sd: &SignedData) -> Option<String> {
    let sid = &first_signer(&sd.signer_infos)?.sid;
    let certs = sd.certificates.as_ref()?;
    for choice in certs.0.iter() {
        if let cms::cert::CertificateChoices::Certificate(c) = choice {
            if let SignerIdentifier::IssuerAndSerialNumber(ias) = sid {
                if c.tbs_certificate.issuer == ias.issuer
                    && c.tbs_certificate.serial_number == ias.serial_number
                {
                    return extract_rdn(&c.tbs_certificate.subject.to_string(), "O")
                        .or_else(|| extract_rdn(&c.tbs_certificate.subject.to_string(), "CN"));
                }
            }
        }
    }
    // A SubjectKeyIdentifier sid, or a token shipping no
    // certificates. Both legal. The time is still good, so
    // report it without a name.
    None
}

fn first_signer(infos: &SignerInfos) -> Option<&SignerInfo> {
    infos.0.iter().next()
}

/// The countersignature carries no certificate, only a reference
/// to one. The issuer DN is the closest usable identity.
fn org_from_sid(sid: &SignerIdentifier) -> Option<String> {
    match sid {
        SignerIdentifier::IssuerAndSerialNumber(ias) => {
            let issuer = ias.issuer.to_string();
            extract_rdn(&issuer, "O").or_else(|| extract_rdn(&issuer, "CN"))
        }
        _ => None,
    }
}

/// `signingTime` is UTCTime on older signatures and
/// GeneralizedTime on newer ones. Both appear in the corpus.
fn parse_asn1_time(der: &[u8]) -> Result<i64> {
    let mut r = SliceReader::new(der)?;
    let header = r.peek_header()?;
    let any: der::asn1::AnyRef = der::asn1::AnyRef::decode(&mut r)?;
    match header.tag {
        Tag::UtcTime => parse_utc(any.value()),
        Tag::GeneralizedTime => parse_generalized(any.value()),
        other => Err(anyhow!("unexpected time tag {other:?}")),
    }
}

/// `YYMMDDHHMMSSZ`. Two-digit year per RFC 5280: 00-49 is 20xx,
/// 50-99 is 19xx. Get it wrong and a 2024 signature lands in
/// 1924, which reads as an expired certificate, not as a bug.
fn parse_utc(bytes: &[u8]) -> Result<i64> {
    let s = std::str::from_utf8(bytes)?;
    if s.len() < 13 {
        return Err(anyhow!("UTCTime too short: {s}"));
    }
    let yy: i64 = s[0..2].parse()?;
    let year = if yy <= 49 { 2000 + yy } else { 1900 + yy };
    to_unix(year, &s[2..])
}

/// `YYYYMMDDHHMMSSZ`.
fn parse_generalized(bytes: &[u8]) -> Result<i64> {
    let s = std::str::from_utf8(bytes)?;
    if s.len() < 15 {
        return Err(anyhow!("GeneralizedTime too short: {s}"));
    }
    let year: i64 = s[0..4].parse()?;
    to_unix(year, &s[4..])
}

/// `rest` is `MMDDHHMMSS` with an optional `Z`. Fractional
/// seconds and numeric offsets are legal ASN.1 and appear in no
/// timestamp token seen, so they are rejected, not guessed at.
fn to_unix(year: i64, rest: &str) -> Result<i64> {
    if rest.len() < 10 {
        return Err(anyhow!("truncated time: {rest}"));
    }
    let month: i64 = rest[0..2].parse()?;
    let day: i64 = rest[2..4].parse()?;
    let hour: i64 = rest[4..6].parse()?;
    let minute: i64 = rest[6..8].parse()?;
    let second: i64 = rest[8..10].parse()?;
    if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
        return Err(anyhow!("implausible date {year}-{month}-{day}"));
    }
    Ok(days_from_civil(year, month, day) * 86_400 + hour * 3600 + minute * 60 + second)
}

/// Days since 1970-01-01. Howard Hinnant's civil-date algorithm.
/// Exact over the proleptic Gregorian range, and no dependency.
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
    let y = if m <= 2 { y - 1 } else { y };
    let era = if y >= 0 { y } else { y - 399 } / 400;
    let yoe = y - era * 400;
    let mp = (m + 9) % 12;
    let doy = (153 * mp + 2) / 5 + d - 1;
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
    era * 146_097 + doe - 719_468
}

#[cfg(test)]
mod tests {
    use super::*;

    // Both fixtures are the timestamp attribute lifted out of a
    // real signed binary. The parser is pinned against what
    // tools emit, not against something built to parse.
    const RFC3161: &[u8] = include_bytes!("../fixtures/ts-rfc3161.der");
    const COUNTERSIG: &[u8] = include_bytes!("../fixtures/ts-countersig.der");

    #[test]
    fn rfc3161_token_yields_time_and_authority() {
        let info = from_rfc3161_token(RFC3161).expect("parse token");
        // genTime 20260218003552Z
        assert_eq!(info.signed_at_unix, 1_771_374_952);
        assert_eq!(info.kind, TimestampKind::Rfc3161);
        assert_eq!(info.authority.as_deref(), Some("DigiCert, Inc."));
    }

    #[test]
    fn countersignature_yields_time_and_authority() {
        let info = from_countersignature(COUNTERSIG).expect("parse countersignature");
        // signingTime 240326121312Z, UTCTime with a two-digit year
        assert_eq!(info.signed_at_unix, 1_711_455_192);
        assert_eq!(info.kind, TimestampKind::Countersignature);
        assert_eq!(info.authority.as_deref(), Some("DigiCert, Inc."));
    }

    #[test]
    fn utctime_pivots_at_fifty() {
        // The RFC 5280 rule. 49 must not become 1949.
        assert_eq!(parse_utc(b"490101000000Z").unwrap(), to_unix(2049, "0101000000").unwrap());
        assert_eq!(parse_utc(b"500101000000Z").unwrap(), to_unix(1950, "0101000000").unwrap());
    }

    #[test]
    fn epoch_is_zero_and_a_known_date_is_right() {
        assert_eq!(days_from_civil(1970, 1, 1), 0);
        assert_eq!(parse_generalized(b"19700101000000Z").unwrap(), 0);
        // The day after the leap day the century rule nearly eats.
        assert_eq!(parse_generalized(b"20000301000000Z").unwrap(), 951_868_800);
    }

    #[test]
    fn a_malformed_token_is_absent_rather_than_fatal() {
        assert!(from_rfc3161_token(b"not der at all").is_err());
        assert!(from_countersignature(&[0x30, 0x00]).is_err());
    }
}