a simple rust terminal ui (tui) for setting up alternative plc rotation keys driven by: secure enclave hardware (not synced) or software-based keys (synced to icloud)
plc secure-enclave touchid icloud atproto
at main 542 lines 20 kB view raw
1use anyhow::{Result, bail}; 2use core_foundation::base::{CFType, TCFType, kCFAllocatorDefault}; 3use core_foundation::boolean::CFBoolean; 4use core_foundation::data::CFData; 5use core_foundation::dictionary::CFDictionary; 6use core_foundation::number::CFNumber; 7use core_foundation::string::CFString; 8use core_foundation_sys::base::CFRelease; 9use security_framework_sys::access_control::*; 10use security_framework_sys::base::{SecKeyRef, errSecSuccess}; 11use security_framework_sys::item::*; 12use security_framework_sys::key::*; 13use std::ptr; 14use std::sync::mpsc; 15 16// LocalAuthentication framework FFI 17#[link(name = "LocalAuthentication", kind = "framework")] 18extern "C" {} 19 20// Objective-C runtime 21#[link(name = "objc", kind = "dylib")] 22extern "C" { 23 fn objc_getClass(name: *const std::ffi::c_char) -> *mut std::ffi::c_void; 24 fn sel_registerName(name: *const std::ffi::c_char) -> *mut std::ffi::c_void; 25 fn objc_msgSend(obj: *mut std::ffi::c_void, sel: *mut std::ffi::c_void, ...) -> *mut std::ffi::c_void; 26} 27 28const TAG_PREFIX: &str = "com.plc-touch.rotation-key."; 29 30// kSecAttrApplicationTag isn't exported by security-framework-sys. 31// Its value is the CFString "atag". 32fn attr_application_tag() -> CFString { 33 CFString::new("atag") 34} 35 36/// Keychain access group for syncable keys. 37/// Set KEYCHAIN_ACCESS_GROUP env var at compile time, or it defaults to a placeholder. 38fn keychain_access_group() -> &'static str { 39 option_env!("KEYCHAIN_ACCESS_GROUP").unwrap_or("XXXXXXXXXX.com.example.plc-touch") 40} 41 42/// A Secure Enclave key with metadata. 43#[derive(Debug, Clone)] 44pub struct EnclaveKey { 45 pub label: String, 46 pub did_key: String, 47 pub syncable: bool, 48 pub public_key_bytes: Vec<u8>, // uncompressed X9.63 49} 50 51/// Generate a new P-256 key. 52/// When syncable is true, generates a software key that syncs via iCloud Keychain. 53/// When false, generates a hardware-backed Secure Enclave key (device-only). 54/// Both are protected by Touch ID via access control. 55pub fn generate_key(label: &str, syncable: bool) -> Result<EnclaveKey> { 56 let tag = format!("{}{}", TAG_PREFIX, label); 57 58 unsafe { 59 // Create access control 60 let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 61 let protection = if syncable { 62 kSecAttrAccessibleWhenUnlocked 63 } else { 64 kSecAttrAccessibleWhenUnlockedThisDeviceOnly 65 }; 66 67 let flags: core_foundation_sys::base::CFOptionFlags = if syncable { 68 // Software key: biometry for signing 69 kSecAccessControlBiometryAny as _ 70 } else { 71 // SE key: biometry + private key usage 72 (kSecAccessControlBiometryAny | kSecAccessControlPrivateKeyUsage) as _ 73 }; 74 75 let access_control = SecAccessControlCreateWithFlags( 76 kCFAllocatorDefault, 77 protection as *const _, 78 flags, 79 &mut error, 80 ); 81 82 if access_control.is_null() { 83 let err_msg = if !error.is_null() { 84 let cf_error = core_foundation::error::CFError::wrap_under_create_rule(error); 85 format!("Access control error: {} (code: {})", cf_error.description(), cf_error.code()) 86 } else { 87 "Unknown access control error".to_string() 88 }; 89 bail!("{}", err_msg); 90 } 91 92 // Build private key attributes 93 let mut priv_pairs: Vec<(CFString, CFType)> = vec![ 94 ( 95 CFString::wrap_under_get_rule(kSecAttrIsPermanent), 96 CFBoolean::true_value().as_CFType(), 97 ), 98 ( 99 attr_application_tag(), 100 CFData::from_buffer(tag.as_bytes()).as_CFType(), 101 ), 102 ]; 103 104 // Only add access control for non-syncable (SE) keys. 105 // Syncable software keys can't have biometric access control. 106 if !syncable { 107 priv_pairs.push(( 108 CFString::wrap_under_get_rule(kSecAttrAccessControl), 109 CFType::wrap_under_get_rule(access_control as *const _), 110 )); 111 } 112 113 let private_key_attrs = CFDictionary::from_CFType_pairs(&priv_pairs); 114 115 // Build key generation attributes 116 let mut attrs_pairs: Vec<(CFString, CFType)> = vec![ 117 ( 118 CFString::wrap_under_get_rule(kSecAttrKeyType), 119 CFType::wrap_under_get_rule(kSecAttrKeyTypeECSECPrimeRandom as *const _), 120 ), 121 ( 122 CFString::wrap_under_get_rule(kSecAttrKeySizeInBits), 123 CFNumber::from(256i32).as_CFType(), 124 ), 125 ( 126 CFString::wrap_under_get_rule(kSecPrivateKeyAttrs), 127 private_key_attrs.as_CFType(), 128 ), 129 ( 130 CFString::wrap_under_get_rule(kSecAttrLabel), 131 CFString::new(label).as_CFType(), 132 ), 133 ]; 134 135 // Only use Secure Enclave for device-only keys 136 if !syncable { 137 attrs_pairs.push(( 138 CFString::wrap_under_get_rule(kSecAttrTokenID), 139 CFType::wrap_under_get_rule(kSecAttrTokenIDSecureEnclave as *const _), 140 )); 141 } 142 143 if syncable { 144 attrs_pairs.push(( 145 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 146 CFBoolean::true_value().as_CFType(), 147 )); 148 // Explicit access group so the key is findable across devices 149 attrs_pairs.push(( 150 CFString::wrap_under_get_rule(kSecAttrAccessGroup), 151 CFString::new(keychain_access_group()).as_CFType(), 152 )); 153 } 154 155 let attrs = CFDictionary::from_CFType_pairs(&attrs_pairs); 156 157 let mut gen_error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 158 let private_key = SecKeyCreateRandomKey(attrs.as_concrete_TypeRef(), &mut gen_error); 159 160 CFRelease(access_control as *const _); 161 162 if private_key.is_null() { 163 let err_msg = if !gen_error.is_null() { 164 let cf_error = core_foundation::error::CFError::wrap_under_create_rule(gen_error); 165 format!("Secure Enclave error: {} (domain: {}, code: {})", 166 cf_error.description(), cf_error.domain(), cf_error.code()) 167 } else { 168 "Unknown Secure Enclave error".to_string() 169 }; 170 bail!("{}", err_msg); 171 } 172 173 // Get public key 174 let public_key = SecKeyCopyPublicKey(private_key); 175 176 if public_key.is_null() { 177 CFRelease(private_key as *const _); 178 bail!("Failed to extract public key"); 179 } 180 181 let mut export_error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 182 let pub_data = SecKeyCopyExternalRepresentation(public_key, &mut export_error); 183 CFRelease(public_key as *const _); 184 185 if pub_data.is_null() { 186 bail!("Failed to export public key"); 187 } 188 189 let cf_data = CFData::wrap_under_create_rule(pub_data); 190 let pub_bytes = cf_data.bytes().to_vec(); 191 192 // Verify the key was persisted by trying to find it 193 let verify_query = CFDictionary::from_CFType_pairs(&[ 194 ( 195 CFString::wrap_under_get_rule(kSecClass), 196 CFType::wrap_under_get_rule(kSecClassKey as *const _), 197 ), 198 ( 199 attr_application_tag(), 200 CFData::from_buffer(tag.as_bytes()).as_CFType(), 201 ), 202 ( 203 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 204 CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 205 ), 206 ]); 207 208 let mut verify_result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 209 let verify_status = security_framework_sys::keychain_item::SecItemCopyMatching( 210 verify_query.as_concrete_TypeRef(), 211 &mut verify_result, 212 ); 213 if !verify_result.is_null() { 214 CFRelease(verify_result); 215 } 216 217 if verify_status != errSecSuccess { 218 // Key was created in SE but not persisted to keychain. 219 // This usually means entitlements are missing. 220 CFRelease(private_key as *const _); 221 bail!( 222 "Key was generated in Secure Enclave but failed to persist to Keychain \ 223 (OSStatus {}). Check that the app has keychain-access-groups entitlement.", 224 verify_status 225 ); 226 } 227 228 CFRelease(private_key as *const _); 229 230 let did_key = crate::didkey::encode_p256_didkey(&pub_bytes)?; 231 232 Ok(EnclaveKey { 233 label: label.to_string(), 234 did_key, 235 syncable, 236 public_key_bytes: pub_bytes, 237 }) 238 } 239} 240 241/// List all plc-touch keys in the Keychain. 242/// Queries separately for SE keys and software keys to avoid touching other apps' items. 243pub fn list_keys() -> Result<Vec<EnclaveKey>> { 244 let mut all_keys = Vec::new(); 245 246 // Query SE keys (device-only) 247 all_keys.extend(query_keys_with_token(true)?); 248 // Query software keys (potentially synced) 249 all_keys.extend(query_keys_with_token(false)?); 250 251 Ok(all_keys) 252} 253 254fn query_keys_with_token(secure_enclave: bool) -> Result<Vec<EnclaveKey>> { 255 unsafe { 256 let mut query_pairs: Vec<(CFString, CFType)> = vec![ 257 ( 258 CFString::wrap_under_get_rule(kSecClass), 259 CFType::wrap_under_get_rule(kSecClassKey as *const _), 260 ), 261 ( 262 CFString::wrap_under_get_rule(kSecAttrKeyType), 263 CFType::wrap_under_get_rule(kSecAttrKeyTypeECSECPrimeRandom as *const _), 264 ), 265 ( 266 CFString::wrap_under_get_rule(kSecReturnAttributes), 267 CFBoolean::true_value().as_CFType(), 268 ), 269 ( 270 CFString::wrap_under_get_rule(kSecReturnRef), 271 CFBoolean::true_value().as_CFType(), 272 ), 273 ( 274 CFString::wrap_under_get_rule(kSecMatchLimit), 275 CFType::wrap_under_get_rule(kSecMatchLimitAll as *const _), 276 ), 277 ]; 278 279 if secure_enclave { 280 // SE keys: search all (sync and non-sync) 281 query_pairs.push(( 282 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 283 CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 284 )); 285 query_pairs.push(( 286 CFString::wrap_under_get_rule(kSecAttrTokenID), 287 CFType::wrap_under_get_rule(kSecAttrTokenIDSecureEnclave as *const _), 288 )); 289 } else { 290 // Software keys: only syncable ones (our software keys are always syncable) 291 query_pairs.push(( 292 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 293 CFBoolean::true_value().as_CFType(), 294 )); 295 } 296 297 let query = CFDictionary::from_CFType_pairs(&query_pairs); 298 299 let mut result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 300 let status = security_framework_sys::keychain_item::SecItemCopyMatching( 301 query.as_concrete_TypeRef(), 302 &mut result, 303 ); 304 305 if status == security_framework_sys::base::errSecItemNotFound || result.is_null() { 306 return Ok(vec![]); 307 } 308 309 if status != errSecSuccess { 310 bail!("Failed to query keychain: OSStatus {}", status); 311 } 312 313 let array = core_foundation::array::CFArray::<CFDictionary>::wrap_under_create_rule( 314 result as core_foundation_sys::array::CFArrayRef, 315 ); 316 317 let mut keys = Vec::new(); 318 let tag_key = attr_application_tag(); 319 320 for i in 0..array.len() { 321 let dict = &array.get(i).unwrap(); 322 323 // Check if the application tag matches our prefix 324 let app_tag = dict 325 .find(tag_key.as_concrete_TypeRef() as *const _) 326 .map(|v| { 327 let d = CFData::wrap_under_get_rule(*v as core_foundation_sys::data::CFDataRef); 328 d.bytes().to_vec() 329 }); 330 331 let tag_bytes = match app_tag { 332 Some(ref d) if d.starts_with(TAG_PREFIX.as_bytes()) => d, 333 _ => continue, 334 }; 335 336 // Extract label from the tag (strip prefix) 337 let label = String::from_utf8_lossy(&tag_bytes[TAG_PREFIX.len()..]).to_string(); 338 339 // Syncable is determined by which query found the key 340 let syncable = !secure_enclave; 341 342 // Get the key ref and extract public key 343 let key_ref = dict.find(kSecValueRef as *const _); 344 if let Some(key_ptr) = key_ref { 345 let private_key = *key_ptr as SecKeyRef; 346 let public_key = SecKeyCopyPublicKey(private_key); 347 348 if !public_key.is_null() { 349 let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 350 let pub_data = SecKeyCopyExternalRepresentation(public_key, &mut error); 351 CFRelease(public_key as *const _); 352 353 if !pub_data.is_null() { 354 let cf_data = CFData::wrap_under_create_rule(pub_data); 355 let pub_bytes = cf_data.bytes().to_vec(); 356 357 if let Ok(did_key) = crate::didkey::encode_p256_didkey(&pub_bytes) { 358 keys.push(EnclaveKey { 359 label, 360 did_key, 361 syncable, 362 public_key_bytes: pub_bytes, 363 }); 364 } 365 } 366 } 367 } 368 } 369 370 Ok(keys) 371 } 372} 373 374/// Delete a key by label. 375pub fn delete_key(label: &str) -> Result<()> { 376 let tag = format!("{}{}", TAG_PREFIX, label); 377 378 unsafe { 379 let query = CFDictionary::from_CFType_pairs(&[ 380 ( 381 CFString::wrap_under_get_rule(kSecClass), 382 CFType::wrap_under_get_rule(kSecClassKey as *const _), 383 ), 384 ( 385 attr_application_tag(), 386 CFData::from_buffer(tag.as_bytes()).as_CFType(), 387 ), 388 ( 389 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 390 CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 391 ), 392 ]); 393 394 let status = security_framework_sys::keychain_item::SecItemDelete( 395 query.as_concrete_TypeRef(), 396 ); 397 398 if status != errSecSuccess { 399 bail!("Failed to delete key '{}': OSStatus {}", label, status); 400 } 401 } 402 403 Ok(()) 404} 405 406/// Require biometric authentication (Touch ID / Face ID) via LAContext. 407/// Used for software keys that don't have hardware-enforced biometric access control. 408fn require_biometric_auth(reason: &str) -> Result<()> { 409 unsafe { 410 let class = objc_getClass(b"LAContext\0".as_ptr() as *const _); 411 if class.is_null() { 412 bail!("LAContext not available"); 413 } 414 415 let alloc_sel = sel_registerName(b"alloc\0".as_ptr() as *const _); 416 let init_sel = sel_registerName(b"init\0".as_ptr() as *const _); 417 418 let obj = objc_msgSend(class, alloc_sel); 419 let context = objc_msgSend(obj, init_sel); 420 if context.is_null() { 421 bail!("Failed to create LAContext"); 422 } 423 424 // Use a channel to wait for the async callback 425 let (tx, rx) = mpsc::channel::<std::result::Result<(), String>>(); 426 427 let reason_ns = core_foundation::string::CFString::new(reason); 428 429 // evaluatePolicy:localizedReason:reply: 430 // Policy 1 = LAPolicyDeviceOwnerAuthenticationWithBiometrics 431 let eval_sel = sel_registerName( 432 b"evaluatePolicy:localizedReason:reply:\0".as_ptr() as *const _, 433 ); 434 435 // Create a block for the callback 436 let tx_clone = tx.clone(); 437 let block = block::ConcreteBlock::new(move |success: bool, error: *mut std::ffi::c_void| { 438 if success { 439 let _ = tx_clone.send(Ok(())); 440 } else { 441 let _ = tx_clone.send(Err("Biometric authentication cancelled or failed".to_string())); 442 } 443 let _ = error; // suppress unused warning 444 }); 445 let block = block.copy(); 446 447 let _: *mut std::ffi::c_void = { 448 type EvalFn = unsafe extern "C" fn( 449 *mut std::ffi::c_void, 450 *mut std::ffi::c_void, 451 i64, 452 *const std::ffi::c_void, 453 *const std::ffi::c_void, 454 ) -> *mut std::ffi::c_void; 455 let f: EvalFn = std::mem::transmute(objc_msgSend as *const ()); 456 f( 457 context, 458 eval_sel, 459 1, // LAPolicyDeviceOwnerAuthenticationWithBiometrics 460 reason_ns.as_concrete_TypeRef() as *const _, 461 &*block as *const _ as *const std::ffi::c_void, 462 ) 463 }; 464 465 match rx.recv() { 466 Ok(Ok(())) => Ok(()), 467 Ok(Err(e)) => bail!("{}", e), 468 Err(_) => bail!("Biometric authentication timed out"), 469 } 470 } 471} 472 473/// Sign data using a key (triggers Touch ID). 474/// For SE keys, Touch ID is enforced by hardware. 475/// For software keys, Touch ID is enforced via LAContext before signing. 476/// Returns the raw DER-encoded ECDSA signature. 477pub fn sign_with_key(label: &str, data: &[u8], is_syncable: bool) -> Result<Vec<u8>> { 478 // For syncable (software) keys, require biometric auth first 479 if is_syncable { 480 require_biometric_auth("Authenticate to sign PLC operation")?; 481 } 482 483 let tag = format!("{}{}", TAG_PREFIX, label); 484 485 unsafe { 486 let query = CFDictionary::from_CFType_pairs(&[ 487 ( 488 CFString::wrap_under_get_rule(kSecClass), 489 CFType::wrap_under_get_rule(kSecClassKey as *const _), 490 ), 491 ( 492 attr_application_tag(), 493 CFData::from_buffer(tag.as_bytes()).as_CFType(), 494 ), 495 ( 496 CFString::wrap_under_get_rule(kSecReturnRef), 497 CFBoolean::true_value().as_CFType(), 498 ), 499 ( 500 CFString::wrap_under_get_rule(kSecAttrSynchronizable), 501 CFType::wrap_under_get_rule(kSecAttrSynchronizableAny as *const _), 502 ), 503 ]); 504 505 let mut result: core_foundation_sys::base::CFTypeRef = ptr::null_mut(); 506 let status = security_framework_sys::keychain_item::SecItemCopyMatching( 507 query.as_concrete_TypeRef(), 508 &mut result, 509 ); 510 511 if status != errSecSuccess || result.is_null() { 512 bail!("Key '{}' not found in Keychain", label); 513 } 514 515 let private_key = result as SecKeyRef; 516 let cf_data = CFData::from_buffer(data); 517 518 let mut error: core_foundation_sys::error::CFErrorRef = ptr::null_mut(); 519 let algorithm: SecKeyAlgorithm = Algorithm::ECDSASignatureMessageX962SHA256.into(); 520 521 let signature = SecKeyCreateSignature( 522 private_key, 523 algorithm, 524 cf_data.as_concrete_TypeRef(), 525 &mut error, 526 ); 527 528 CFRelease(private_key as *const _); 529 530 if signature.is_null() { 531 bail!("Touch ID authentication cancelled or signing failed"); 532 } 533 534 let sig_data = CFData::wrap_under_create_rule(signature); 535 Ok(sig_data.bytes().to_vec()) 536 } 537} 538 539/// Get the did:key for a public key in X9.63 uncompressed format. 540pub fn public_key_to_didkey(pub_bytes: &[u8]) -> Result<String> { 541 crate::didkey::encode_p256_didkey(pub_bytes) 542}