mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 13:08:48 -06:00
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:
co-authored by
sfv9
Claude Opus 5.5
parent
0af2fdad05
commit
9960e31b72
@@ -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)
|
||||
|
||||
@@ -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())]),
|
||||
|
||||
Reference in New Issue
Block a user