fix(sign): embed the issuers of macOS Keychain identities

A Keychain identity came back with an empty `chain`, so a signature made with
it carried only the signer's certificate. A validator that doesn't already
hold the intermediates (Acrobat, or PdfCraft on another machine) then can't
build the path to the root. The Windows certificate store identities already
carry the chain Windows builds.

`keychain::find` now asks the Security framework for the chain (SecTrust with
the basic X.509 policy, no network fetch) and keeps every certificate but the
signer's. It is best effort: a certificate macOS can't chain still signs.

`chain-legacy.p12` is `chain.p12` re-exported in the legacy format that
`security import` accepts, for a new ignored Keychain test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ffishman
2026-10-20 14:27:52 -03:00
co-authored by Claude Opus 5.5
parent 22bedbf2af
commit 63b4af8ba5
4 changed files with 72 additions and 3 deletions
+27 -3
View File
@@ -5,9 +5,12 @@
use std::path::Path;
use std::sync::Arc;
use security_framework::certificate::SecCertificate;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult};
use security_framework::key::{Algorithm, SecKey};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework::policy::SecPolicy;
use security_framework::trust::SecTrust;
use crate::keys::{DigestAlg, ExternalKey, PrivateKey, PublicKey};
use crate::{Certificate, DigitalId, SignError};
@@ -37,15 +40,36 @@ pub fn reference(c: &Certificate) -> String {
format!("keychain:{}", d.iter().map(|b| format!("{b:02x}")).collect::<String>())
}
/// The identity a reference (or a certificate common name) names.
/// The identity a reference (or a certificate common name) names. Its `chain` holds the
/// issuers macOS finds for the certificate, so a signature embeds them as it does for a file's.
pub fn find(reference_or_name: &str) -> Result<DigitalId, SignError> {
identities(None)?
let mut id = identities(None)?
.into_iter()
.find(|id| {
reference(&id.certificate) == reference_or_name
|| id.certificate.subject.common_name() == Some(reference_or_name.trim_start_matches("keychain:"))
})
.ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}")))
.ok_or_else(|| SignError::Crypto(format!("no Keychain identity {reference_or_name}")))?;
id.chain = issuers(&id.certificate);
Ok(id)
}
/// The certificates macOS chains `certificate` to through the user's keychains (and the
/// system roots), without `certificate` itself. Best effort: an identity signs without them
/// too. Nothing is fetched from the network, so signing stays offline.
fn issuers(certificate: &Certificate) -> Vec<Certificate> {
let Ok(leaf) = SecCertificate::from_der(&certificate.raw) else { return Vec::new() };
let Ok(mut trust) = SecTrust::create_with_certificates(&[leaf], &[SecPolicy::create_x509()]) else { return Vec::new() };
if trust.set_network_fetch_allowed(false).is_err() {
return Vec::new();
}
// The chain is built even when it doesn't end at a trusted root (a private CA, an expired
// certificate); whether the signer is trusted is decided when the signature is validated.
let _ = trust.evaluate_with_error();
// `SecTrust::chain` needs macOS 12; PdfCraft runs on macOS 11 (packaging/macos/package.sh).
#[allow(deprecated)]
let chain: Vec<SecCertificate> = (0..trust.certificate_count()).filter_map(|i| trust.certificate_at_index(i)).collect();
chain.iter().map(SecCertificate::to_der).filter(|der| *der != certificate.raw).filter_map(|der| Certificate::parse(&der).ok()).collect()
}
/// The signing identities in the user's keychains (or only in the keychain file `keychain`).
+2
View File
@@ -11,6 +11,7 @@ The password of every `.p12` is `test`.
| `ec-legacy.p12` | `ec-p256.p12` re-exported with `-legacy` (3DES, SHA-1 MAC): the format macOS `security import` accepts |
| `ec-p384.p12` | P-384 self-signed "Test Signer P384"; AES-128-CBC, SHA-1 MAC |
| `chain.p12` | RSA 2048 "Ada Lovelace" issued by a P-256 test root, chain included |
| `chain-legacy.p12` | `chain.p12` re-exported with `-legacy`, for the Keychain tests |
| `rsa.crt.pem`, `ca.crt.pem` | the RSA certificate and the test root alone |
Regenerate with:
@@ -29,6 +30,7 @@ openssl req -newkey rsa:2048 -nodes -keyout leaf.key -out leaf.csr -subj "/CN=Ad
printf "basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,nonRepudiation\n" > leaf.ext
openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -set_serial 77 -days 3650 -extfile leaf.ext -out leaf.crt
openssl pkcs12 -export -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain.p12 -passout pass:test -name "Ada Lovelace"
openssl pkcs12 -export -legacy -inkey leaf.key -in leaf.crt -certfile ca.crt -out chain-legacy.p12 -passout pass:test -name "Ada Lovelace"
```
`openssl-signed.pdf` is a hand-written one-page PDF with a `/Contents` placeholder, signed with
Binary file not shown.
+43
View File
@@ -667,6 +667,49 @@ fn signing_with_keychain_identities() {
result.unwrap();
}
/// A Keychain identity carries the certificates macOS chains it to, as a `.p12`'s does, so the
/// signature embeds its issuers. Ignored by default: the throwaway keychain joins the user's
/// keychain search list while the test runs (restored afterwards).
#[cfg(target_os = "macos")]
#[test]
#[ignore = "creates a temporary macOS keychain; run with --ignored"]
fn keychain_identities_carry_their_issuers() {
use std::process::Command;
let dir = std::env::temp_dir().join(format!("pdfcraft-keychain-chain-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let kc = dir.join("test.keychain-db");
let sec = |args: &[&str]| Command::new("security").args(args).output().unwrap();
let list = String::from_utf8(sec(&["list-keychains", "-d", "user"]).stdout).unwrap();
let saved: Vec<String> = list.lines().map(|l| l.trim().trim_matches('"').to_string()).filter(|l| !l.is_empty()).collect();
let k = kc.to_str().unwrap();
assert!(sec(&["create-keychain", "-p", "pc-test", k]).status.success());
let set_list = |extra: Option<&str>| {
let mut args = vec!["list-keychains", "-d", "user", "-s"];
args.extend(saved.iter().map(String::as_str));
args.extend(extra);
sec(&args);
};
// macOS finds an identity's issuers in the keychains on the search list.
set_list(Some(k));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
sec(&["unlock-keychain", "-p", "pc-test", k]);
let p = format!("{}/tests/data/chain-legacy.p12", env!("CARGO_MANIFEST_DIR"));
let out = sec(&["import", &p, "-k", k, "-P", "test", "-A"]);
assert!(out.status.success(), "chain-legacy.p12: {}", String::from_utf8_lossy(&out.stderr));
let listed = pdfcraft_sign::keychain::identities(Some(&kc)).unwrap();
assert_eq!(listed.len(), 1, "{listed:?}");
let id = pdfcraft_sign::keychain::find(&pdfcraft_sign::keychain::reference(&listed[0].certificate)).unwrap();
let issuers: Vec<_> = id.chain.iter().map(|c| c.subject.common_name()).collect();
assert_eq!(issuers, [Some("PrintCraft Test Root CA")]);
let file = pkcs12::open(&data("chain.p12"), "test").unwrap();
assert_eq!(id.chain.iter().map(|c| &c.raw).collect::<Vec<_>>(), file.chain.iter().map(|c| &c.raw).collect::<Vec<_>>());
}));
sec(&["delete-keychain", k]);
set_list(None);
let _ = std::fs::remove_dir_all(&dir);
result.unwrap();
}
/// `fixture()` plus a Form XObject (object 7) that the page draws.
fn fixture_with_xobject() -> Vec<u8> {
let objs: Vec<&[u8]> = vec![