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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/dotnet-tool.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/powershell-module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
- name: windows-x64
os: windows-2022
- name: macos-arm64
os: macos-14
os: macos-15

steps:
- name: Checkout
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/psign-authenticode-trust/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions crates/psign-authenticode-trust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
166 changes: 166 additions & 0 deletions crates/psign-authenticode-trust/src/pkcs12.rs
Original file line number Diff line number Diff line change
@@ -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<u8>,
pub private_key_pem: String,
pub chain_certs_der: Vec<Vec<u8>>,
}

#[derive(Debug, Clone)]
struct PfxCertEntry {
der: Vec<u8>,
local_key_id: Option<Vec<u8>>,
}

#[derive(Debug, Clone)]
struct PfxKeyEntry {
pem: String,
key: PrivateKey,
local_key_id: Option<Vec<u8>>,
}

/// 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<ParsedPfxIdentity> {
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<PfxCertEntry>,
keys: &mut Vec<PfxKeyEntry>,
) -> 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<PfxCertEntry>,
keys: &mut Vec<PfxKeyEntry>,
) -> 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(())
}
Loading
Loading