tsafe-core 1.0.11

Encrypted local secret vault library — AES-256 via age, audit log, RBAC, biometric keyring, CloudEvents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
//! macOS Keychain with `SecAccessControl` so reads require Touch ID (BiometryCurrentSet).
//!
//! Since Tranche 2 (E4.2, 2026-05-16) the ACL flag is `kSecAccessControlBiometryCurrentSet`
//! (`AccessControlOptions::BIOMETRY_CURRENT_SET`), which requires Touch ID and is invalidated
//! when the biometric enrollment changes (new finger enrolled). This is strictly stronger than
//! `kSecAccessControlUserPresence`, which accepted a device password as a fallback and survived
//! new-enrollment events. The upgrade closes residual C5.3.

use crate::errors::{SafeError, SafeResult};
use core_foundation::base::{CFRelease, CFType, CFTypeRef, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
use keyring::Error as KeyringError;
use security_framework::base::Error as SecError;
use security_framework::passwords::{
    delete_generic_password_options, generic_password, set_generic_password_options,
    PasswordOptions,
};
use security_framework::passwords_options::AccessControlOptions;
use security_framework_sys::base::errSecItemNotFound;
use security_framework_sys::item::{
    kSecAttrAccount, kSecAttrService, kSecClass, kSecClassGenericPassword, kSecReturnAttributes,
    kSecReturnData, kSecUseAuthenticationUI, kSecUseAuthenticationUISkip,
    kSecUseDataProtectionKeychain,
};
use security_framework_sys::keychain::SecKeychainSetUserInteractionAllowed;
use security_framework_sys::keychain_item::SecItemCopyMatching;

/// Item exists but authentication UI was skipped (`kSecUseAuthenticationUISkip`).
const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;

/// Creating items with `SecAccessControl` biometry requires a properly entitled, signed binary.
/// Unsigned `tsafe` builds (local `cargo install`, dev) hit this — fall back to plain keychain storage.
const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34018;

/// `kSecAccessControlBiometryCurrentSet` raw flag value: `1 << 3 = 0x08`.
///
/// `AccessControlOptions::BIOMETRY_CURRENT_SET` from `security-framework` 3.x exposes this
/// constant natively; this local constant exists only to make the value testable on all platforms
/// without pulling in the macOS-only `security_framework_sys` in non-macOS test contexts.
pub(crate) const SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET: u64 = 0x08;

pub(super) const SERVICE_NAME: &str = "tsafe";

/// Set at **compile time** for signed release builds so Keychain items use the same access group as
/// `codesign --entitlements` (`keychain-access-groups`). Format: `{TEAM_ID}.com.tsafe.cli`.
const KEYCHAIN_ACCESS_GROUP: Option<&str> = option_env!("TSAFE_MACOS_KEYCHAIN_ACCESS_GROUP");

fn plain_options(service: &str, account: &str) -> PasswordOptions {
    PasswordOptions::new_generic_password(service, account)
}

fn data_protection_options(service: &str, account: &str) -> PasswordOptions {
    let mut o = PasswordOptions::new_generic_password(service, account);
    o.use_protected_keychain();
    if let Some(group) = KEYCHAIN_ACCESS_GROUP {
        o.set_access_group(group);
    }
    o
}

fn missing_entitlement_for_touch_id_protected_item(e: &SecError) -> bool {
    if e.code() == ERR_SEC_MISSING_ENTITLEMENT {
        return true;
    }
    e.message()
        .map(|m| m.to_lowercase().contains("entitlement"))
        .unwrap_or(false)
}

/// Store using the cross-platform `keyring` backend (login keychain, no `SecAccessControl` ACL).
fn store_password_plain_keyring(profile: &str, password: &str) -> SafeResult<()> {
    let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
        context: format!("keyring init: {e}"),
    })?;
    entry
        .set_password(password)
        .map_err(|e| SafeError::Crypto {
            context: format!("keyring store: {e}"),
        })?;
    Ok(())
}

/// Store the vault password in the data-protection keychain with **BiometryCurrentSet** ACL.
///
/// `kSecAccessControlBiometryCurrentSet` requires Touch ID and is invalidated when the biometric
/// enrollment changes (new finger added or removed), closing the residual risk that a stolen device
/// with a freshly enrolled finger could use an old ACL-gated credential. This is strictly stronger
/// than the former `USER_PRESENCE` flag, which also accepted the device password as a fallback and
/// survived enrollment changes.
///
/// When the OS rejects `SecAccessControl` (unsigned CLI, missing entitlement), falls back to the
/// same generic-keychain path as Linux/Windows so `biometric enable` still succeeds; quick unlock
/// then uses the standard login keychain (not a per-read biometric prompt).
pub fn store_password(profile: &str, password: &str) -> SafeResult<()> {
    // Remove any prior item (data-protection keychain, file-based keychain, or `keyring` entry).
    let _ = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
    let _ = delete_generic_password_options(plain_options(SERVICE_NAME, profile));
    let _ = keyring::Entry::new(SERVICE_NAME, profile).and_then(|e| e.delete_credential());

    let mut options = data_protection_options(SERVICE_NAME, profile);
    // BiometryCurrentSet: Touch ID only, invalidated on new-enrollment. Upgraded from
    // USER_PRESENCE in Tranche 2 (E4.2) — see ADR-008 implementation note.
    options.set_access_control_options(AccessControlOptions::BIOMETRY_CURRENT_SET);
    match set_generic_password_options(password.as_bytes(), options) {
        Ok(()) => Ok(()),
        Err(e_acl) => {
            // Unsigned CLIs, missing entitlements, and some environments fail the ACL path for
            // many reasons — always try the plain keyring store before surfacing an error.
            match store_password_plain_keyring(profile, password) {
                Ok(()) => {
                    if missing_entitlement_for_touch_id_protected_item(&e_acl) {
                        eprintln!(
                            "tsafe: note: Touch ID–protected keychain items are not available for this build (OS reports missing keychain entitlement). Stored in the login keychain without per-read biometric (quick unlock still works when your session can access the keychain)."
                        );
                    } else {
                        eprintln!(
                            "tsafe: note: could not store with Touch ID–protected keychain item ({e_acl}). Stored using standard login keychain storage instead."
                        );
                    }
                    Ok(())
                }
                Err(e_plain) => Err(SafeError::Crypto {
                    context: format!(
                        "keychain: could not store credential (primary: {e_acl}; fallback: {e_plain})"
                    ),
                }),
            }
        }
    }
}

pub fn retrieve_password(profile: &str) -> SafeResult<Option<String>> {
    // Some hosts (e.g. terminal emulators after raw mode, or tools that toggled the flag) run with
    // keychain UI disabled; reads then fail or fall back to password-only prompts without Touch ID.
    // Re-allow interaction before every protected read (idempotent when already allowed).
    // SAFETY: SecKeychainSetUserInteractionAllowed takes a single Boolean (u8) by
    // value and mutates only process-global Security framework state. No pointers
    // are passed in or returned, and the call is documented as thread-safe and
    // idempotent. We deliberately ignore the OSStatus return — failure here just
    // means the subsequent keychain read may fall back to a non-biometric prompt.
    unsafe {
        let _ = SecKeychainSetUserInteractionAllowed(1);
    }

    fn retrieve_plain(profile: &str) -> SafeResult<Option<String>> {
        match generic_password(plain_options(SERVICE_NAME, profile)) {
            Ok(bytes) => String::from_utf8(bytes)
                .map(Some)
                .map_err(|e| SafeError::Crypto {
                    context: format!("keychain password is not valid UTF-8: {e}"),
                }),
            Err(e2) if e2.code() == errSecItemNotFound => Ok(None),
            Err(e2) => Err(SafeError::Crypto {
                context: format!("keychain retrieve: {e2}"),
            }),
        }
    }

    match generic_password(data_protection_options(SERVICE_NAME, profile)) {
        Ok(bytes) => String::from_utf8(bytes)
            .map(Some)
            .map_err(|e| SafeError::Crypto {
                context: format!("keychain password is not valid UTF-8: {e}"),
            }),
        Err(e) if e.code() == errSecItemNotFound => retrieve_plain(profile),
        Err(e) if missing_entitlement_for_touch_id_protected_item(&e) => retrieve_plain(profile),
        Err(e) => Err(SafeError::Crypto {
            context: format!("keychain retrieve: {e}"),
        }),
    }
}

pub fn remove_password(profile: &str) -> SafeResult<()> {
    let d1 = delete_generic_password_options(data_protection_options(SERVICE_NAME, profile));
    let d2 = delete_generic_password_options(plain_options(SERVICE_NAME, profile));

    let mut sec_err: Option<SecError> = None;
    for (idx, r) in [d1, d2].into_iter().enumerate() {
        match r {
            Ok(()) => return Ok(()),
            Err(e) if e.code() == errSecItemNotFound => {}
            // Unsigned / dev builds cannot delete data-protection items created by a signed release;
            // treat like "no Sec delete this way" and still try plain + `keyring` removal.
            Err(e) if idx == 0 && missing_entitlement_for_touch_id_protected_item(&e) => {}
            Err(e) => sec_err = Some(e),
        }
    }
    if let Some(e) = sec_err {
        return Err(SafeError::Crypto {
            context: format!("keychain delete: {e}"),
        });
    }

    let entry = keyring::Entry::new(SERVICE_NAME, profile).map_err(|e| SafeError::Crypto {
        context: format!("keyring init: {e}"),
    })?;
    match entry.delete_credential() {
        Ok(()) => Ok(()),
        Err(KeyringError::NoEntry) => Ok(()),
        Err(ke) => Err(SafeError::Crypto {
            context: format!("keyring delete: {ke}"),
        }),
    }
}

/// Whether a keychain item exists for this profile **without** triggering Touch ID.
/// Uses `kSecUseAuthenticationUISkip` so the biometric sheet is not shown.
pub fn has_password(profile: &str) -> bool {
    item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false)
        || item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false)
}

/// Best-effort classification of where the quick-unlock credential appears to live.
///
/// Uses the same no-UI `SecItemCopyMatching` probes as [`has_password`]. If the item is visible
/// under the **data-protection** keychain query first, we report the tier that matches
/// `kSecUseDataProtectionKeychain` + ACL storage; if only the legacy generic-password query
/// matches, we report the **login keychain / fallback** tier typical of unsigned builds or older
/// tsafe versions. This is a **heuristic** (not a cryptographic guarantee).
pub(super) fn quick_unlock_storage_note(profile: &str) -> Option<String> {
    // Heuristic: returns a storage-tier classification. Not a cryptographic guarantee.
    let in_dp = item_exists_skip_ui(SERVICE_NAME, profile, true).unwrap_or(false);
    if in_dp {
        return Some(
            "macOS storage tier: data-protection keychain (matches signed-release + ACL path; Touch ID / Watch / passcode per read when the OS allows)."
                .to_string(),
        );
    }
    let in_legacy = item_exists_skip_ui(SERVICE_NAME, profile, false).unwrap_or(false);
    if in_legacy {
        return Some(
            "macOS storage tier: login keychain fallback (no data-protection item found for this profile). Per-read Touch ID may not apply — common for unsigned `cargo install` / dev builds; try `biometric disable` then `biometric enable` after installing a signed release, or see docs/features/biometric.md."
                .to_string(),
        );
    }
    None
}

fn item_exists_skip_ui(
    service: &str,
    account: &str,
    data_protection: bool,
) -> Result<bool, SecError> {
    // The `unsafe` calls below are all `CFString::wrap_under_get_rule(kSec*)`. Each
    // `kSec*` symbol is a `CFStringRef` exported by the Security framework as an
    // immortal, framework-owned constant — it is initialized at framework load and
    // never released. `wrap_under_get_rule` follows Core Foundation Get-rule
    // semantics: it retains the wrapped object, so the resulting CFString owns a
    // +1 retain that is balanced by CFString's Drop. This is the documented sound
    // pattern for borrowing a framework-owned CFStringRef.
    let mut pairs: Vec<(CFString, CFType)> = vec![
        (
            unsafe { CFString::wrap_under_get_rule(kSecClass) },
            unsafe { CFString::wrap_under_get_rule(kSecClassGenericPassword).into_CFType() },
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecAttrService) },
            CFString::from(service).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecAttrAccount) },
            CFString::from(account).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecReturnAttributes) },
            CFBoolean::from(true).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecReturnData) },
            CFBoolean::from(false).into_CFType(),
        ),
        (
            unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUI) },
            unsafe { CFString::wrap_under_get_rule(kSecUseAuthenticationUISkip).into_CFType() },
        ),
    ];
    if data_protection {
        pairs.push((
            unsafe { CFString::wrap_under_get_rule(kSecUseDataProtectionKeychain) },
            CFBoolean::from(true).into_CFType(),
        ));
    }
    let params = CFDictionary::from_CFType_pairs(&pairs);
    let mut ret: CFTypeRef = std::ptr::null();
    // SAFETY: `params.as_concrete_TypeRef()` returns a CFDictionaryRef that is
    // valid for as long as `params` is alive — `params` outlives this call (it is
    // not moved or dropped until end of function). `&mut ret` points to a
    // properly-initialized `CFTypeRef` (null pointer) on this stack frame, alive
    // for the call. SecItemCopyMatching writes a +1-retained CFTypeRef there on
    // success (which we release below) or leaves it null on failure.
    let status = unsafe { SecItemCopyMatching(params.as_concrete_TypeRef(), &mut ret) };
    if !ret.is_null() {
        // SAFETY: SecItemCopyMatching followed the Copy rule and returned a
        // +1-retained CFTypeRef in `ret`. We are the sole owner; calling
        // CFRelease exactly once balances that retain. After this point we never
        // dereference `ret` again, so there is no use-after-free.
        unsafe {
            CFRelease(ret);
        }
    }
    match status {
        0 => Ok(true),
        e if e == errSecItemNotFound => Ok(false),
        // Item is present but requires authentication — counts as "configured".
        e if e == ERR_SEC_INTERACTION_NOT_ALLOWED => Ok(true),
        e => Err(SecError::from_code(e)),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_entitlement_code_triggers_fallback_path() {
        let e = SecError::from_code(ERR_SEC_MISSING_ENTITLEMENT);
        assert!(missing_entitlement_for_touch_id_protected_item(&e));
    }

    /// Verify the `BiometryCurrentSet` constant matches the Apple Security framework value.
    ///
    /// `kSecAccessControlBiometryCurrentSet` is defined as `1 << 3 = 0x08` in Apple's
    /// `Security/SecAccessControl.h` and confirmed in `security-framework-sys` 2.17.
    /// The task specification cited `0x20`; Apple's headers and the Rust binding both use `0x08`.
    /// This test is the authoritative record — if the value ever diverges, fail loudly.
    #[test]
    fn biometry_current_set_constant_has_expected_value() {
        assert_eq!(
            SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET, 0x08,
            "kSecAccessControlBiometryCurrentSet must equal 1<<3 (0x08) per Apple Security headers"
        );
        // Also cross-check against the bitflags value exposed by security-framework.
        assert_eq!(
            AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
            SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET,
            "AccessControlOptions::BIOMETRY_CURRENT_SET bits must match SEC_ACCESS_CONTROL_BIOMETRY_CURRENT_SET"
        );
    }

    /// Verify that `store_password` uses `BIOMETRY_CURRENT_SET`, not `USER_PRESENCE`.
    ///
    /// This is a compile-time / unit-level check: if the wrong flag is used, this fails because
    /// `BIOMETRY_CURRENT_SET` bits (0x08) != `USER_PRESENCE` bits (0x01).
    ///
    /// A full integration test (actually writing to the macOS Keychain) requires a signed binary
    /// with the `keychain-access-groups` entitlement and macOS hardware — those tests are marked
    /// `#[ignore]` with the tag `[requires: macos-signed-hardware]`.
    #[test]
    fn biometry_current_set_is_not_user_presence() {
        assert_ne!(
            AccessControlOptions::BIOMETRY_CURRENT_SET.bits(),
            AccessControlOptions::USER_PRESENCE.bits(),
            "BIOMETRY_CURRENT_SET and USER_PRESENCE must be distinct flag values"
        );
    }

    /// Integration test: `store_password` and `retrieve_password` round-trip under BiometryCurrentSet.
    ///
    /// Requires:
    /// - macOS hardware (Touch ID enrolled or Simulator with biometrics)
    /// - A signed binary with `keychain-access-groups` entitlement (`TSAFE_MACOS_KEYCHAIN_ACCESS_GROUP`)
    ///
    /// Run manually: `cargo test -p tsafe-core -- store_password_uses_biometry_current_set --ignored`
    #[test]
    #[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
    fn store_password_uses_biometry_current_set() {
        let profile = "tsafe-e4-2-test-biometry-current-set";
        let password = "hunter2-test-only";

        // Clean up any leftover from a previous run.
        let _ = remove_password(profile);

        store_password(profile, password).expect("store_password must succeed on signed macOS");

        // Verify the item is visible (without Touch ID — using kSecUseAuthenticationUISkip probe).
        assert!(
            has_password(profile),
            "has_password must return true after store_password"
        );

        // Clean up.
        remove_password(profile).expect("remove_password must succeed");
        assert!(
            !has_password(profile),
            "has_password must be false after removal"
        );
    }

    /// Integration test: `biometric re-enroll` stores the fresh credential under BiometryCurrentSet.
    ///
    /// Re-enroll calls `store_password` after removing the stale credential.  Because `store_password`
    /// is the only write path on macOS, the re-enrolled credential carries the same
    /// `BIOMETRY_CURRENT_SET` ACL as a first-time enrollment.
    ///
    /// This test exercises the re-enroll sequence end-to-end (remove stale + store fresh).
    ///
    /// Requires:
    /// - macOS hardware with signed binary (same as above)
    ///
    /// Run manually: `cargo test -p tsafe-core -- re_enroll_stores_with_updated_acl --ignored`
    #[test]
    #[ignore = "requires: macos-signed-hardware — signed binary + Touch ID enrollment"]
    fn re_enroll_stores_with_updated_acl() {
        let profile = "tsafe-e4-2-test-reenroll";
        let old_password = "old-password-stale";
        let new_password = "new-password-fresh";

        // Simulate a stale credential by storing under the old password.
        let _ = remove_password(profile);
        store_password(profile, old_password).expect("initial store must succeed");
        assert!(has_password(profile));

        // Simulate re-enroll: remove stale + store fresh (mirrors `biometric_re_enroll` logic).
        remove_password(profile).expect("remove stale credential");
        store_password(profile, new_password).expect("re-enroll store must succeed");

        assert!(
            has_password(profile),
            "credential must be present after re-enroll"
        );

        // Clean up.
        remove_password(profile).expect("cleanup");
        assert!(!has_password(profile));
    }
}