Skip to content

Commit 2054492

Browse files
authored
Keychain search to return all matches (#6748)
Motivation: A registry URL (i.e., same scheme, host, and port) can have multiple entries in Keychain. Currently we only ask Keychain to return one match when doing a search, but that might not be the one we want. Modifications: - Have Keychain search return all matches - Sort the matches by modification timestamp - Return the most recently modified match rdar://112556752
1 parent a2d4b44 commit 2054492

File tree

1 file changed

+88
-15
lines changed

1 file changed

+88
-15
lines changed

Sources/Basics/AuthorizationProvider.swift

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import struct Foundation.Data
14+
import struct Foundation.Date
1415
import struct Foundation.URL
1516
#if canImport(Security)
1617
import Security
@@ -194,7 +195,7 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
194195
}
195196

196197
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")
198199

199200
if !persist {
200201
self.cache[protocolHostPort.description] = (user, password)
@@ -221,7 +222,7 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
221222
}
222223

223224
self.observabilityScope
224-
.emit(debug: "Remove credentials for '\(protocolHostPort)' [\(url.absoluteString)] from keychain")
225+
.emit(debug: "remove credentials for '\(protocolHostPort)' [\(url.absoluteString)] from keychain")
225226

226227
do {
227228
try self.delete(protocolHostPort: protocolHostPort)
@@ -241,28 +242,66 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
241242
}
242243

243244
self.observabilityScope
244-
.emit(debug: "Search credentials for '\(protocolHostPort)' [\(url.absoluteString)] in keychain")
245+
.emit(debug: "search credentials for '\(protocolHostPort)' [\(url.absoluteString)] in keychain")
245246

246247
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
252290
else {
253291
throw AuthorizationProviderError
254292
.other("Failed to extract credentials for '\(protocolHostPort)' from keychain")
255293
}
294+
256295
return (user: account, password: password)
257296
} catch {
258297
switch error {
259298
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")
261300
case AuthorizationProviderError.other(let detail):
262301
self.observabilityScope.emit(error: detail)
263302
default:
264303
self.observabilityScope.emit(
265-
error: "Failed to find credentials for '\(protocolHostPort)' in keychain",
304+
error: "failed to find credentials for '\(protocolHostPort)' in keychain",
266305
underlyingError: error
267306
)
268307
}
@@ -331,13 +370,47 @@ public class KeychainAuthorizationProvider: AuthorizationProvider, Authorization
331370
var query: [String: Any] = [kSecClass as String: kSecClassInternetPassword,
332371
kSecAttrProtocol as String: protocolHostPort.protocolCFString,
333372
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
335405
kSecReturnAttributes as String: true,
336-
kSecReturnData as String: true]
406+
kSecReturnData as String: true] // password
337407

338408
if let port = protocolHostPort.port {
339409
query[kSecAttrPort as String] = port
340410
}
411+
if let modified {
412+
query[kSecAttrModificationDate as String] = modified
413+
}
341414

342415
var item: CFTypeRef?
343416
// Search keychain for server's username and password, if any.
@@ -414,13 +487,13 @@ public struct CompositeAuthorizationProvider: AuthorizationProvider {
414487
if let authentication = provider.authentication(for: url) {
415488
switch provider {
416489
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)")
418491
#if canImport(Security)
419492
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")
421494
#endif
422495
default:
423-
self.observabilityScope.emit(info: "Credentials for \(url) found in \(provider)")
496+
self.observabilityScope.emit(info: "credentials for \(url) found in \(provider)")
424497
}
425498
return authentication
426499
}

0 commit comments

Comments
 (0)