@@ -189,12 +189,53 @@ const SERVICE_NAME: &str = "pup";
189189#[ cfg( not( target_arch = "wasm32" ) ) ]
190190impl 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