11
11
//===----------------------------------------------------------------------===//
12
12
13
13
import struct Foundation. Data
14
+ import struct Foundation. Date
14
15
import struct Foundation. URL
15
16
#if canImport(Security)
16
17
import Security
@@ -194,7 +195,7 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
194
195
}
195
196
196
197
self . observabilityScope
197
- . emit ( debug: " Add /update credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] in keychain " )
198
+ . emit ( debug: " add /update credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] in keychain " )
198
199
199
200
if !persist {
200
201
self . cache [ protocolHostPort. description] = ( user, password)
@@ -221,7 +222,7 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
221
222
}
222
223
223
224
self . observabilityScope
224
- . emit ( debug: " Remove credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] from keychain " )
225
+ . emit ( debug: " remove credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] from keychain " )
225
226
226
227
do {
227
228
try self . delete ( protocolHostPort: protocolHostPort)
@@ -241,28 +242,66 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
241
242
}
242
243
243
244
self . observabilityScope
244
- . emit ( debug: " Search credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] in keychain " )
245
+ . emit ( debug: " search credentials for '\( protocolHostPort) ' [ \( url. absoluteString) ] in keychain " )
245
246
246
247
do {
247
- guard let existingItem = try self
248
- . search ( protocolHostPort: protocolHostPort) as? [ String : Any ] ,
249
- let passwordData = existingItem [ kSecValueData as String ] as? Data ,
250
- let password = String ( data: passwordData, encoding: String . Encoding. utf8) ,
251
- let account = existingItem [ kSecAttrAccount as String ] as? String
248
+ guard let existingItems = try self . search ( protocolHostPort: protocolHostPort) as? [ [ String : Any ] ] else {
249
+ throw AuthorizationProviderError
250
+ . other ( " Failed to extract credentials for ' \( protocolHostPort) ' from keychain " )
251
+ }
252
+
253
+ // Log warning if there is more than one result
254
+ if existingItems. count > 1 {
255
+ self . observabilityScope
256
+ . emit (
257
+ warning: " multiple ( \( existingItems. count) ) keychain entries found for ' \( protocolHostPort) ' [ \( url. absoluteString) ] "
258
+ )
259
+ }
260
+
261
+ // Sort by modification timestamp
262
+ let sortedItems = existingItems. sorted {
263
+ switch (
264
+ $0 [ kSecAttrModificationDate as String ] as? Date ,
265
+ $1 [ kSecAttrModificationDate as String ] as? Date
266
+ ) {
267
+ case ( nil , nil ) :
268
+ return false
269
+ case ( _, nil ) :
270
+ return true
271
+ case ( nil , _) :
272
+ return false
273
+ case ( . some( let left) , . some( let right) ) :
274
+ return left < right
275
+ }
276
+ }
277
+
278
+ // Return most recently modified item
279
+ guard let mostRecent = sortedItems. last,
280
+ let created = mostRecent [ kSecAttrCreationDate as String ] as? Date ,
281
+ // Get password for this specific item
282
+ let existingItem = try self . get (
283
+ protocolHostPort: protocolHostPort,
284
+ created: created,
285
+ modified: mostRecent [ kSecAttrModificationDate as String ] as? Date
286
+ ) as? [ String : Any ] ,
287
+ let passwordData = existingItem [ kSecValueData as String ] as? Data ,
288
+ let password = String ( data: passwordData, encoding: String . Encoding. utf8) ,
289
+ let account = existingItem [ kSecAttrAccount as String ] as? String
252
290
else {
253
291
throw AuthorizationProviderError
254
292
. other ( " Failed to extract credentials for ' \( protocolHostPort) ' from keychain " )
255
293
}
294
+
256
295
return ( user: account, password: password)
257
296
} catch {
258
297
switch error {
259
298
case AuthorizationProviderError . notFound:
260
- self . observabilityScope. emit ( debug: " No credentials found for '\( protocolHostPort) ' in keychain " )
299
+ self . observabilityScope. emit ( debug: " no credentials found for '\( protocolHostPort) ' in keychain " )
261
300
case AuthorizationProviderError . other( let detail) :
262
301
self . observabilityScope. emit ( error: detail)
263
302
default :
264
303
self . observabilityScope. emit (
265
- error: " Failed to find credentials for '\( protocolHostPort) ' in keychain " ,
304
+ error: " failed to find credentials for '\( protocolHostPort) ' in keychain " ,
266
305
underlyingError: error
267
306
)
268
307
}
@@ -331,13 +370,47 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
331
370
var query : [ String : Any ] = [ kSecClass as String : kSecClassInternetPassword,
332
371
kSecAttrProtocol as String : protocolHostPort. protocolCFString,
333
372
kSecAttrServer as String : protocolHostPort. server,
334
- kSecMatchLimit as String : kSecMatchLimitOne,
373
+ kSecMatchLimit as String : kSecMatchLimitAll, // returns all matches
374
+ kSecReturnAttributes as String : true ]
375
+ // https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items
376
+ // Can't combine `kSecMatchLimitAll` and `kSecReturnData` (which contains password)
377
+
378
+ if let port = protocolHostPort. port {
379
+ query [ kSecAttrPort as String ] = port
380
+ }
381
+
382
+ var items : CFTypeRef ?
383
+ // Search keychain for server's username and password, if any.
384
+ let status = SecItemCopyMatching ( query as CFDictionary , & items)
385
+ guard status != errSecItemNotFound else {
386
+ throw AuthorizationProviderError . notFound
387
+ }
388
+ guard status == errSecSuccess else {
389
+ throw AuthorizationProviderError
390
+ . other ( " Failed to find credentials for ' \( protocolHostPort) ' in keychain: status \( status) " )
391
+ }
392
+
393
+ return items
394
+ }
395
+
396
+ private func get( protocolHostPort: ProtocolHostPort , created: Date , modified: Date ? ) throws -> CFTypeRef ? {
397
+ self . observabilityScope
398
+ . emit ( debug: " read credentials for ' \( protocolHostPort) ', created at \( created) , in keychain " )
399
+
400
+ var query : [ String : Any ] = [ kSecClass as String : kSecClassInternetPassword,
401
+ kSecAttrProtocol as String : protocolHostPort. protocolCFString,
402
+ kSecAttrServer as String : protocolHostPort. server,
403
+ kSecAttrCreationDate as String : created,
404
+ kSecMatchLimit as String : kSecMatchLimitOne, // limit to one match
335
405
kSecReturnAttributes as String : true ,
336
- kSecReturnData as String : true ]
406
+ kSecReturnData as String : true ] // password
337
407
338
408
if let port = protocolHostPort. port {
339
409
query [ kSecAttrPort as String ] = port
340
410
}
411
+ if let modified {
412
+ query [ kSecAttrModificationDate as String ] = modified
413
+ }
341
414
342
415
var item : CFTypeRef ?
343
416
// Search keychain for server's username and password, if any.
@@ -414,13 +487,13 @@ public struct CompositeAuthorizationProvider: AuthorizationProvider {
414
487
if let authentication = provider. authentication ( for: url) {
415
488
switch provider {
416
489
case let provider as NetrcAuthorizationProvider :
417
- self . observabilityScope. emit ( info: " Credentials for \( url) found in netrc file at \( provider. path) " )
490
+ self . observabilityScope. emit ( info: " credentials for \( url) found in netrc file at \( provider. path) " )
418
491
#if canImport(Security)
419
492
case is KeychainAuthorizationProvider :
420
- self . observabilityScope. emit ( info: " Credentials for \( url) found in keychain " )
493
+ self . observabilityScope. emit ( info: " credentials for \( url) found in keychain " )
421
494
#endif
422
495
default :
423
- self . observabilityScope. emit ( info: " Credentials for \( url) found in \( provider) " )
496
+ self . observabilityScope. emit ( info: " credentials for \( url) found in \( provider) " )
424
497
}
425
498
return authentication
426
499
}
0 commit comments