diff --git a/.github/workflows/dotnet-tool.yml b/.github/workflows/dotnet-tool.yml index 25e91e5..50907d7 100644 --- a/.github/workflows/dotnet-tool.yml +++ b/.github/workflows/dotnet-tool.yml @@ -40,13 +40,13 @@ jobs: binary: psign-tool.exe - name: macos-x64 - os: macos-14 + os: macos-15 target: x86_64-apple-darwin archive: psign-tool-macos-x64.zip binary: psign-tool - name: macos-arm64 - os: macos-14 + os: macos-15 target: aarch64-apple-darwin archive: psign-tool-macos-arm64.zip binary: psign-tool diff --git a/.github/workflows/powershell-module.yml b/.github/workflows/powershell-module.yml index aab6104..c7084fb 100644 --- a/.github/workflows/powershell-module.yml +++ b/.github/workflows/powershell-module.yml @@ -20,7 +20,7 @@ jobs: - name: windows-x64 os: windows-2022 - name: macos-arm64 - os: macos-14 + os: macos-15 steps: - name: Checkout diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dea6b3b..770a19e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -205,14 +205,14 @@ jobs: - name: macos-x64 rid: osx-x64 - os: macos-14 + os: macos-15 target: x86_64-apple-darwin archive: psign-tool-macos-x64.zip binary: psign-tool - name: macos-arm64 rid: osx-arm64 - os: macos-14 + os: macos-15 target: aarch64-apple-darwin archive: psign-tool-macos-arm64.zip binary: psign-tool diff --git a/Cargo.lock b/Cargo.lock index e800149..ad381b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2061,6 +2061,7 @@ dependencies = [ "psign-opc-sign", "psign-sip-digest", "quick-xml", + "rand 0.8.6", "rcgen", "rsa 0.9.10", "serde", diff --git a/README.md b/README.md index 526a742..37be861 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,10 @@ cargo build -p psign --bin psign-tool --locked # psign-tool portable business-central-app-info package.app # psign-tool portable msix-manifest-info package.msix # psign-tool portable msix-set-publisher package.msix --publisher "CN=Example" --output updated.msix +# Portable test PKI generation (isolated Root CA, Code Signing PFX with chain, TLS Server PFX/PEM): +# psign-tool portable generate-root-ca --subject "CN=Test Root CA" --cert-out root.cer --pfx-out root.pfx --password "$ROOT_PW" +# psign-tool portable issue-code-signing-cert --ca-pfx root.pfx --ca-password "$ROOT_PW" --subject "CN=Example" --pfx-out codesign.pfx --password "$SIGN_PW" +# psign-tool portable issue-tls-server-cert --ca-pfx root.pfx --ca-password "$ROOT_PW" --dns example.test --ip 127.0.0.1 --pfx-out tls.pfx --password "$TLS_PW" # psign-tool portable clickonce-deploy-info app.exe.deploy # psign-tool portable clickonce-copy-deploy-payload app.exe.deploy --output app.exe # psign-tool portable clickonce-update-manifest-hashes app.exe.manifest --base-directory . --output updated.manifest diff --git a/crates/psign-authenticode-trust/Cargo.toml b/crates/psign-authenticode-trust/Cargo.toml index 9059ac6..4223d4b 100644 --- a/crates/psign-authenticode-trust/Cargo.toml +++ b/crates/psign-authenticode-trust/Cargo.toml @@ -22,8 +22,9 @@ psign-sip-digest = { path = "../psign-sip-digest" } psign-opc-sign = { path = "../psign-opc-sign" } quick-xml = "0.38" zip = { version = "0.6.6", default-features = false, features = ["deflate"] } -picky = { version = "7.0.0-rc.23", features = ["pkcs7", "time_conversion"] } +picky = { version = "7.0.0-rc.23", features = ["pkcs7", "pkcs12", "time_conversion"] } picky-asn1-x509 = "0.15.4" +rand = "0.8" time = { version = "0.3", features = ["formatting", "parsing"] } x509-cert = "0.2.5" cab = "0.6" diff --git a/crates/psign-authenticode-trust/src/lib.rs b/crates/psign-authenticode-trust/src/lib.rs index 3cd8960..1b6573e 100644 --- a/crates/psign-authenticode-trust/src/lib.rs +++ b/crates/psign-authenticode-trust/src/lib.rs @@ -14,8 +14,10 @@ pub mod authroot_ctl; pub mod chain; pub mod inspect; pub mod online; +pub mod pkcs12; pub mod policy; pub mod rfc3161_extract; +pub mod test_pki; pub mod trust_pkcs7; pub mod trust_verify_cab; pub mod trust_verify_catalog; @@ -42,6 +44,7 @@ pub use inspect::{ InspectAuthenticodeDigest, InspectPeEntry, InspectPeFileReport, InspectPkcs7Report, InspectSigner, TimestampHint, inspect_authenticode_pkcs7_der, inspect_pe_authenticode, }; +pub use pkcs12::{ParsedPfxIdentity, parse_pfx_identity}; pub use policy::AuthenticodeTrustPolicy; pub use trust_verify_cab::trust_verify_cab_bytes; pub use trust_verify_catalog::trust_verify_catalog_bytes; diff --git a/crates/psign-authenticode-trust/src/pkcs12.rs b/crates/psign-authenticode-trust/src/pkcs12.rs new file mode 100644 index 0000000..1801887 --- /dev/null +++ b/crates/psign-authenticode-trust/src/pkcs12.rs @@ -0,0 +1,166 @@ +//! PKCS#12 (`.pfx` / `.p12`) signer identity and certificate-chain extraction. + +use std::collections::HashSet; + +use anyhow::{Context, Result, anyhow, bail}; +use picky::key::PrivateKey; +use picky::pkcs12::{ + Pfx, Pkcs12AttributeKind, Pkcs12CryptoContext, Pkcs12ParsingParams, SafeBag, SafeBagKind, + SafeContentsKind, +}; +use picky::x509::Cert as PickyCert; + +/// Parsed signer identity and accompanying certificate chain extracted from a PKCS#12 (`.pfx`) archive. +#[derive(Debug, Clone)] +pub struct ParsedPfxIdentity { + pub signer_cert_der: Vec, + pub private_key_pem: String, + pub chain_certs_der: Vec>, +} + +#[derive(Debug, Clone)] +struct PfxCertEntry { + der: Vec, + local_key_id: Option>, +} + +#[derive(Debug, Clone)] +struct PfxKeyEntry { + pem: String, + key: PrivateKey, + local_key_id: Option>, +} + +/// Parse a PKCS#12 (`.pfx`) archive into its matched signer certificate, PKCS#8 PEM private key, +/// and any additional issuer/root certificates bundled in the PFX. +/// +/// # Errors +/// +/// Returns an error if the PFX cannot be decrypted or parsed, or if no certificate matches an +/// included private key. +pub fn parse_pfx_identity(bytes: &[u8], password: &str) -> Result { + let crypto_context = Pkcs12CryptoContext::new_with_password(password)?; + let parsing_params = Pkcs12ParsingParams::default(); + let pfx = Pfx::from_der(bytes, &crypto_context, &parsing_params)?; + let mut certs = Vec::new(); + let mut keys = Vec::new(); + for safe_contents in pfx.safe_contents() { + collect_pfx_bags(safe_contents.kind(), &mut certs, &mut keys)?; + } + if certs.is_empty() { + bail!("PFX did not contain an X.509 certificate"); + } + if keys.is_empty() { + bail!("PFX did not contain a private key"); + } + + let matched = find_matching_pfx_pair(&certs, &keys).ok_or_else(|| { + anyhow!("PFX did not contain a certificate matching an included private key") + })?; + let signer_cert_der = certs[matched.0].der.clone(); + let private_key_pem = keys[matched.1].pem.clone(); + + let mut seen = HashSet::with_capacity(certs.len()); + seen.insert(signer_cert_der.clone()); + let mut chain_certs_der = Vec::new(); + for entry in certs { + if seen.insert(entry.der.clone()) { + chain_certs_der.push(entry.der); + } + } + + Ok(ParsedPfxIdentity { + signer_cert_der, + private_key_pem, + chain_certs_der, + }) +} + +fn find_matching_pfx_pair(certs: &[PfxCertEntry], keys: &[PfxKeyEntry]) -> Option<(usize, usize)> { + // Prefer an explicit PKCS#12 localKeyId match that also satisfies public-key equality. + for (ci, cert) in certs.iter().enumerate() { + let Some(cert_id) = cert.local_key_id.as_deref() else { + continue; + }; + for (ki, key) in keys.iter().enumerate() { + if key.local_key_id.as_deref() == Some(cert_id) + && key_matches_cert(&cert.der, &key.key).is_ok() + { + return Some((ci, ki)); + } + } + } + for (ci, cert) in certs.iter().enumerate() { + for (ki, key) in keys.iter().enumerate() { + if key_matches_cert(&cert.der, &key.key).is_ok() { + return Some((ci, ki)); + } + } + } + None +} + +fn collect_pfx_bags( + kind: &SafeContentsKind, + certs: &mut Vec, + keys: &mut Vec, +) -> Result<()> { + match kind { + SafeContentsKind::SafeBags(bags) + | SafeContentsKind::EncryptedSafeBags { + safe_bags: bags, .. + } => { + for bag in bags { + collect_safe_bag(bag, certs, keys)?; + } + } + SafeContentsKind::Unknown => {} + } + Ok(()) +} + +fn collect_safe_bag( + bag: &SafeBag, + certs: &mut Vec, + keys: &mut Vec, +) -> Result<()> { + let local_key_id = bag.attributes().iter().find_map(|attr| match attr.kind() { + Pkcs12AttributeKind::LocalKeyId(id) => Some(id.clone()), + _ => None, + }); + match bag.kind() { + SafeBagKind::PrivateKey(key) | SafeBagKind::EncryptedPrivateKey { key, .. } => { + let mut pem = key + .to_pem_str() + .context("encode PFX private key as PKCS#8 PEM")?; + pem.push('\n'); + keys.push(PfxKeyEntry { + pem, + key: key.clone(), + local_key_id, + }); + } + SafeBagKind::Certificate(cert) => { + let der = cert.to_der().context("encode PFX certificate as DER")?; + certs.push(PfxCertEntry { der, local_key_id }); + } + SafeBagKind::Nested(bags) => { + for nested in bags { + collect_safe_bag(nested, certs, keys)?; + } + } + SafeBagKind::Secret(_) | SafeBagKind::Unknown => {} + } + Ok(()) +} + +pub(crate) fn key_matches_cert(cert_der: &[u8], key: &PrivateKey) -> Result<()> { + let cert = PickyCert::from_der(cert_der).context("parse certificate for key matching")?; + let key_public = key + .to_public_key() + .context("derive public key from private key")?; + if cert.public_key() != &key_public { + bail!("private key does not match certificate public key"); + } + Ok(()) +} diff --git a/crates/psign-authenticode-trust/src/test_pki.rs b/crates/psign-authenticode-trust/src/test_pki.rs new file mode 100644 index 0000000..9a5b62d --- /dev/null +++ b/crates/psign-authenticode-trust/src/test_pki.rs @@ -0,0 +1,1210 @@ +//! Portable test PKI generation and PKCS#12 chain extraction for local and VM test environments. +//! +//! Produces RFC 5280 / Windows CryptoAPI-compatible X.509 v3 Root CA, Code Signing, and TLS Server +//! certificates and PKCS#12 (`.pfx`) bundles without modifying OS certificate stores. + +use std::net::IpAddr; +use std::time::{Duration, SystemTime}; + +pub use crate::pkcs12::{ParsedPfxIdentity, parse_pfx_identity}; +use anyhow::{Context, Result, anyhow, bail}; +use base64::Engine as _; +use der::asn1::{ + Any, AnyRef, BitString, GeneralizedTime, Ia5String, Ia5StringRef, ObjectIdentifier, + OctetString, PrintableStringRef, SetOfVec, TeletexStringRef, UtcTime, Utf8StringRef, +}; +use der::{Decode, Encode, Tag, Tagged}; +use picky::key::PrivateKey; +use picky::pkcs12::{ + Pbes2Cipher, Pbes2Encryption, Pfx, Pkcs12Attribute, Pkcs12CryptoContext, Pkcs12Encryption, + Pkcs12HashAlgorithm, Pkcs12MacAlgorithmHmac, SafeBag, SafeContents, +}; +use picky::x509::Cert as PickyCert; +use psign_sip_digest::rdp; +use rand::RngCore as _; +use rand::rngs::OsRng; +use rsa::RsaPrivateKey; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::{EncodePrivateKey, LineEnding}; +use rsa::signature::{Keypair, SignatureEncoding, Signer}; +use serde::Serialize; +use sha1::Sha1; +use sha2::{Digest as _, Sha256}; +use x509_cert::attr::AttributeTypeAndValue; +use x509_cert::ext::pkix::name::GeneralName; +use x509_cert::ext::pkix::{ + AuthorityKeyIdentifier, BasicConstraints, ExtendedKeyUsage, KeyUsage, KeyUsages, + SubjectAltName, SubjectKeyIdentifier, +}; +use x509_cert::ext::{AsExtension, Extension}; +use x509_cert::name::{Name, RdnSequence, RelativeDistinguishedName}; +use x509_cert::serial_number::SerialNumber; +use x509_cert::spki::{AlgorithmIdentifierOwned, SubjectPublicKeyInfoOwned}; +use x509_cert::time::{Time, Validity}; +use x509_cert::{Certificate, TbsCertificate, Version}; + +const OID_COMMON_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.3"); +const OID_SURNAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.4"); +const OID_SERIAL_NUMBER: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.5"); +const OID_COUNTRY_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.6"); +const OID_LOCALITY_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.7"); +const OID_STATE_OR_PROVINCE_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.8"); +const OID_STREET_ADDRESS: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.9"); +const OID_ORGANIZATION_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.10"); +const OID_ORGANIZATIONAL_UNIT_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.11"); +const OID_TITLE: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.12"); +const OID_GIVEN_NAME: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.5.4.42"); +const OID_EMAIL_ADDRESS: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.113549.1.9.1"); +const OID_USER_ID: ObjectIdentifier = ObjectIdentifier::new_unwrap("0.9.2342.19200300.100.1.1"); +const OID_DOMAIN_COMPONENT: ObjectIdentifier = + ObjectIdentifier::new_unwrap("0.9.2342.19200300.100.1.25"); + +const OID_SHA256_WITH_RSA_ENCRYPTION: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.113549.1.1.11"); +const OID_EKU_SERVER_AUTH: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.3.1"); +const OID_EKU_CODE_SIGNING: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.6.1.5.5.7.3.3"); + +/// Backdate generated certificates by 24 hours so guest/host VM clock skew does not reject fresh certs. +const NOT_BEFORE_CLOCK_SKEW_SECS: u64 = 86_400; + +/// Parameters for generating a self-signed test Root CA. +#[derive(Debug, Clone)] +pub struct RootCaSpec { + pub subject: String, + pub validity_days: u32, + pub key_bits: usize, +} + +/// Input CA signing material used to issue leaf certificates. +#[derive(Debug, Clone)] +pub struct CaIssuerMaterial { + pub cert: Certificate, + pub cert_der: Vec, + pub private_key: RsaPrivateKey, + pub extra_chain_certs_der: Vec>, +} + +/// Parameters for issuing a Code Signing leaf certificate. +#[derive(Debug, Clone)] +pub struct CodeSigningCertSpec { + pub subject: String, + pub validity_days: u32, + pub key_bits: usize, + pub extra_eku_oids: Vec, +} + +/// Parameters for issuing a TLS Server leaf certificate. +#[derive(Debug, Clone)] +pub struct TlsServerCertSpec { + pub subject: Option, + pub dns_names: Vec, + pub ip_addresses: Vec, + pub validity_days: u32, + pub key_bits: usize, +} + +/// Generated certificate, private key, and chain ready to serialize to DER, PEM, or PKCS#12 (`.pfx`). +#[derive(Debug, Clone)] +pub struct GeneratedCertMaterial { + pub cert: Certificate, + pub cert_der: Vec, + pub private_key_pem: String, + pub chain_certs_der: Vec>, + pub summary: GeneratedCertSummary, +} + +/// Machine-readable metadata describing a generated X.509 certificate. +#[derive(Debug, Clone, Serialize)] +pub struct GeneratedCertSummary { + pub subject: String, + pub issuer: String, + pub thumbprint_sha1: String, + pub thumbprint_sha256: String, + pub serial_hex: String, + pub not_before: String, + pub not_after: String, + pub is_ca: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub eku_oids: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub dns_names: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub ip_addresses: Vec, + pub chain_certificate_count: usize, +} + +/// Parse a Windows `X500DistinguishedName` or RFC 4514 distinguished name string into an X.509 [`Name`]. +/// +/// Supports standard Windows attribute aliases (`S=`, `E=`, `SN=`, spaces after commas, double-quoted +/// values containing commas or backslashes) as well as RFC 4514 escaping (`\,`, `\\`, `\ `, `\XX`), +/// reversing the RDN list into ASN.1 `RDNSequence` order so Windows `X509Certificate2.Subject` +/// formats it back in the exact input order. +/// +/// # Errors +/// +/// Returns an error if `input` is empty, contains malformed `KEY=VALUE` components, violates +/// `PrintableString` / `IA5String` character-set rules, or uses an unknown attribute name. +pub fn parse_distinguished_name(input: &str) -> Result { + let trimmed = trim_unescaped_spaces(input); + if trimmed.is_empty() { + bail!("distinguished name must not be empty"); + } + let rdn_tokens = split_unescaped(trimmed, ',')?; + let mut rdns = Vec::with_capacity(rdn_tokens.len()); + for token in rdn_tokens { + let token = trim_unescaped_spaces(token); + if token.is_empty() { + bail!("distinguished name '{input}' contains an empty RDN component"); + } + let atv_tokens = split_unescaped(token, '+')?; + let mut atvs = Vec::with_capacity(atv_tokens.len()); + for atv_str in atv_tokens { + atvs.push(parse_atv(trim_unescaped_spaces(atv_str))?); + } + let set = SetOfVec::try_from(atvs) + .map_err(|e| anyhow!("encode RDN attribute set for '{token}': {e}"))?; + rdns.push(RelativeDistinguishedName(set)); + } + // Both RFC 4514 and Windows X500DistinguishedName list most-specific (CN) first in text and + // most-general (C) first in ASN.1 RDNSequence. + rdns.reverse(); + Ok(RdnSequence(rdns)) +} + +/// Trim surrounding ASCII whitespace while preserving an RFC 4514 escaped trailing space (`\ `). +fn trim_unescaped_spaces(raw: &str) -> &str { + let bytes = raw.as_bytes(); + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + let backslashes = bytes[start..end - 1] + .iter() + .rev() + .take_while(|&&c| c == b'\\') + .count(); + if bytes[end - 1] == b' ' && backslashes % 2 == 1 { + break; + } + end -= 1; + } + &raw[start..end] +} + +fn split_unescaped(input: &str, delimiter: char) -> Result> { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut in_quotes = false; + let mut escaped = false; + let mut chars = input.char_indices().peekable(); + + while let Some((idx, ch)) = chars.next() { + if escaped { + escaped = false; + continue; + } + if ch == '\\' && !in_quotes { + escaped = true; + continue; + } + if ch == '"' { + if in_quotes && chars.peek().is_some_and(|(_, next)| *next == '"') { + let _ = chars.next(); + continue; + } + in_quotes = !in_quotes; + continue; + } + if ch == delimiter && !in_quotes { + parts.push(&input[start..idx]); + start = idx + ch.len_utf8(); + } + } + if in_quotes { + bail!("unterminated double quote in distinguished name '{input}'"); + } + if escaped { + bail!("trailing backslash escape in distinguished name '{input}'"); + } + parts.push(&input[start..]); + Ok(parts) +} + +fn parse_atv(token: &str) -> Result { + let Some((raw_key, raw_val)) = token.split_once('=') else { + bail!( + "distinguished name component '{token}' must be in KEY=VALUE form (for example, CN=Example)" + ); + }; + let key = raw_key.trim(); + if key.is_empty() { + bail!("distinguished name component '{token}' has an empty attribute name"); + } + let oid = resolve_dn_attribute_oid(key)?; + let val_trimmed = trim_unescaped_spaces(raw_val); + if let Some(hex_body) = val_trimmed.strip_prefix('#') { + let raw_der = decode_hex(hex_body) + .with_context(|| format!("decode hex DER value in RDN '{token}'"))?; + let value = Any::from_der(&raw_der) + .map_err(|e| anyhow!("decode DER value in RDN '{token}': {e}"))?; + return Ok(AttributeTypeAndValue { oid, value }); + } + + let unescaped = unescape_dn_value(val_trimmed)?; + let value = if oid == OID_COUNTRY_NAME { + if unescaped.len() != 2 { + bail!("RDN '{key}' must be a 2-character ISO 3166 country code, got '{unescaped}'"); + } + let ps = PrintableStringRef::new(unescaped.as_bytes()) + .map_err(|e| anyhow!("RDN '{key}' must be PrintableString: {e}"))?; + Any::from(ps) + } else if oid == OID_SERIAL_NUMBER { + if unescaped.is_empty() { + bail!("RDN '{key}' must not be empty"); + } + let ps = PrintableStringRef::new(unescaped.as_bytes()) + .map_err(|e| anyhow!("RDN '{key}' must be PrintableString: {e}"))?; + Any::from(ps) + } else if oid == OID_DOMAIN_COMPONENT || oid == OID_EMAIL_ADDRESS { + if unescaped.is_empty() { + bail!("RDN '{key}' must not be empty"); + } + let ia5 = Ia5String::new(&unescaped) + .map_err(|e| anyhow!("RDN '{key}' must be ASCII IA5String: {e}"))?; + Any::from(&ia5) + } else { + Any::new(Tag::Utf8String, unescaped.as_bytes()) + .map_err(|e| anyhow!("encode RDN '{key}={unescaped}' as Utf8String: {e}"))? + }; + Ok(AttributeTypeAndValue { oid, value }) +} + +fn resolve_dn_attribute_oid(key: &str) -> Result { + match key.to_ascii_uppercase().as_str() { + "CN" | "COMMONNAME" => Ok(OID_COMMON_NAME), + "O" | "ORG" | "ORGANIZATION" | "ORGANIZATIONNAME" => Ok(OID_ORGANIZATION_NAME), + "OU" | "ORGANIZATIONALUNIT" | "ORGANIZATIONALUNITNAME" => Ok(OID_ORGANIZATIONAL_UNIT_NAME), + "L" | "LOCALITY" | "LOCALITYNAME" => Ok(OID_LOCALITY_NAME), + "S" | "ST" | "STATE" | "PROVINCE" | "STATEORPROVINCENAME" => Ok(OID_STATE_OR_PROVINCE_NAME), + "C" | "COUNTRY" | "COUNTRYNAME" => Ok(OID_COUNTRY_NAME), + "STREET" | "STREETADDRESS" => Ok(OID_STREET_ADDRESS), + "E" | "EMAIL" | "EMAILADDRESS" => Ok(OID_EMAIL_ADDRESS), + "DC" | "DOMAINCOMPONENT" => Ok(OID_DOMAIN_COMPONENT), + "SERIALNUMBER" => Ok(OID_SERIAL_NUMBER), + "UID" | "USERID" => Ok(OID_USER_ID), + "T" | "TITLE" => Ok(OID_TITLE), + "G" | "GN" | "GIVENNAME" => Ok(OID_GIVEN_NAME), + "SN" | "SURNAME" => Ok(OID_SURNAME), + other => { + let dotted = other.strip_prefix("OID.").unwrap_or(other); + ObjectIdentifier::new(dotted) + .map_err(|_| anyhow!("unsupported X.500 attribute '{key}'")) + } + } +} + +fn unescape_dn_value(raw: &str) -> Result { + if raw.starts_with('"') { + if !raw.ends_with('"') || raw.len() < 2 { + bail!("improperly quoted RDN value '{raw}'"); + } + let inner = &raw[1..raw.len() - 1]; + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '"' { + if chars.peek() == Some(&'"') { + let _ = chars.next(); + out.push('"'); + } else { + bail!("unescaped double quote inside quoted RDN value '{raw}'"); + } + } else { + // Inside Windows CERT_X500_NAME_STR quotes, only `""` is an escape; `\` is literal. + out.push(ch); + } + } + return Ok(out); + } + + let mut bytes = Vec::with_capacity(raw.len()); + let mut iter = raw.as_bytes().iter().copied(); + while let Some(b) = iter.next() { + if b == b'"' { + bail!("unescaped double quote in RDN value '{raw}'"); + } + if b != b'\\' { + bytes.push(b); + continue; + } + let esc = iter + .next() + .ok_or_else(|| anyhow!("trailing backslash in RDN value '{raw}'"))?; + if matches!( + esc, + b' ' | b'"' | b'#' | b'+' | b',' | b';' | b'<' | b'=' | b'>' | b'\\' + ) { + bytes.push(esc); + } else if esc.is_ascii_hexdigit() { + let lo = iter.next().ok_or_else(|| { + anyhow!( + "incomplete hex escape '\\{}' in RDN value '{raw}'; escape literal backslashes as '\\\\' or quote the value", + esc as char + ) + })?; + let byte = decode_hex_pair(esc, lo) + .ok_or_else(|| anyhow!("invalid hex escape in RDN value '{raw}'"))?; + bytes.push(byte); + } else { + bail!( + "invalid backslash escape '\\{}' in RDN value '{raw}'; escape literal backslashes as '\\\\' or quote the value", + esc as char + ); + } + } + String::from_utf8(bytes).context("RDN value is not valid UTF-8") +} + +fn decode_hex(hex: &str) -> Result> { + let clean: Vec = hex.bytes().filter(|b| !b.is_ascii_whitespace()).collect(); + if clean.is_empty() || !clean.len().is_multiple_of(2) { + bail!("hex string must have a non-zero even number of hex digits"); + } + let mut out = Vec::with_capacity(clean.len() / 2); + for &[hi, lo] in clean.as_chunks::<2>().0 { + let byte = + decode_hex_pair(hi, lo).ok_or_else(|| anyhow!("invalid hex digit in '{hex}'"))?; + out.push(byte); + } + Ok(out) +} + +fn decode_hex_pair(hi: u8, lo: u8) -> Option { + fn nibble(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } + } + Some((nibble(hi)? << 4) | nibble(lo)?) +} + +/// Format an X.509 [`Name`] using Windows `X500DistinguishedName` order (`CN=..., O=..., S=..., C=...`). +/// +/// Matches `X509Certificate2.Subject` and MSIX `Identity/@Publisher` string formatting. +#[must_use] +pub fn format_windows_dn(name: &Name) -> String { + let mut parts = Vec::with_capacity(name.0.len()); + for rdn in name.0.iter().rev() { + let mut rdn_parts = Vec::with_capacity(rdn.0.len()); + for atv in rdn.0.iter() { + rdn_parts.push(format_windows_atv(atv)); + } + parts.push(rdn_parts.join(" + ")); + } + parts.join(", ") +} + +fn format_windows_atv(atv: &AttributeTypeAndValue) -> String { + let key = match atv.oid { + OID_COMMON_NAME => "CN".to_string(), + OID_ORGANIZATION_NAME => "O".to_string(), + OID_ORGANIZATIONAL_UNIT_NAME => "OU".to_string(), + OID_LOCALITY_NAME => "L".to_string(), + OID_STATE_OR_PROVINCE_NAME => "S".to_string(), + OID_COUNTRY_NAME => "C".to_string(), + OID_STREET_ADDRESS => "STREET".to_string(), + OID_EMAIL_ADDRESS => "E".to_string(), + OID_DOMAIN_COMPONENT => "DC".to_string(), + OID_SERIAL_NUMBER => "SERIALNUMBER".to_string(), + OID_USER_ID => "UID".to_string(), + OID_TITLE => "T".to_string(), + OID_GIVEN_NAME => "G".to_string(), + OID_SURNAME => "SN".to_string(), + other => format!("OID.{other}"), + }; + let decoded = match atv.value.tag() { + Tag::PrintableString => PrintableStringRef::try_from(&atv.value) + .ok() + .map(|s| s.as_str().to_owned()), + Tag::Utf8String => Utf8StringRef::try_from(&atv.value) + .ok() + .map(|s| s.as_str().to_owned()), + Tag::Ia5String => Ia5StringRef::try_from(&atv.value) + .ok() + .map(|s| s.as_str().to_owned()), + Tag::TeletexString => TeletexStringRef::try_from(&atv.value) + .ok() + .map(|s| s.as_str().to_owned()), + Tag::BmpString => decode_bmp_string(atv.value.value()), + _ => None, + }; + match decoded { + Some(val) => format!("{key}={}", quote_windows_dn_value(&val)), + None => { + let der = atv.value.to_der().unwrap_or_default(); + format!("{key}=#{}", hex_upper(&der)) + } + } +} + +fn decode_bmp_string(bytes: &[u8]) -> Option { + if !bytes.len().is_multiple_of(2) { + return None; + } + let mut out = String::with_capacity(bytes.len() / 2); + for &[hi, lo] in bytes.as_chunks::<2>().0 { + let unit = u16::from_be_bytes([hi, lo]); + out.push(char::from_u32(u32::from(unit))?); + } + Some(out) +} + +fn quote_windows_dn_value(val: &str) -> String { + let needs_quotes = val.is_empty() + || val.starts_with(' ') + || val.ends_with(' ') + || val.starts_with('#') + || val.contains([',', '+', '=', '"', '<', '>', ';', '\\', '\r', '\n']); + if !needs_quotes { + return val.to_owned(); + } + format!("\"{}\"", val.replace('"', "\"\"")) +} + +/// Load CA signing material from a PKCS#12 (`.pfx`) archive. +/// +/// # Errors +/// +/// Returns an error if the PFX is invalid or the matched certificate is not a CA certificate. +pub fn load_ca_issuer_from_pfx(pfx_bytes: &[u8], password: &str) -> Result { + let parsed = parse_pfx_identity(pfx_bytes, password)?; + build_ca_issuer_material( + parsed.signer_cert_der, + parsed.private_key_pem.as_bytes(), + parsed.chain_certs_der, + ) +} + +/// Load CA signing material from separate certificate (DER or PEM) and RSA private key (DER or PEM) bytes. +/// +/// # Errors +/// +/// Returns an error if the certificate or key cannot be parsed, do not match, or the certificate is not a CA. +pub fn load_ca_issuer_from_cert_and_key( + cert_bytes: &[u8], + key_bytes: &[u8], + extra_chain_certs_der: Vec>, +) -> Result { + let cert = rdp::parse_certificate(cert_bytes).context("parse CA certificate")?; + let cert_der = cert.to_der().context("encode CA certificate DER")?; + build_ca_issuer_material(cert_der, key_bytes, extra_chain_certs_der) +} + +fn build_ca_issuer_material( + cert_der: Vec, + key_bytes: &[u8], + extra_chain_certs_der: Vec>, +) -> Result { + let cert = Certificate::from_der(&cert_der).context("parse CA X.509 certificate")?; + let is_ca = cert + .tbs_certificate + .get::() + .ok() + .flatten() + .is_some_and(|(_, bc)| bc.ca); + if !is_ca { + bail!( + "issuer certificate '{}' is not a CA certificate (BasicConstraints CA=true required)", + format_windows_dn(&cert.tbs_certificate.subject) + ); + } + let private_key = rdp::parse_rsa_private_key(key_bytes).context("parse CA RSA private key")?; + let key_pem = private_key + .to_pkcs8_pem(LineEnding::LF) + .context("encode CA private key PKCS#8 PEM")?; + let picky_key = PrivateKey::from_pem_str(&key_pem).context("parse CA key for verification")?; + crate::pkcs12::key_matches_cert(&cert_der, &picky_key) + .context("verify CA private key matches CA certificate")?; + Ok(CaIssuerMaterial { + cert, + cert_der, + private_key, + extra_chain_certs_der, + }) +} + +/// Generate a self-signed X.509 v3 Root CA certificate and RSA private key. +/// +/// # Errors +/// +/// Returns an error if `spec.subject` is invalid, `spec.key_bits` is unsupported, or key generation fails. +pub fn generate_root_ca(spec: &RootCaSpec) -> Result { + validate_key_bits(spec.key_bits)?; + if spec.validity_days == 0 { + bail!("validity_days must be at least 1"); + } + let subject = parse_distinguished_name(&spec.subject)?; + let private_key = + RsaPrivateKey::new(&mut OsRng, spec.key_bits).context("generate Root CA RSA key")?; + let signing_key = SigningKey::::new(private_key.clone()); + let spki = SubjectPublicKeyInfoOwned::from_key(signing_key.verifying_key()) + .context("encode Root CA SubjectPublicKeyInfo")?; + + let now = SystemTime::now(); + let not_before = now + .checked_sub(Duration::from_secs(NOT_BEFORE_CLOCK_SKEW_SECS)) + .unwrap_or(now); + let not_after = now + .checked_add(Duration::from_secs(u64::from(spec.validity_days) * 86_400)) + .ok_or_else(|| anyhow!("validity_days overflow"))?; + let validity = build_validity(not_before, not_after)?; + + let ski = SubjectKeyIdentifier(compute_ski_octet_string(&spki)?); + let bc = BasicConstraints { + ca: true, + path_len_constraint: None, + }; + let ku = KeyUsage(KeyUsages::KeyCertSign | KeyUsages::CRLSign); + let mut extensions = Vec::with_capacity(3); + push_extension(&mut extensions, &ski, &subject)?; + push_extension(&mut extensions, &bc, &subject)?; + push_extension(&mut extensions, &ku, &subject)?; + + let tbs = TbsCertificate { + version: Version::V3, + serial_number: random_serial_number()?, + signature: sha256_with_rsa_alg_id(), + issuer: subject.clone(), + validity, + subject, + subject_public_key_info: spki, + issuer_unique_id: None, + subject_unique_id: None, + extensions: Some(extensions), + }; + let cert = sign_tbs_certificate(tbs, &signing_key)?; + finalize_generated_material( + cert, + &private_key, + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) +} + +/// Issue an X.509 v3 Code Signing leaf certificate chained to `ca`. +/// +/// Includes `BasicConstraints(CA=false)`, `KeyUsage(DigitalSignature)`, and +/// `ExtendedKeyUsage(1.3.6.1.5.5.7.3.3)` (plus any `spec.extra_eku_oids`). +/// +/// # Errors +/// +/// Returns an error if parameters are invalid, the CA is expired, or signing fails. +pub fn issue_code_signing_cert( + ca: &CaIssuerMaterial, + spec: &CodeSigningCertSpec, +) -> Result { + validate_key_bits(spec.key_bits)?; + if spec.validity_days == 0 { + bail!("validity_days must be at least 1"); + } + let subject = parse_distinguished_name(&spec.subject)?; + let mut eku_oids = vec![OID_EKU_CODE_SIGNING]; + for extra in &spec.extra_eku_oids { + let oid = ObjectIdentifier::new(extra.trim()) + .map_err(|e| anyhow!("invalid extra EKU OID '{extra}': {e}"))?; + if !eku_oids.contains(&oid) { + eku_oids.push(oid); + } + } + + let leaf_private_key = + RsaPrivateKey::new(&mut OsRng, spec.key_bits).context("generate Code Signing RSA key")?; + let leaf_signing_key = SigningKey::::new(leaf_private_key.clone()); + let leaf_spki = SubjectPublicKeyInfoOwned::from_key(leaf_signing_key.verifying_key()) + .context("encode Code Signing SubjectPublicKeyInfo")?; + let validity = leaf_validity_clamped_to_ca(ca, spec.validity_days)?; + + let ski = SubjectKeyIdentifier(compute_ski_octet_string(&leaf_spki)?); + let aki = AuthorityKeyIdentifier { + key_identifier: Some(extract_or_compute_ca_ski(&ca.cert)?), + authority_cert_issuer: None, + authority_cert_serial_number: None, + }; + let bc = BasicConstraints { + ca: false, + path_len_constraint: None, + }; + let ku = KeyUsage(KeyUsages::DigitalSignature.into()); + let eku = ExtendedKeyUsage(eku_oids.clone()); + + let mut extensions = Vec::with_capacity(5); + push_extension(&mut extensions, &ski, &subject)?; + push_extension(&mut extensions, &aki, &subject)?; + push_extension(&mut extensions, &bc, &subject)?; + push_extension(&mut extensions, &ku, &subject)?; + push_extension(&mut extensions, &eku, &subject)?; + + let ca_signing_key = SigningKey::::new(ca.private_key.clone()); + let tbs = TbsCertificate { + version: Version::V3, + serial_number: random_serial_number()?, + signature: sha256_with_rsa_alg_id(), + issuer: ca.cert.tbs_certificate.subject.clone(), + validity, + subject, + subject_public_key_info: leaf_spki, + issuer_unique_id: None, + subject_unique_id: None, + extensions: Some(extensions), + }; + let cert = sign_tbs_certificate(tbs, &ca_signing_key)?; + let mut chain = Vec::with_capacity(1 + ca.extra_chain_certs_der.len()); + chain.push(ca.cert_der.clone()); + chain.extend(ca.extra_chain_certs_der.iter().cloned()); + let eku_strings = eku_oids.iter().map(ToString::to_string).collect(); + + finalize_generated_material( + cert, + &leaf_private_key, + chain, + eku_strings, + Vec::new(), + Vec::new(), + ) +} + +/// Issue an X.509 v3 TLS Server leaf certificate with DNS and/or IP SANs chained to `ca`. +/// +/// Includes `BasicConstraints(CA=false)`, `KeyUsage(DigitalSignature | KeyEncipherment)`, +/// `ExtendedKeyUsage(1.3.6.1.5.5.7.3.1)`, and `SubjectAltName`. +/// +/// # Errors +/// +/// Returns an error if neither DNS nor IP SANs are provided, parameters are invalid, or signing fails. +pub fn issue_tls_server_cert( + ca: &CaIssuerMaterial, + spec: &TlsServerCertSpec, +) -> Result { + validate_key_bits(spec.key_bits)?; + if spec.validity_days == 0 { + bail!("validity_days must be at least 1"); + } + if spec.dns_names.is_empty() && spec.ip_addresses.is_empty() { + bail!( + "TLS server certificate requires at least one --dns or --ip Subject Alternative Name" + ); + } + + let mut san_entries = Vec::with_capacity(spec.dns_names.len() + spec.ip_addresses.len()); + let mut normalized_dns = Vec::with_capacity(spec.dns_names.len()); + for dns in &spec.dns_names { + let host = dns.trim(); + if host.is_empty() { + bail!("DNS Subject Alternative Name must not be empty"); + } + let ia5 = Ia5String::new(host).map_err(|e| { + anyhow!("DNS Subject Alternative Name '{host}' must be ASCII IA5String: {e}") + })?; + san_entries.push(GeneralName::DnsName(ia5)); + normalized_dns.push(host.to_owned()); + } + let mut normalized_ips = Vec::with_capacity(spec.ip_addresses.len()); + for &ip in &spec.ip_addresses { + san_entries.push(GeneralName::from(ip)); + normalized_ips.push(ip.to_string()); + } + + let default_subject; + let subject_str = match spec + .subject + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(explicit) => explicit, + None => { + let primary = normalized_dns + .first() + .map(String::as_str) + .or_else(|| normalized_ips.first().map(String::as_str)) + .expect("at least one SAN was validated"); + default_subject = format!("CN={}", quote_windows_dn_value(primary)); + &default_subject + } + }; + let subject = parse_distinguished_name(subject_str)?; + + let leaf_private_key = + RsaPrivateKey::new(&mut OsRng, spec.key_bits).context("generate TLS Server RSA key")?; + let leaf_signing_key = SigningKey::::new(leaf_private_key.clone()); + let leaf_spki = SubjectPublicKeyInfoOwned::from_key(leaf_signing_key.verifying_key()) + .context("encode TLS Server SubjectPublicKeyInfo")?; + let validity = leaf_validity_clamped_to_ca(ca, spec.validity_days)?; + + let ski = SubjectKeyIdentifier(compute_ski_octet_string(&leaf_spki)?); + let aki = AuthorityKeyIdentifier { + key_identifier: Some(extract_or_compute_ca_ski(&ca.cert)?), + authority_cert_issuer: None, + authority_cert_serial_number: None, + }; + let bc = BasicConstraints { + ca: false, + path_len_constraint: None, + }; + let ku = KeyUsage(KeyUsages::DigitalSignature | KeyUsages::KeyEncipherment); + let eku = ExtendedKeyUsage(vec![OID_EKU_SERVER_AUTH]); + let san = SubjectAltName(san_entries); + + let mut extensions = Vec::with_capacity(6); + push_extension(&mut extensions, &ski, &subject)?; + push_extension(&mut extensions, &aki, &subject)?; + push_extension(&mut extensions, &bc, &subject)?; + push_extension(&mut extensions, &ku, &subject)?; + push_extension(&mut extensions, &eku, &subject)?; + push_extension(&mut extensions, &san, &subject)?; + + let ca_signing_key = SigningKey::::new(ca.private_key.clone()); + let tbs = TbsCertificate { + version: Version::V3, + serial_number: random_serial_number()?, + signature: sha256_with_rsa_alg_id(), + issuer: ca.cert.tbs_certificate.subject.clone(), + validity, + subject, + subject_public_key_info: leaf_spki, + issuer_unique_id: None, + subject_unique_id: None, + extensions: Some(extensions), + }; + let cert = sign_tbs_certificate(tbs, &ca_signing_key)?; + let mut chain = Vec::with_capacity(1 + ca.extra_chain_certs_der.len()); + chain.push(ca.cert_der.clone()); + chain.extend(ca.extra_chain_certs_der.iter().cloned()); + + finalize_generated_material( + cert, + &leaf_private_key, + chain, + vec![OID_EKU_SERVER_AUTH.to_string()], + normalized_dns, + normalized_ips, + ) +} + +/// Encode a [`GeneratedCertMaterial`] (leaf certificate, private key, and any CA chain certificates) +/// as a password-protected PKCS#12 (`.pfx`) archive using PBES2 (`AES-256-CBC` + `PBKDF2-HMAC-SHA256`) +/// and `HMAC-SHA256` MAC integrity (tested with `psign`, `.NET`, and Windows 10/11/Server 2022+ `PFXImportCertStore`). +/// +/// # Errors +/// +/// Returns an error if PKCS#12 encryption or DER serialization fails. +pub fn encode_pfx_der(material: &GeneratedCertMaterial, password: &str) -> Result> { + let leaf_cert = + PickyCert::from_der(&material.cert_der).context("parse generated certificate for PFX")?; + let private_key = PrivateKey::from_pem_str(&material.private_key_pem) + .context("parse generated PKCS#8 private key for PFX")?; + let local_key_id = Sha1::digest( + material + .cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(), + ) + .to_vec(); + + let mut context = + Pkcs12CryptoContext::new_with_password(password).context("initialize PKCS#12 context")?; + + let mut cert_bags = Vec::with_capacity(1 + material.chain_certs_der.len()); + cert_bags.push( + SafeBag::new_certificate( + leaf_cert, + vec![Pkcs12Attribute::new_local_key_id(local_key_id.clone())], + ) + .context("create PFX leaf certificate bag")?, + ); + for chain_der in &material.chain_certs_der { + let chain_cert = + PickyCert::from_der(chain_der).context("parse chain certificate for PFX")?; + cert_bags.push( + SafeBag::new_certificate(chain_cert, Vec::new()) + .context("create PFX chain certificate bag")?, + ); + } + + let cert_encryption = Pkcs12Encryption::new_pbes2( + Pbes2Encryption::new(Pbes2Cipher::Aes256Cbc, Pkcs12HashAlgorithm::Sha256), + &mut context, + ); + let cert_contents = SafeContents::new_encrypted(cert_bags, cert_encryption, &context) + .context("encrypt PFX certificate SafeContents")?; + + let key_encryption = Pkcs12Encryption::new_pbes2( + Pbes2Encryption::new(Pbes2Cipher::Aes256Cbc, Pkcs12HashAlgorithm::Sha256), + &mut context, + ); + let key_bag = SafeBag::new_encrypted_key( + private_key, + vec![Pkcs12Attribute::new_local_key_id(local_key_id)], + key_encryption, + &context, + ) + .context("encrypt PFX private key SafeBag")?; + let key_contents = SafeContents::new(vec![key_bag]); + + Pfx::new_with_hmac( + vec![cert_contents, key_contents], + Pkcs12MacAlgorithmHmac::new(Pkcs12HashAlgorithm::Sha256), + &mut context, + ) + .context("create PKCS#12 MAC")? + .to_der() + .context("serialize PKCS#12 PFX as DER") +} + +/// Encode one or more DER certificates (for example `[leaf, ca...]`) as a PEM bundle. +#[must_use] +pub fn encode_pem_certificate_bundle(cert_ders: &[&[u8]]) -> String { + let mut out = String::new(); + for der in cert_ders { + out.push_str("-----BEGIN CERTIFICATE-----\n"); + let b64 = base64::engine::general_purpose::STANDARD.encode(der); + for chunk in b64.as_bytes().chunks(64) { + out.push_str(std::str::from_utf8(chunk).expect("base64 is ASCII")); + out.push('\n'); + } + out.push_str("-----END CERTIFICATE-----\n"); + } + out +} + +fn validate_key_bits(key_bits: usize) -> Result<()> { + if !matches!(key_bits, 2048 | 3072 | 4096) { + bail!("unsupported RSA key size {key_bits}; supported values are 2048, 3072, or 4096"); + } + Ok(()) +} + +fn random_serial_number() -> Result { + let mut bytes = [0u8; 16]; + OsRng.fill_bytes(&mut bytes); + bytes[0] &= 0x7f; + if bytes.iter().all(|&b| b == 0) { + bytes[15] = 1; + } + // Ensure minimal positive ASN.1 INTEGER encoding (guaranteed non-zero high bit or non-zero byte). + bytes[0] |= 0x40; + SerialNumber::new(&bytes).map_err(|e| anyhow!("construct X.509 SerialNumber: {e}")) +} + +fn sha256_with_rsa_alg_id() -> AlgorithmIdentifierOwned { + AlgorithmIdentifierOwned { + oid: OID_SHA256_WITH_RSA_ENCRYPTION, + parameters: Some(Any::from(AnyRef::NULL)), + } +} + +fn compute_ski_octet_string(spki: &SubjectPublicKeyInfoOwned) -> Result { + let digest = Sha1::digest(spki.subject_public_key.raw_bytes()); + OctetString::new(digest.to_vec()).map_err(|e| anyhow!("encode SubjectKeyIdentifier: {e}")) +} + +fn extract_or_compute_ca_ski(ca_cert: &Certificate) -> Result { + if let Ok(Some((_, ski))) = ca_cert.tbs_certificate.get::() { + return Ok(ski.0); + } + compute_ski_octet_string(&ca_cert.tbs_certificate.subject_public_key_info) +} + +fn push_extension( + extensions: &mut Vec, + ext: &E, + subject: &Name, +) -> Result<()> { + let encoded = ext + .to_extension(subject, extensions) + .map_err(|e| anyhow!("encode X.509 extension {}: {e}", E::OID))?; + extensions.push(encoded); + Ok(()) +} + +fn rfc5280_time_from_system_time(time: SystemTime) -> Result