diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcb7010..e12f52c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version: - description: Release version to build/publish (for example 0.6.1) + description: Release version to build/publish (for example 0.6.2) required: true type: string publish_nuget: diff --git a/Cargo.lock b/Cargo.lock index dd39abd..39eac35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2010,7 +2010,7 @@ dependencies = [ [[package]] name = "psign" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "assert_cmd", @@ -2047,7 +2047,7 @@ dependencies = [ [[package]] name = "psign-authenticode-trust" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "authenticode", @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "psign-azure-kv-rest" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "base64", @@ -2087,7 +2087,7 @@ dependencies = [ [[package]] name = "psign-codesigning-rest" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "base64", @@ -2099,7 +2099,7 @@ dependencies = [ [[package]] name = "psign-digest-cli" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "assert_cmd", @@ -2125,7 +2125,7 @@ dependencies = [ [[package]] name = "psign-opc-sign" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "base64", @@ -2136,7 +2136,7 @@ dependencies = [ [[package]] name = "psign-portable-core" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "authenticode", @@ -2160,7 +2160,7 @@ dependencies = [ [[package]] name = "psign-portable-ffi" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "psign-portable-core", @@ -2170,7 +2170,7 @@ dependencies = [ [[package]] name = "psign-sip-digest" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "authenticode", diff --git a/Cargo.toml b/Cargo.toml index 0d264f8..aeb7e41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ repository = "https://github.com/Devolutions/psign" [package] name = "psign" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Rust port of the Windows SDK signtool.exe (Authenticode sign/verify/timestamp) with portable digest helpers." license.workspace = true diff --git a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 index 53778a3..0280a8d 100644 --- a/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 +++ b/PowerShell/Devolutions.Psign/Devolutions.Psign.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Devolutions.Psign.psm1' - ModuleVersion = '0.6.1' + ModuleVersion = '0.6.2' GUID = 'e6e50e4b-bf25-4ed6-a343-49f904e79f8f' Author = 'Devolutions' CompanyName = 'Devolutions' diff --git a/README.md b/README.md index 3f15dd5..aef1ee3 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ dotnet tool run psign-tool -- --help Create local dotnet tool packages from prebuilt release artifacts: ```powershell -pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.6.1 -ArtifactsRoot ./dist -OutputDir ./dist/nuget +pwsh ./nuget/pack-psign-dotnet-tool.ps1 -Version 0.6.2 -ArtifactsRoot ./dist -OutputDir ./dist/nuget ``` The package is built from native `psign-tool` artifacts for `win-x64`, `win-arm64`, `linux-x64`, `linux-arm64`, `osx-x64`, and `osx-arm64`, plus an `any` fallback package for unsupported runtimes. diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index 37023ee..d30ec95 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-authenticode-trust" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Portable Authenticode PKCS#7 trust verification (anchors, chain, EKU) using picky-rs" license.workspace = true diff --git a/crates/psign-authenticode-trust/src/rfc3161_extract.rs b/crates/psign-authenticode-trust/src/rfc3161_extract.rs index 76dbafe..bd8f7a1 100644 --- a/crates/psign-authenticode-trust/src/rfc3161_extract.rs +++ b/crates/psign-authenticode-trust/src/rfc3161_extract.rs @@ -14,7 +14,7 @@ use crate::policy::OnlineTrustOptions; use anyhow::{Result, anyhow}; use cms::content_info::ContentInfo; use cms::signed_data::{SignedData, SignerInfo}; -use der::asn1::{GeneralizedTime, ObjectIdentifier, OctetStringRef}; +use der::asn1::{ObjectIdentifier, OctetStringRef}; use der::{Decode, Encode, Header, Reader, SliceReader, Tag}; use picky::x509::certificate::Cert; use picky::x509::date::UtcDate; @@ -85,10 +85,11 @@ pub fn trusted_utc_date_from_authenticode_timestamp_token( return Ok(None); }; - let ts_sd: SignedData = token - .content - .decode_as() - .map_err(|e| anyhow!("timestamp token SignedData: {e}"))?; + let ts_sd = signed_data_from_content_info(&token).ok_or_else(|| { + anyhow!( + "timestamp token SignedData decode failed (after stripping unsupported cert choices)" + ) + })?; let parsed = psign_sip_digest::timestamp::parse_tst_info_der(&tstinfo_der) .ok_or_else(|| anyhow!("timestamp token TSTInfo parse failed"))?; let expected_imprint = @@ -154,10 +155,150 @@ pub fn trusted_utc_date_from_authenticode_timestamp_token( fn signed_data_from_pkcs7(pkcs7_der: &[u8]) -> Option { let mut r = SliceReader::new(pkcs7_der).ok()?; let ci = ContentInfo::decode(&mut r).ok()?; + signed_data_from_content_info(&ci) +} + +/// Decode CMS **`SignedData`**, rewriting the certificate bag when the `cms` crate cannot parse it. +/// +/// Microsoft RFC3161 tokens commonly embed attribute certificates as **`CertificateChoices`** +/// alternatives (`[1]` / `[2]`). The `cms` crate only models plain X.509 certificates and +/// `other [3]`, so a strict decode fails even though the X.509 TSA certs and TSTInfo are fine. +/// Portable timestamp trust only needs X.509 certificates, so unsupported choices (and optional +/// CRLs) are stripped before a second decode attempt. +fn signed_data_from_content_info(ci: &ContentInfo) -> Option { if ci.content_type != ID_SIGNED_DATA { return None; } - ci.content.decode_as::().ok() + if let Ok(sd) = ci.content.decode_as::() { + return Some(sd); + } + let sd_der = ci.content.to_der().ok()?; + let rewritten = rewrite_cms_signed_data_for_timestamp_decode(&sd_der)?; + SignedData::from_der(&rewritten).ok() +} + +fn rewrite_cms_signed_data_for_timestamp_decode(sd_tlv: &[u8]) -> Option> { + let mut r = SliceReader::new(sd_tlv).ok()?; + let hdr = Header::decode(&mut r).ok()?; + if hdr.tag != Tag::Sequence { + return None; + } + let body = r.read_slice(hdr.length).ok()?; + + let mut fields: Vec<&[u8]> = Vec::new(); + let mut pos = body; + while !pos.is_empty() { + let (tlv, rest) = split_der_tlv(pos)?; + fields.push(tlv); + pos = rest; + } + // version, digestAlgorithms, encapContentInfo, [certs], [crls], signerInfos + if fields.len() < 4 { + return None; + } + + let mut out: Vec = Vec::with_capacity(sd_tlv.len()); + let mut body_out = Vec::with_capacity(body.len()); + body_out.extend_from_slice(fields[0]); + body_out.extend_from_slice(fields[1]); + body_out.extend_from_slice(fields[2]); + + let mut idx = 3; + if idx < fields.len() && is_context_specific_constructed(fields[idx], 0) { + if let Some(filtered) = filter_a0_certificate_set_keep_x509_only(fields[idx]) + && !filtered.is_empty() + { + body_out.extend_from_slice(&filtered); + } + idx += 1; + } + // Drop CRLs — not required for MessageImprint / CMS sig / TSA chain checks. + if idx < fields.len() && is_context_specific_constructed(fields[idx], 1) { + idx += 1; + } + while idx < fields.len() { + body_out.extend_from_slice(fields[idx]); + idx += 1; + } + + encode_der_sequence(&body_out, &mut out); + Some(out) +} + +fn is_context_specific_constructed(tlv: &[u8], number: u8) -> bool { + let Some(&tag) = tlv.first() else { + return false; + }; + // Short-form context-specific constructed: 0xa0 | number (number < 31). + tag == (0xa0 | number) +} + +/// Keep only plain X.509 **`Certificate`** (SEQUENCE) entries inside IMPLICIT **`[0] CertificateSet`**. +/// Returns **`Some(empty)`** to drop the entire certificates field, or **`None`** on malformed input. +fn filter_a0_certificate_set_keep_x509_only(a0_tlv: &[u8]) -> Option> { + if !is_context_specific_constructed(a0_tlv, 0) { + return None; + } + let mut r = SliceReader::new(a0_tlv).ok()?; + let hdr = Header::decode(&mut r).ok()?; + let body = r.read_slice(hdr.length).ok()?; + let mut kept = Vec::new(); + let mut pos = body; + while !pos.is_empty() { + let (tlv, rest) = split_der_tlv(pos)?; + if tlv.first() == Some(&0x30) { + kept.extend_from_slice(tlv); + } + pos = rest; + } + if kept.is_empty() { + return Some(Vec::new()); + } + Some(encode_der_context_specific_constructed(0, &kept)) +} + +fn split_der_tlv(input: &[u8]) -> Option<(&[u8], &[u8])> { + let mut r = SliceReader::new(input).ok()?; + let start_remaining = usize::try_from(r.remaining_len()).ok()?; + let hdr = Header::decode(&mut r).ok()?; + let _body = r.read_slice(hdr.length).ok()?; + let end_remaining = usize::try_from(r.remaining_len()).ok()?; + let tlv_len = start_remaining.checked_sub(end_remaining)?; + let tlv = input.get(..tlv_len)?; + let rest = input.get(tlv_len..)?; + Some((tlv, rest)) +} + +fn encode_der_sequence(body: &[u8], out: &mut Vec) { + out.push(0x30); + encode_der_length(body.len(), out); + out.extend_from_slice(body); +} + +fn encode_der_context_specific_constructed(number: u8, body: &[u8]) -> Vec { + let mut out = Vec::with_capacity(2 + body.len()); + out.push(0xa0 | number); + encode_der_length(body.len(), &mut out); + out.extend_from_slice(body); + out +} + +fn encode_der_length(len: usize, out: &mut Vec) { + if len < 0x80 { + out.push(len as u8); + } else if len <= 0xff { + out.push(0x81); + out.push(len as u8); + } else if len <= 0xffff { + out.push(0x82); + out.push((len >> 8) as u8); + out.push((len & 0xff) as u8); + } else { + out.push(0x83); + out.push((len >> 16) as u8); + out.push(((len >> 8) & 0xff) as u8); + out.push((len & 0xff) as u8); + } } fn timestamp_token_from_signer_unsigned_attrs( @@ -185,17 +326,24 @@ fn timestamp_token_from_signer_unsigned_attrs( fn timestamp_token_from_attribute(attr: &Attribute) -> Option<(ContentInfo, Vec, UtcDate)> { for val in attr.values.iter() { - let token = timestamp_content_info_from_attribute_value(val)?; - if token.content_type != ID_SIGNED_DATA { + let Some(token) = timestamp_content_info_from_attribute_value(val) else { continue; - } - let sd: SignedData = token.content.decode_as().ok()?; + }; + let Some(sd) = signed_data_from_content_info(&token) else { + continue; + }; if sd.encap_content_info.econtent_type != ID_CT_TSTINFO { continue; } - let any = sd.encap_content_info.econtent.as_ref()?; - let tstinfo_der = tstinfo_bytes_from_encapsulated_econtent(any)?; - let gen_date = tstinfo_gen_time(tstinfo_der)?; + let Some(any) = sd.encap_content_info.econtent.as_ref() else { + continue; + }; + let Some(tstinfo_der) = tstinfo_bytes_from_encapsulated_econtent(any) else { + continue; + }; + let Some(gen_date) = tstinfo_gen_time(tstinfo_der) else { + continue; + }; return Some((token, tstinfo_der.to_vec(), gen_date)); } None @@ -288,6 +436,13 @@ fn timestamp_attr_priority(attr: &Attribute) -> Option { fn utc_date_from_timestamp_attribute(attr: &Attribute) -> Option { for val in attr.values.iter() { + // CMS AttributeValue is often a bare ContentInfo SEQUENCE. `Any::value()` is only the + // contents (no tag/length), so prefer the full DER TLV before falling back to raw value. + if let Ok(tlv) = val.to_der() + && let Some(d) = utc_date_from_unsigned_attr_payload(&tlv) + { + return Some(d); + } let payload = attribute_value_bytes(val); if let Some(d) = utc_date_from_unsigned_attr_payload(payload) { return Some(d); @@ -326,10 +481,7 @@ fn peel_octet_string_outer(bytes: &[u8]) -> Option<&[u8]> { } fn extract_gentime_from_timestamp_content_info(ci: &ContentInfo) -> Option { - if ci.content_type != ID_SIGNED_DATA { - return None; - } - let sd: SignedData = ci.content.decode_as().ok()?; + let sd = signed_data_from_content_info(ci)?; gentime_from_signed_data_timestamp(&sd) } @@ -377,18 +529,62 @@ fn tstinfo_gen_time(tstinfo_der: &[u8]) -> Option { } let inner = r.read_slice(hdr.length).ok()?; let mut sr = SliceReader::new(inner).ok()?; - der_skip_tlv(&mut sr)?; - der_skip_tlv(&mut sr)?; - der_skip_tlv(&mut sr)?; - der_skip_tlv(&mut sr)?; - let gt = GeneralizedTime::decode(&mut sr).ok()?; - utc_date_from_der_generalized_time(gt) + der_skip_tlv(&mut sr)?; // version + der_skip_tlv(&mut sr)?; // policy + der_skip_tlv(&mut sr)?; // messageImprint + der_skip_tlv(&mut sr)?; // serialNumber + let gt_hdr = Header::decode(&mut sr).ok()?; + if gt_hdr.tag != Tag::GeneralizedTime { + return None; + } + let gt_body = sr.read_slice(gt_hdr.length).ok()?; + utc_date_from_generalized_time_contents(gt_body) } -fn utc_date_from_der_generalized_time(gt: GeneralizedTime) -> Option { - let secs = i64::try_from(gt.to_unix_duration().as_secs()).ok()?; - let odt = time::OffsetDateTime::from_unix_timestamp(secs).ok()?; - Some(UtcDate::from(odt)) +/// Parse **`GeneralizedTime`** contents (no tag/length) into a picky **`UtcDate`**. +/// +/// Accepts the common RFC 5280 / CMS forms used by Authenticode TSAs, including Microsoft ACS +/// tokens that emit fractional seconds (`YYYYMMDDHHMMSS.fffZ`). Fractional seconds are truncated +/// toward zero; only **`Z`** / **`z`** (UTC) is accepted. The strict `der` crate decoder rejects +/// fractional seconds, so this path is intentionally more permissive for real-world tokens. +fn utc_date_from_generalized_time_contents(body: &[u8]) -> Option { + let s = std::str::from_utf8(body).ok()?.trim(); + let s = s.strip_suffix('Z').or_else(|| s.strip_suffix('z'))?; + let main = match s.split_once('.') { + Some((m, frac)) => { + if frac.is_empty() || !frac.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + m + } + None => s, + }; + if !main.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let (year, month, day, hour, minute, second) = match main.len() { + 14 => ( + main[0..4].parse::().ok()?, + main[4..6].parse::().ok()?, + main[6..8].parse::().ok()?, + main[8..10].parse::().ok()?, + main[10..12].parse::().ok()?, + main[12..14].parse::().ok()?, + ), + 12 => ( + main[0..4].parse::().ok()?, + main[4..6].parse::().ok()?, + main[6..8].parse::().ok()?, + main[8..10].parse::().ok()?, + main[10..12].parse::().ok()?, + 0u8, + ), + _ => return None, + }; + let date = + time::Date::from_calendar_date(year, time::Month::try_from(month).ok()?, day).ok()?; + let clock = time::Time::from_hms(hour, minute, second).ok()?; + Some(UtcDate::from(time::OffsetDateTime::new_utc(date, clock))) } fn der_skip_tlv<'a, R: Reader<'a>>(reader: &mut R) -> Option<()> { @@ -433,7 +629,7 @@ mod tests { use super::*; use der::DateTime; use der::Encode; - use der::asn1::UtcTime; + use der::asn1::{GeneralizedTime, UtcTime}; use psign_sip_digest::verify_pe::pe_first_pkcs7_signed_data_der; #[test] @@ -475,6 +671,139 @@ mod tests { assert!(super::tstinfo_gen_time(&[0x30, 0x00]).is_none()); } + /// Microsoft ACS / Azure Trusted Signing TSAs emit fractional-second GeneralizedTime + /// (`YYYYMMDDHHMMSS.fffZ`). The strict `der` decoder rejects that form; portable trust must not. + #[test] + fn utc_date_from_generalized_time_contents_accepts_fractional_seconds() { + let d = super::utc_date_from_generalized_time_contents(b"20260810234021.194Z") + .expect("fractional GeneralizedTime"); + assert_eq!((d.year(), d.month(), d.day()), (2026, 8, 10)); + } + + #[test] + fn utc_date_from_generalized_time_contents_accepts_whole_seconds() { + let d = super::utc_date_from_generalized_time_contents(b"20230701120000Z") + .expect("whole-second GeneralizedTime"); + assert_eq!((d.year(), d.month(), d.day()), (2023, 7, 1)); + } + + #[test] + fn utc_date_from_generalized_time_contents_rejects_non_utc() { + assert!(super::utc_date_from_generalized_time_contents(b"20230701120000+0000").is_none()); + assert!(super::utc_date_from_generalized_time_contents(b"not-a-time").is_none()); + assert!(super::utc_date_from_generalized_time_contents(b"20230701120000.Z").is_none()); + } + + /// Microsoft timestamp tokens embed attribute certificates (`CertificateChoices` `[1]`) that the + /// `cms` crate cannot decode. Stripping them must preserve plain X.509 entries. + #[test] + fn filter_a0_certificate_set_keeps_only_x509_sequences() { + // Two X.509 SEQUENCE placeholders + one context-specific [1] attr-cert placeholder. + let mut body = Vec::new(); + body.extend_from_slice(&[0x30, 0x03, 0x02, 0x01, 0x01]); // fake cert A + body.extend_from_slice(&[0xa1, 0x03, 0x02, 0x01, 0x02]); // attr cert choice + body.extend_from_slice(&[0x30, 0x03, 0x02, 0x01, 0x03]); // fake cert B + let a0 = super::encode_der_context_specific_constructed(0, &body); + let filtered = super::filter_a0_certificate_set_keep_x509_only(&a0).expect("filter"); + assert_eq!(filtered[0], 0xa0); + // Body should be cert A + cert B only (10 bytes). + let mut r = SliceReader::new(&filtered).unwrap(); + let hdr = Header::decode(&mut r).unwrap(); + let kept = r.read_slice(hdr.length).unwrap(); + assert_eq!( + kept, + &[0x30, 0x03, 0x02, 0x01, 0x01, 0x30, 0x03, 0x02, 0x01, 0x03] + ); + } + + #[test] + fn rewrite_signed_data_strips_attr_cert_and_crls() { + // Minimal SignedData-shaped SEQUENCE: + // version, digestAlgs SET, encapContentInfo SEQUENCE, certs[0], crls[1], signerInfos SET + let version = [0x02, 0x01, 0x03]; + let digest_algs = [0x31, 0x00]; + let encap = [0x30, 0x00]; + let mut certs_body = Vec::new(); + certs_body.extend_from_slice(&[0x30, 0x03, 0x02, 0x01, 0x0a]); + certs_body.extend_from_slice(&[0xa1, 0x03, 0x02, 0x01, 0x0b]); + let certs = super::encode_der_context_specific_constructed(0, &certs_body); + let crls = super::encode_der_context_specific_constructed(1, &[0x30, 0x00]); + let signer_infos = [0x31, 0x00]; + let mut body = Vec::new(); + body.extend_from_slice(&version); + body.extend_from_slice(&digest_algs); + body.extend_from_slice(&encap); + body.extend_from_slice(&certs); + body.extend_from_slice(&crls); + body.extend_from_slice(&signer_infos); + let mut sd = Vec::new(); + super::encode_der_sequence(&body, &mut sd); + let out = super::rewrite_cms_signed_data_for_timestamp_decode(&sd).expect("rewrite"); + // Parse top-level fields of rewritten SEQUENCE and ensure no [1] CRL and no attr-cert. + let mut r = SliceReader::new(&out).unwrap(); + let hdr = Header::decode(&mut r).unwrap(); + assert_eq!(hdr.tag, Tag::Sequence); + let inner = r.read_slice(hdr.length).unwrap(); + let mut fields = Vec::new(); + let mut pos = inner; + while !pos.is_empty() { + let (tlv, rest) = super::split_der_tlv(pos).unwrap(); + fields.push(tlv.to_vec()); + pos = rest; + } + assert_eq!(fields.len(), 5, "version,digests,encap,certs,signerInfos"); + assert!(super::is_context_specific_constructed(&fields[3], 0)); + assert!( + !fields + .iter() + .any(|f| super::is_context_specific_constructed(f, 1)) + ); + let cert_field = &fields[3]; + let mut cr = SliceReader::new(cert_field).unwrap(); + let ch = Header::decode(&mut cr).unwrap(); + let cb = cr.read_slice(ch.length).unwrap(); + assert_eq!(cb, &[0x30, 0x03, 0x02, 0x01, 0x0a]); + } + + /// Build a minimal **`TSTInfo` SEQUENCE** whose **`genTime`** uses ACS-style fractional seconds. + #[test] + fn tstinfo_gen_time_accepts_fractional_generalized_time() { + // version INTEGER 1 + // policy OID 1.2.3 + // messageImprint SEQUENCE { AlgorithmIdentifier sha256, OCTET STRING 32 zero } + // serial INTEGER 1 + // genTime 20260810234021.194Z + let mut body = Vec::new(); + body.extend_from_slice(&[0x02, 0x01, 0x01]); // version + body.extend_from_slice(&[0x06, 0x02, 0x2a, 0x03]); // OID 1.2.3 + // messageImprint + let mi_inner = { + let mut v = Vec::new(); + // AlgorithmIdentifier SEQUENCE { OID 2.16.840.1.101.3.4.2.1, NULL } + v.extend_from_slice(&[ + 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, + 0x00, + ]); + v.extend_from_slice(&[0x04, 0x20]); + v.extend_from_slice(&[0u8; 32]); + v + }; + body.push(0x30); + body.push(mi_inner.len() as u8); + body.extend_from_slice(&mi_inner); + body.extend_from_slice(&[0x02, 0x01, 0x01]); // serial + let gt = b"20260810234021.194Z"; + body.push(0x18); + body.push(gt.len() as u8); + body.extend_from_slice(gt); + let mut tlv = Vec::new(); + tlv.push(0x30); + tlv.push(body.len() as u8); + tlv.extend_from_slice(&body); + let d = super::tstinfo_gen_time(&tlv).expect("fractional genTime TSTInfo"); + assert_eq!((d.year(), d.month(), d.day()), (2026, 8, 10)); + } + /// **`tiny32.signed.efi`** carries PKCS#9 **`signing-time`** (no nested RFC3161 **`TSTInfo`** in unsigned attrs). /// Portable extraction uses that for verification-instant selection when **`prefer_timestamp_signing_time`** is on. #[test] diff --git a/crates/psign-azure-kv-rest/Cargo.toml b/crates/psign-azure-kv-rest/Cargo.toml index a31312c..fe8ff6b 100644 --- a/crates/psign-azure-kv-rest/Cargo.toml +++ b/crates/psign-azure-kv-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-azure-kv-rest" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Azure Key Vault certificate metadata + keys/sign REST (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-codesigning-rest/Cargo.toml b/crates/psign-codesigning-rest/Cargo.toml index 076c047..90e0f61 100644 --- a/crates/psign-codesigning-rest/Cargo.toml +++ b/crates/psign-codesigning-rest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-codesigning-rest" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Azure Code Signing data-plane CertificateProfileOperations Sign LRO (portable, blocking HTTP)" license.workspace = true diff --git a/crates/psign-digest-cli/Cargo.toml b/crates/psign-digest-cli/Cargo.toml index 0e6f0e0..bc45784 100644 --- a/crates/psign-digest-cli/Cargo.toml +++ b/crates/psign-digest-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-digest-cli" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Linux/macOS-friendly CLI over portable Authenticode SIP digests (psign-sip-digest)" license.workspace = true diff --git a/crates/psign-opc-sign/Cargo.toml b/crates/psign-opc-sign/Cargo.toml index 039d2ea..b61f276 100644 --- a/crates/psign-opc-sign/Cargo.toml +++ b/crates/psign-opc-sign/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-opc-sign" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Portable OPC, VSIX, and NuGet package signing primitives" license.workspace = true diff --git a/crates/psign-portable-core/Cargo.toml b/crates/psign-portable-core/Cargo.toml index 390d6fa..c29a5ab 100644 --- a/crates/psign-portable-core/Cargo.toml +++ b/crates/psign-portable-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-core" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Reusable portable Authenticode signing and inspection APIs for psign" license.workspace = true diff --git a/crates/psign-portable-ffi/Cargo.toml b/crates/psign-portable-ffi/Cargo.toml index 3e8ccb3..2609c6e 100644 --- a/crates/psign-portable-ffi/Cargo.toml +++ b/crates/psign-portable-ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-portable-ffi" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "C ABI shared library for psign portable Authenticode operations" license.workspace = true diff --git a/crates/psign-sip-digest/Cargo.toml b/crates/psign-sip-digest/Cargo.toml index a5c55b8..d48e3dd 100644 --- a/crates/psign-sip-digest/Cargo.toml +++ b/crates/psign-sip-digest/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psign-sip-digest" -version = "0.6.1" +version = "0.6.2" edition = "2024" description = "Portable Authenticode SIP digest recomputation (PE, CAB, MSI, MSIX, scripts, …) without Win32" license.workspace = true diff --git a/docs/authenticode-trust-stack.md b/docs/authenticode-trust-stack.md index 9f7b928..678fedb 100644 --- a/docs/authenticode-trust-stack.md +++ b/docs/authenticode-trust-stack.md @@ -45,3 +45,4 @@ PKCS#9 **`signing-time`** remains a convenience for non-required timestamp insta - OS **AuthRoot** / **Intermediate** stores, **PinRules**, enterprise **TrustedPublisher**, or public-store policy. AIA and CRL retrieval are explicit and in-memory only. - Indirect CRLs, delta CRLs, OCSP nonce policy, delegated OCSP responder authorization, and richer OCSP response variants are still future work; the implemented revocation path handles issuing-CA-signed OCSP and CRL responses over HTTP for `psign-server` tests and rejects stale `nextUpdate` windows. - RFC3161 support is intentionally narrow: RSA/SHA-256 timestamp CMS signatures, primary-signature **`MessageImprint`**, explicit-anchor TSA chains, and `timeStamping` EKU are covered; delegated responders, non-RSA/non-SHA-256 TSA signatures, richer timestamp policies, and timestamp token embedding during portable signing remain future work. +- Real Microsoft / ACS nested tokens are accepted when they use fractional-second **`TSTInfo.genTime`** (truncated to whole seconds for **`UtcDate`**) and when the timestamp CMS certificate bag contains attribute-certificate **`CertificateChoices`** entries the `cms` crate cannot decode (those choices are stripped; only X.509 certificates are kept for TSA chain building). diff --git a/docs/rust-sip-gaps.md b/docs/rust-sip-gaps.md index fe68dd0..eb0c9b0 100644 --- a/docs/rust-sip-gaps.md +++ b/docs/rust-sip-gaps.md @@ -42,7 +42,7 @@ VSIX and NuGet package signatures should not be modeled as Rust SIP gaps. VSIX u |------|--------| | PE/CAB/MSI **PKCS#7 encode** + format embed entirely in Rust | **Implemented for PE RSA/SHA-2:** **`psign-tool portable sign-pe`** computes the PE Authenticode digest, creates Authenticode **`SignedData`** from scratch, wraps it in a **`WIN_CERTIFICATE`**, replaces existing PE signatures by default, supports explicit append mode with **`--append-signature`**, and recomputes **`CheckSum`**. **Implemented for unsigned single-volume CAB RSA/SHA-2:** **`psign-tool portable sign-cab`** inserts the CAB Authenticode reserve header, creates CAB **`SpcIndirectDataContent`**, appends tail PKCS#7, and verifies through **`verify-cab`**. **Implemented for MSI/MSP RSA/SHA-2:** **`psign-tool portable sign-msi`** creates MSI **`SpcSigInfo`** indirect data and writes the root **`\u{5}DigitalSignature`** stream. **`pe_embed`** still exposes **`wrap_pkcs7_der_authenticode_win_certificate`** + **`pe_append_authenticode_pkcs7_certificate`** for lower-level PE flows. **`pkcs7.rs`** now includes PE, CAB, and MSI **`SpcIndirectDataContent`** construction, local RSA/SHA-256/384/512 CMS signing, and remote RSA signature injection helpers in addition to PKCS#9 **`messageDigest`** extract/replace, authenticated-attribute **`SET OF Attribute`** DER, **`signer_info_sha256_digest_over_signed_attrs`**, **`signer_info_clone_with_signed_attrs`** / **`signer_info_clone_with_signature_octets`**, **`signed_data_replace_signer_info_at`**, and **`signed_data_replace_first_signer_info`**. Remaining gaps: ECDSA attribute-sign rules, optional attr tweaks beyond PKCS#9 **`messageDigest`**, broad top-level `sign` routing, CAB replacement/multivolume cases, `MsiDigitalSignatureEx` authoring, MSIX production embedding, and non-PE timestamp mutation. | | **MSIX/Appx `CryptSIPDllCreateIndirectData`** | **`AppxSipCreateIndirectData`** / **`AppxBundleSipCreateIndirectData`** build the **APPX `SpcIndirectData`** blob at sign time; **`msix_digest`** only **verifies** recomputed AX\* vs PKCS#7 — see [`windows-signing-components.md`](windows-signing-components.md) (**AppxSip.dll**) and [`rust-sip-spec-refs.md`](rust-sip-spec-refs.md). | -| **RFC3161** timestamp construction in Rust | **Partial:** `crates/psign-sip-digest/src/timestamp.rs` — **`build_timestamp_request_bytes`** encodes **DER** **`TimeStampReq`** (version 1 + **`MessageImprint`** + optional **`nonce`** / **`certReq`**) for SHA-1 / SHA-256 / SHA-384 / SHA-512; **`parse_time_stamp_resp_der`** reads **`PKIStatusInfo.status`**, optional **`statusString`**, optional **`failInfo`**, plus optional raw **`timeStampToken`**; **`parse_time_stamp_token_tst_info`** structurally extracts CMS **`id-ct-TSTInfo`** policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. **`psign-tool portable`** exposes **`rfc3161-timestamp-req`**, **`rfc3161-timestamp-resp-inspect`**, optional **`rfc3161-timestamp-http-post`** with **`--features timestamp-http`**, **`sign-pe --timestamp-url --timestamp-digest`** for PE sign-time timestamping, and **`timestamp-pe-rfc3161`** to attach a raw token or granted **`TimeStampResp`** token to a PE `SignerInfo` unsigned attribute. Portable trust uses cryptographic RFC3161 validation when both **`--prefer-timestamp-signing-time`** and **`--require-valid-timestamp`** are set: nested token **`MessageImprint`** over primary **`SignerInfo.signature`**, timestamp CMS **`messageDigest`**, RSA/SHA-256 timestamp signature, TSA `timeStamping` EKU, and explicit-anchor TSA chain. PKCS#9 **`signing-time`** still works only for non-required instant selection. Remaining gaps: delegated/non-RSA TSA support, non-PE timestamp mutation, and full Windows **`CryptVerifyTimeStampSignature`** parity. | +| **RFC3161** timestamp construction in Rust | **Partial:** `crates/psign-sip-digest/src/timestamp.rs` — **`build_timestamp_request_bytes`** encodes **DER** **`TimeStampReq`** (version 1 + **`MessageImprint`** + optional **`nonce`** / **`certReq`**) for SHA-1 / SHA-256 / SHA-384 / SHA-512; **`parse_time_stamp_resp_der`** reads **`PKIStatusInfo.status`**, optional **`statusString`**, optional **`failInfo`**, plus optional raw **`timeStampToken`**; **`parse_time_stamp_token_tst_info`** structurally extracts CMS **`id-ct-TSTInfo`** policy OID, message-imprint digest OID/hash, serial, **`genTime`**, and nonce. **`psign-tool portable`** exposes **`rfc3161-timestamp-req`**, **`rfc3161-timestamp-resp-inspect`**, optional **`rfc3161-timestamp-http-post`** with **`--features timestamp-http`**, **`sign-pe --timestamp-url --timestamp-digest`** for PE sign-time timestamping, and **`timestamp-pe-rfc3161`** to attach a raw token or granted **`TimeStampResp`** token to a PE `SignerInfo` unsigned attribute. Portable trust uses cryptographic RFC3161 validation when both **`--prefer-timestamp-signing-time`** and **`--require-valid-timestamp`** are set: nested token **`MessageImprint`** over primary **`SignerInfo.signature`**, timestamp CMS **`messageDigest`**, RSA/SHA-256 timestamp signature, TSA `timeStamping` EKU, and explicit-anchor TSA chain. Microsoft/ACS tokens with fractional-second **`genTime`** and attribute certificates in the timestamp bag are handled (fractional seconds truncated; non-X.509 **`CertificateChoices`** stripped). PKCS#9 **`signing-time`** still works only for non-required instant selection. Remaining gaps: delegated/non-RSA TSA support, non-PE timestamp mutation, and full Windows **`CryptVerifyTimeStampSignature`** parity. | | **`/ph`** **page hashes** (`SPC_PE_IMAGE_PAGE_HASHES`) | Portable **CMS extract** + **payload peel** + **flat `(offset,digest)*` parse** + **experimental contiguous file-offset verify** (`page_hashes`, CLI **`pe-has-page-hashes`** / **`pe-page-hash-info`** / **`verify-pe-page-hashes`**). Differs from **`WinVerifyTrust`** where checksum / security-directory handling diverges — native **`verify --verify-page-hashes`** remains the strict `/ph` reference. | | **MSIX/Appx `SignerSignEx3` signing** (`psign sign` on `.msix`) | **`APPX_SIP_CLIENT_DATA`** + **`SIGNER_SIGN_EX2_PARAMS`** as **`pSipData`** for all cleartext **`MsixFamily`** paths (embedded and **`/dlib`** decoupled) so **`AppxSip.dll`** receives **`SIP_SUBJECTINFO.pClientData`**. CI may still record **`documented_rust_msix_sign_ex3_gap`** when native succeeds but Rust fails (**`CRYPT_E_NO_PROVIDER`** `0x80092006`, publisher / manifest mismatches, etc.). **`CreateFileW`** subject handle + **`--debug`** diagnostics remain; **`pCryptoPolicy`** is still **`NULL`** — see [**SignerSignEx3 / SIP glue**](rust-sip-spec-refs.md#signersignex3-and-sip-glue). **Publisher-vs-signer binding** is enforced in **`AppxSip.dll`** (manifest vs PKCS#7 signer), not in **`msix_digest`** — see [`windows-signing-components.md`](windows-signing-components.md). | | **PowerShell-class script SIP line discipline** | **`pwrshsip.dll`** decompilation shows extension dispatch for `.ps1`, `.ps1xml`, `.psc1`, `.psd1`, `.psm1`, `.cdxml`, and `.mof`; BOM / `RtlIsTextUnicode` format probing; UTF-16 line-oriented hashing; marker families `# SIG #`, XML comments, and `/* SIG # */`; base64 extraction through `CryptStringToBinaryW`. The Rust `ps_script` module mirrors markers and BOM-aware UTF-16 stripping, but remains a heuristic consistency checker rather than a byte-for-byte clone of every line-reader and malformed-block error. | diff --git a/nuget/tool/Devolutions.Psign.Tool.csproj b/nuget/tool/Devolutions.Psign.Tool.csproj index baf3ce3..b4c9dc5 100644 --- a/nuget/tool/Devolutions.Psign.Tool.csproj +++ b/nuget/tool/Devolutions.Psign.Tool.csproj @@ -8,7 +8,7 @@ psign-tool Devolutions.Psign.Tool - 0.6.1 + 0.6.2 Devolutions RID-specific dotnet tool wrapper around prebuilt psign-tool native executables. README.md