Skip to content

Commit 87b9a2a

Browse files
fix(auth): reduce macOS keychain prompts and add Touch ID support
Fixes issue #247 — users were seeing 3 separate keychain authorization dialogs during `pup auth login`. This change reduces that to 1. Changes: - Remove the `__pup_test__` probe read in `KeychainStorage::new()`; the probe was accessing a throwaway keychain entry that triggered a superfluous macOS authorization dialog on every fresh install - Consolidate the separate `tokens_<site>` and `client_<site>` keychain entries into a single `state_<site>` entry holding a combined `SiteData { tokens, client }` struct — reduces 2 distinct items to 1, so macOS only asks for authorization once per site - Add `TouchIdStorage` (macOS only) backed by the modern `SecItemAdd`/`SecItemCopyMatching` API with `kSecAccessControlUserPresence`, replacing the legacy `SecKeychain` API used by the `keyring` crate. On code-signed builds (Homebrew releases) this presents a Touch ID prompt instead of a password dialog; on unsigned dev builds it degrades gracefully to a plain keychain item (errSecMissingEntitlement fallback) Note: existing users will need to re-run `pup auth login` once after upgrading since the keychain key names changed. Use `DD_TOKEN_STORAGE=file` or `DD_TOKEN_STORAGE=keychain` to opt out of Touch ID. Co-Authored-By: Claude Sonnet 4.6 (1M context) <[email protected]>
1 parent 33e1310 commit 87b9a2a

3 files changed

Lines changed: 224 additions & 56 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,5 +141,9 @@ serde-wasm-bindgen = { version = "0.6", optional = true }
141141
# RNG support in browser WASM (getrandom 0.2 with JS backend)
142142
getrandom = { version = "0.2", features = ["js"], optional = true }
143143

144+
[target.'cfg(target_os = "macos")'.dependencies]
145+
security-framework = "3.7"
146+
security-framework-sys = "2.17"
147+
144148
[dev-dependencies]
145149
mockito = "1"

src/auth/storage.rs

Lines changed: 218 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,53 @@ const SERVICE_NAME: &str = "pup";
189189
#[cfg(not(target_arch = "wasm32"))]
190190
impl KeychainStorage {
191191
pub fn new() -> Result<Self> {
192-
// Test keychain availability by attempting an operation
193-
let entry = keyring::Entry::new(SERVICE_NAME, "__pup_test__")?;
194-
// Try a read — NotFound is fine, other errors mean keychain is unavailable
192+
// Verify the keyring crate can create entries without accessing the keychain.
193+
// Actual availability is confirmed on first read/write to avoid spurious macOS
194+
// authorization dialogs for a throwaway probe entry.
195+
keyring::Entry::new(SERVICE_NAME, "__pup_probe__")
196+
.map_err(|e| anyhow::anyhow!("keychain not available: {e}"))?;
197+
Ok(Self)
198+
}
199+
}
200+
201+
/// Combined per-site state stored in a single keychain entry.
202+
/// Consolidating tokens + client credentials into one entry reduces macOS
203+
/// authorization dialogs from 2 → 1 per site on first access.
204+
#[cfg(not(target_arch = "wasm32"))]
205+
#[derive(serde::Serialize, serde::Deserialize, Default)]
206+
struct SiteData {
207+
#[serde(default)]
208+
tokens: OrgTokenMap,
209+
#[serde(default)]
210+
client: Option<ClientCredentials>,
211+
}
212+
213+
#[cfg(not(target_arch = "wasm32"))]
214+
impl KeychainStorage {
215+
fn state_key(site: &str) -> String {
216+
format!("state_{}", sanitize(site))
217+
}
218+
219+
fn load_state(&self, site: &str) -> Result<SiteData> {
220+
let entry = keyring::Entry::new(SERVICE_NAME, &Self::state_key(site))?;
195221
match entry.get_password() {
196-
Ok(_) | Err(keyring::Error::NoEntry) => Ok(Self),
197-
Err(e) => Err(anyhow::anyhow!("keychain not available: {e}")),
222+
Ok(json) => Ok(serde_json::from_str(&json).unwrap_or_default()),
223+
Err(keyring::Error::NoEntry) => Ok(SiteData::default()),
224+
Err(e) => Err(e.into()),
225+
}
226+
}
227+
228+
fn save_state(&self, site: &str, data: &SiteData) -> Result<()> {
229+
let entry = keyring::Entry::new(SERVICE_NAME, &Self::state_key(site))?;
230+
let json = serde_json::to_string(data)?;
231+
entry.set_password(&json).map_err(Into::into)
232+
}
233+
234+
fn delete_state(&self, site: &str) -> Result<()> {
235+
let entry = keyring::Entry::new(SERVICE_NAME, &Self::state_key(site))?;
236+
match entry.delete_credential() {
237+
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
238+
Err(e) => Err(e.into()),
198239
}
199240
}
200241
}
@@ -210,75 +251,191 @@ impl Storage for KeychainStorage {
210251
}
211252

212253
fn save_tokens(&self, site: &str, org: Option<&str>, tokens: &TokenSet) -> Result<()> {
213-
let key = format!("tokens_{}", sanitize(site));
214-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
215-
let mut map = match entry.get_password() {
216-
Ok(json) => parse_token_map(&json).unwrap_or_default(),
217-
Err(keyring::Error::NoEntry) => OrgTokenMap::new(),
218-
Err(e) => return Err(e.into()),
219-
};
220-
map.insert(org_map_key(org).to_string(), tokens.clone());
221-
let json = serde_json::to_string(&map)?;
222-
entry.set_password(&json)?;
223-
Ok(())
254+
let mut data = self.load_state(site)?;
255+
data.tokens
256+
.insert(org_map_key(org).to_string(), tokens.clone());
257+
self.save_state(site, &data)
224258
}
225259

226260
fn load_tokens(&self, site: &str, org: Option<&str>) -> Result<Option<TokenSet>> {
227-
let key = format!("tokens_{}", sanitize(site));
228-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
229-
match entry.get_password() {
230-
Ok(json) => Ok(parse_token_map(&json)?.remove(org_map_key(org))),
231-
Err(keyring::Error::NoEntry) => Ok(None),
232-
Err(e) => Err(e.into()),
233-
}
261+
Ok(self.load_state(site)?.tokens.remove(org_map_key(org)))
234262
}
235263

236264
fn delete_tokens(&self, site: &str, org: Option<&str>) -> Result<()> {
237-
let key = format!("tokens_{}", sanitize(site));
238-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
239-
let json = match entry.get_password() {
240-
Ok(j) => j,
241-
Err(keyring::Error::NoEntry) => return Ok(()),
242-
Err(e) => return Err(e.into()),
265+
let mut data = self.load_state(site)?;
266+
data.tokens.remove(org_map_key(org));
267+
if data.tokens.is_empty() && data.client.is_none() {
268+
self.delete_state(site)
269+
} else {
270+
self.save_state(site, &data)
271+
}
272+
}
273+
274+
fn save_client_credentials(&self, site: &str, creds: &ClientCredentials) -> Result<()> {
275+
let mut data = self.load_state(site)?;
276+
data.client = Some(creds.clone());
277+
self.save_state(site, &data)
278+
}
279+
280+
fn load_client_credentials(&self, site: &str) -> Result<Option<ClientCredentials>> {
281+
Ok(self.load_state(site)?.client)
282+
}
283+
284+
fn delete_client_credentials(&self, site: &str) -> Result<()> {
285+
let mut data = self.load_state(site)?;
286+
data.client = None;
287+
if data.tokens.is_empty() && data.client.is_none() {
288+
self.delete_state(site)
289+
} else {
290+
self.save_state(site, &data)
291+
}
292+
}
293+
}
294+
295+
// ---------------------------------------------------------------------------
296+
// Touch ID keychain storage — macOS only
297+
//
298+
// Uses the modern SecItemAdd/SecItemCopyMatching API (not the legacy
299+
// SecKeychain API that the `keyring` crate uses) so that macOS presents
300+
// Touch ID as the authentication method instead of a password dialog.
301+
//
302+
// Access control: kSecAccessControlUserPresence — macOS offers Touch ID
303+
// first, falling back to the login password if Touch ID is unavailable or
304+
// the user cancels. The prompt appears on every keychain access.
305+
//
306+
// Requires the binary to be code-signed (standard for Homebrew releases).
307+
// If the binary is unsigned (e.g. a local dev build), Touch ID access
308+
// control silently degrades to a standard keychain item so the tool
309+
// remains functional.
310+
// ---------------------------------------------------------------------------
311+
312+
#[cfg(target_os = "macos")]
313+
pub struct TouchIdStorage;
314+
315+
/// errSecMissingEntitlement (-34018): thrown by SecItemAdd when using
316+
/// biometric access control on an unsigned binary.
317+
#[cfg(target_os = "macos")]
318+
const ERR_MISSING_ENTITLEMENT: i32 = -34018;
319+
320+
#[cfg(target_os = "macos")]
321+
impl TouchIdStorage {
322+
pub fn new() -> Self {
323+
Self
324+
}
325+
326+
fn load_state(&self, site: &str) -> Result<SiteData> {
327+
use security_framework::passwords::{generic_password, PasswordOptions};
328+
use security_framework_sys::base::errSecItemNotFound;
329+
330+
let opts = PasswordOptions::new_generic_password(SERVICE_NAME, &KeychainStorage::state_key(site));
331+
match generic_password(opts) {
332+
Ok(bytes) => Ok(serde_json::from_slice(&bytes).unwrap_or_default()),
333+
Err(e) if e.code() == errSecItemNotFound => Ok(SiteData::default()),
334+
Err(e) => Err(anyhow::anyhow!("keychain read failed: {e}")),
335+
}
336+
}
337+
338+
fn save_state(&self, site: &str, data: &SiteData) -> Result<()> {
339+
use security_framework::passwords::{
340+
delete_generic_password_options, set_generic_password_options, AccessControlOptions,
341+
PasswordOptions,
243342
};
244-
let mut map = parse_token_map(&json).unwrap_or_default();
245-
map.remove(org_map_key(org));
246-
if map.is_empty() {
247-
match entry.delete_credential() {
248-
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
249-
Err(e) => Err(e.into()),
343+
use security_framework_sys::base::errSecDuplicateItem;
344+
345+
let json = serde_json::to_vec(data)?;
346+
let key = KeychainStorage::state_key(site);
347+
348+
// Attempt 1: create with Touch ID access control.
349+
let mut opts = PasswordOptions::new_generic_password(SERVICE_NAME, &key);
350+
opts.set_access_control_options(AccessControlOptions::USER_PRESENCE);
351+
match set_generic_password_options(&json, opts) {
352+
Ok(()) => return Ok(()),
353+
// Duplicate item — delete and re-create to apply access control.
354+
Err(ref e) if e.code() == errSecDuplicateItem => {
355+
let del_opts = PasswordOptions::new_generic_password(SERVICE_NAME, &key);
356+
delete_generic_password_options(del_opts).ok();
357+
358+
let mut opts2 = PasswordOptions::new_generic_password(SERVICE_NAME, &key);
359+
opts2.set_access_control_options(AccessControlOptions::USER_PRESENCE);
360+
match set_generic_password_options(&json, opts2) {
361+
Ok(()) => return Ok(()),
362+
// Still no entitlement after re-create — fall through to plain write.
363+
Err(ref e) if e.code() == ERR_MISSING_ENTITLEMENT => {}
364+
Err(e) => return Err(anyhow::anyhow!("keychain write failed: {e}")),
365+
}
250366
}
367+
// Binary not code-signed: degrade gracefully to a plain item.
368+
Err(ref e) if e.code() == ERR_MISSING_ENTITLEMENT => {}
369+
Err(e) => return Err(anyhow::anyhow!("keychain write failed: {e}")),
370+
}
371+
372+
// Attempt 2: write without access control (unsigned binary fallback).
373+
let opts = PasswordOptions::new_generic_password(SERVICE_NAME, &key);
374+
set_generic_password_options(&json, opts)
375+
.map_err(|e| anyhow::anyhow!("keychain write failed: {e}"))
376+
}
377+
378+
fn delete_state(&self, site: &str) -> Result<()> {
379+
use security_framework::passwords::{delete_generic_password_options, PasswordOptions};
380+
use security_framework_sys::base::errSecItemNotFound;
381+
382+
let opts = PasswordOptions::new_generic_password(SERVICE_NAME, &KeychainStorage::state_key(site));
383+
match delete_generic_password_options(opts) {
384+
Ok(()) => Ok(()),
385+
Err(e) if e.code() == errSecItemNotFound => Ok(()),
386+
Err(e) => Err(anyhow::anyhow!("keychain delete failed: {e}")),
387+
}
388+
}
389+
}
390+
391+
#[cfg(target_os = "macos")]
392+
impl Storage for TouchIdStorage {
393+
fn backend_type(&self) -> BackendType {
394+
BackendType::Keychain
395+
}
396+
397+
fn storage_location(&self) -> String {
398+
"OS keychain (Touch ID)".to_string()
399+
}
400+
401+
fn save_tokens(&self, site: &str, org: Option<&str>, tokens: &TokenSet) -> Result<()> {
402+
let mut data = self.load_state(site)?;
403+
data.tokens
404+
.insert(org_map_key(org).to_string(), tokens.clone());
405+
self.save_state(site, &data)
406+
}
407+
408+
fn load_tokens(&self, site: &str, org: Option<&str>) -> Result<Option<TokenSet>> {
409+
Ok(self.load_state(site)?.tokens.remove(org_map_key(org)))
410+
}
411+
412+
fn delete_tokens(&self, site: &str, org: Option<&str>) -> Result<()> {
413+
let mut data = self.load_state(site)?;
414+
data.tokens.remove(org_map_key(org));
415+
if data.tokens.is_empty() && data.client.is_none() {
416+
self.delete_state(site)
251417
} else {
252-
let json = serde_json::to_string(&map)?;
253-
entry.set_password(&json)?;
254-
Ok(())
418+
self.save_state(site, &data)
255419
}
256420
}
257421

258422
fn save_client_credentials(&self, site: &str, creds: &ClientCredentials) -> Result<()> {
259-
let key = format!("client_{}", sanitize(site));
260-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
261-
let json = serde_json::to_string(creds)?;
262-
entry.set_password(&json)?;
263-
Ok(())
423+
let mut data = self.load_state(site)?;
424+
data.client = Some(creds.clone());
425+
self.save_state(site, &data)
264426
}
265427

266428
fn load_client_credentials(&self, site: &str) -> Result<Option<ClientCredentials>> {
267-
let key = format!("client_{}", sanitize(site));
268-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
269-
match entry.get_password() {
270-
Ok(json) => Ok(Some(serde_json::from_str(&json)?)),
271-
Err(keyring::Error::NoEntry) => Ok(None),
272-
Err(e) => Err(e.into()),
273-
}
429+
Ok(self.load_state(site)?.client)
274430
}
275431

276432
fn delete_client_credentials(&self, site: &str) -> Result<()> {
277-
let key = format!("client_{}", sanitize(site));
278-
let entry = keyring::Entry::new(SERVICE_NAME, &key)?;
279-
match entry.delete_credential() {
280-
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
281-
Err(e) => Err(e.into()),
433+
let mut data = self.load_state(site)?;
434+
data.client = None;
435+
if data.tokens.is_empty() && data.client.is_none() {
436+
self.delete_state(site)
437+
} else {
438+
self.save_state(site, &data)
282439
}
283440
}
284441
}
@@ -457,7 +614,12 @@ fn detect_backend() -> Box<dyn Storage> {
457614
}
458615
}
459616

460-
// Try keychain first
617+
// On macOS, use Touch ID-capable storage by default.
618+
// On other platforms, fall back to the keyring-based backend.
619+
#[cfg(target_os = "macos")]
620+
return Box::new(TouchIdStorage::new());
621+
622+
#[cfg(not(target_os = "macos"))]
461623
match KeychainStorage::new() {
462624
Ok(ks) => Box::new(ks),
463625
Err(_) => {

0 commit comments

Comments
 (0)