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
//! OS credential store for vault passwords (biometric / keyring unlock).
//!
//! On **macOS**, passwords are stored with
//! [`SecAccessControl`](https://developer.apple.com/documentation/security/secaccesscontrol)
//! **BiometryCurrentSet** (`kSecAccessControlBiometryCurrentSet`) constraints so **Touch ID** is
//! required when reading the secret and the credential is invalidated when the biometric enrollment
//! changes (new finger enrolled). This is strictly stronger than the former `USER_PRESENCE` flag
//! (Tranche 2 upgrade, E4.2, 2026-05-16 — see ADR-008 implementation note).
//!
//! If the OS returns a **missing entitlement** error (typical for unsigned `cargo install` / dev
//! binaries), [`store_password`] falls back to the same [`keyring`](https://docs.rs/keyring)-based
//! login keychain storage as other platforms so setup commands still succeed.
//!
//! On other platforms, behavior is unchanged ([`keyring`](https://docs.rs/keyring)).
//!
//! **macOS:** [`quick_unlock_storage_note`] classifies
//! whether the credential appears to live in the data-protection keychain vs the legacy login-keychain
//! path (for `doctor` / `biometric status` UX).
use generic as imp;
use macos as imp;
/// Store the vault password in the OS credential store.
/// Retrieve the vault password from the OS credential store.
/// Returns `None` if no entry exists.
/// Remove the vault password from the OS credential store.
/// Check if a keyring entry exists for this profile.
///
/// Cost varies by platform — callers should treat this as **rare-path only**, not hot-loop:
///
/// - **macOS** — cheap probe via `kSecUseAuthenticationUISkip`; will not prompt for Touch ID
/// or any biometric UI even when the entry has a user-presence ACL. Safe to call freely.
/// - **Windows / Linux** — performs a full `keyring::Entry::get_password()` round-trip and
/// discards the bytes. No prompt fires on Windows Credential Manager, and Linux
/// Secret Service typically prompts only once at session start to unlock the keyring
/// (not per-read). Treat as a real OS round-trip — appropriate for `tsafe doctor`,
/// `tsafe biometric status`, and onboarding guards, but **not** appropriate for
/// render loops or hot paths.
///
/// Pairing this with [`retrieve_password`] back-to-back is wasteful — `retrieve_password`
/// already returns `Ok(None)` for missing entries. Call only one of the two in a single
/// code path. See `docs/keyring-audit.md` for the call-site survey.
/// Extra context for CLI (`biometric status`, `doctor`) about **macOS** quick-unlock storage tier.
///
/// On non-macOS, always returns `None`. On macOS, returns `None` when quick unlock is not
/// configured.
/// Classify a keyring/Crypto error as a stale-credential signal.
///
/// A "stale credential" means the credential store entry exists but the password
/// it contains no longer opens the vault — typically because the vault password
/// was rotated or (on macOS) a new fingerprint was enrolled after `biometric enable`.
///
/// This helper checks the *error message text* returned from a failed vault open
/// that used the retrieved keyring password. It does **not** inspect the keyring
/// read itself (the read may succeed; the stale credential is only discovered when
/// the returned password fails decryption and the decrypt error text indicates a
/// credential mismatch rather than a corrupted vault).
///
/// Current detection heuristics (conservative — may miss platform-specific variants):
/// - `"wrong password"` — from `SafeError::DecryptionFailed` display
/// - `"decryption failed"` — same
/// - `"credential"` + `"changed"` or `"stale"` — OS-level messages that may
/// surface on some keychain/libsecret backends
///
/// Callers that use `retrieve_password` followed by `Vault::open` and see
/// `SafeError::DecryptionFailed` SHOULD call this to decide whether to emit a
/// `StaleBiometricCredential` error rather than a generic decryption failure.
/// Internal account used only to verify the OS credential store accepts writes and deletes.
const PROBE_ACCOUNT: &str = "tsafekeyringprobe";
/// Returns `Ok(())` if the OS credential store can store and remove a credential.
///
/// Use this before offering interactive “quick unlock” setup (e.g. after `tsafe init`). This does
/// not prompt for biometrics. Fails on headless/SSH sessions or when no credential backend exists.
/// Account suffix used to store the `tsafe attest run` Ed25519 signing key
/// (Phase 5 of the algol→tsafe merge).
///
/// Keys live under `<profile>:<ATTEST_SIGNING_KEY_ACCOUNT>` in the OS
/// credential store so the existing per-profile keyring patterns
/// continue to apply (the platform-specific backends already namespace
/// entries by the profile string).
pub const ATTEST_SIGNING_KEY_ACCOUNT: &str = "tsafe-attest-signing-key";
/// Store the base64url-encoded Ed25519 signing-key bytes for the
/// `tsafe-attest` purpose under the given profile.
///
/// Phase 5 reuses the existing password-shaped credential slot rather
/// than introducing a new entry kind (per the design constraint —
/// "No new keyring entry kind unless required" — recorded in
/// `ecosystem-catalog/portfolio-algol-tsafe-roadmap-to-code-complete-2026-05-21.md`
/// §5.1).
/// Retrieve the base64url-encoded Ed25519 signing-key bytes for the
/// `tsafe-attest` purpose under the given profile, or `None` if no key
/// is currently stored.
/// Remove the Ed25519 signing-key for the `tsafe-attest` purpose under
/// the given profile. No-op if no entry is present.
/// Returns true when an attest signing key is already provisioned for
/// this profile. Hot-path-safe per [`has_password`]'s contract.