From 9960e31b72c4d1a5f6a5166ef1293cae82f85ff7 Mon Sep 17 00:00:00 2001 From: sfv9 Date: Sun, 11 Oct 2026 13:20:44 -0400 Subject: [PATCH] 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 --- crates/sign/src/keychain.rs | 33 ++++++++++++++++++++++++++++++--- crates/ui-egui/src/sign_ui.rs | 7 ++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/sign/src/keychain.rs b/crates/sign/src/keychain.rs index 4d52031bbd..601f1b97ba 100644 --- a/crates/sign/src/keychain.rs +++ b/crates/sign/src/keychain.rs @@ -49,7 +49,9 @@ pub fn find(reference_or_name: &str) -> Result { } /// 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, 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, 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, security_framework::base::Error>, card: bool) -> Result, 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, 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) diff --git a/crates/ui-egui/src/sign_ui.rs b/crates/ui-egui/src/sign_ui.rs index 6dda634896..b35a19177e 100644 --- a/crates/ui-egui/src/sign_ui.rs +++ b/crates/ui-egui/src/sign_ui.rs @@ -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())]),