M9: sign with CAC/PIV smart cards through the macOS Keychain (#968)

Token identities that macOS exposes through CryptoTokenKit (a CAC or PIV
card in a reader) were not listed: the Keychain search only covered the
file-based keychains. keychain::identities now also searches the token
access group, de-duplicates by certificate, and skips card certificates
whose key usage allows neither digitalSignature nor nonRepudiation (the
CAC encryption certificate, which would produce a signature that fails
validation). Card identities are labelled "(smart card)" in the sign
dialog. The key never leaves the card; macOS asks for the PIN.


Claude-Session: https://claude.ai/code/session_0158vdscYZsFFCPLjokXoyzM

Co-authored-by: sfv9 <286824270+sfv9@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sfv9
2026-10-11 13:20:44 -04:00
committed by GitHub
co-authored by sfv9 Claude Opus 5.5
parent 0af2fdad05
commit 9960e31b72
2 changed files with 36 additions and 4 deletions
+30 -3
View File
@@ -49,7 +49,9 @@ pub fn find(reference_or_name: &str) -> Result<DigitalId, SignError> {
}
/// The signing identities in the user's keychains (or only in the keychain file `keychain`).
/// Identities with keys PdfCraft can't use (other curves, Ed25519) are left out.
/// Without a keychain file, smart cards macOS reads through CryptoTokenKit (a CAC or PIV card
/// in a reader) are searched too; their keys stay on the card, which asks for its PIN when
/// signing. Identities with keys PdfCraft can't use (other curves, Ed25519) are left out.
pub fn identities(keychain: Option<&Path>) -> Result<Vec<DigitalId>, SignError> {
let mut search = ItemSearchOptions::new();
search.class(ItemClass::identity()).load_refs(true).limit(Limit::All);
@@ -58,7 +60,25 @@ pub fn identities(keychain: Option<&Path>) -> Result<Vec<DigitalId>, SignError>
kc = SecKeychain::open(p).map_err(|e| SignError::Crypto(format!("{}: {e}", p.display())))?;
search.keychains(std::slice::from_ref(&kc));
}
let found = match search.search() {
let mut out = collect(search.search(), false)?;
if keychain.is_none() {
let mut tokens = ItemSearchOptions::new();
tokens.class(ItemClass::identity()).load_refs(true).limit(Limit::All).access_group_token();
// A smart card that can't be searched (none inserted, reader unplugged) only means
// there are no card identities; the Keychain's own identities still count.
for id in collect(tokens.search(), true).unwrap_or_default() {
if !out.iter().any(|o| o.certificate.raw == id.certificate.raw) {
out.push(id);
}
}
}
Ok(out)
}
/// The usable identities in one search's results. `card` marks them as smart card identities
/// in their friendly name.
fn collect(found: Result<Vec<SearchResult>, security_framework::base::Error>, card: bool) -> Result<Vec<DigitalId>, SignError> {
let found = match found {
Ok(f) => f,
// "The specified item could not be found in the keychain."
Err(e) if e.code() == -25300 => return Ok(Vec::new()),
@@ -69,9 +89,16 @@ pub fn identities(keychain: Option<&Path>) -> Result<Vec<DigitalId>, SignError>
let SearchResult::Ref(Reference::Identity(id)) = r else { continue };
let (Ok(cert), Ok(key)) = (id.certificate(), id.private_key()) else { continue };
let Ok(certificate) = Certificate::parse(&cert.to_der()) else { continue };
// A CAC carries an encryption-only certificate beside its signing ones; a signature
// made with it would fail validation, so only offer certificates allowed to sign
// (key usage digitalSignature or nonRepudiation).
if card && certificate.key_usage.is_some_and(|u| u & 0b11 == 0) {
continue;
}
let rsa = matches!(certificate.public_key, PublicKey::Rsa { .. });
let key = PrivateKey::external(certificate.public_key.clone(), Arc::new(KeychainKey { key, rsa }));
let friendly_name = Some(certificate.display_name());
let name = certificate.display_name();
let friendly_name = Some(if card { format!("{name} (smart card)") } else { name });
out.push(DigitalId { key, certificate, chain: Vec::new(), friendly_name });
}
Ok(out)
+6 -1
View File
@@ -259,7 +259,12 @@ impl PdfCraftApp {
match sign::keychain::identities(None) {
Ok(ids) => {
for id in ids {
self.digital_ids.push(entry_for(&sign::keychain::reference(&id.certificate), &id.certificate));
let mut entry = entry_for(&sign::keychain::reference(&id.certificate), &id.certificate);
// Smart card identities say so ("… (smart card)").
if let Some(name) = id.friendly_name {
entry.name = name;
}
self.digital_ids.push(entry);
}
}
Err(e) => self.notify_fmt("The Keychain's digital IDs couldn't be listed: {e}", &[("e", &e.to_string())]),