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
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}