Skip to content

Commit 947832e

Browse files
fix: filter Keychain items to include only those with AfterFirstUnlock or AfterFirstUnlockThisDeviceOnly accessibility.
1 parent 62e4525 commit 947832e

File tree

9 files changed

+151
-38
lines changed

9 files changed

+151
-38
lines changed

TwilioVerifyDemo/TwilioVerifyDemo/Common/AppDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
8686
return
8787
}
8888

89-
showChallenge(payload: payload)
89+
handleChallengeApproval(with: payload)
9090
}
9191
}
9292

TwilioVerifySDK/TwilioSecurity/Sources/Keychain/Keychain.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ protocol KeychainProtocol {
2626
func representation(forKey key: SecKey) throws -> Data
2727
func generateKeyPair(withParameters parameters: [String: Any], allowIphoneMigration: Bool) throws -> KeyPair
2828
func copyItemMatching(query: Query) throws -> AnyObject
29+
func copyItemsMatching(queries: [Query]) throws -> [AnyObject]
2930
func addItem(withQuery query: Query) -> OSStatus
3031
func updateItem(withQuery query: Query, attributes: CFDictionary) -> OSStatus
3132
@discardableResult func deleteItem(withQuery query: Query) -> OSStatus
@@ -170,7 +171,35 @@ class Keychain: KeychainProtocol {
170171

171172
return result
172173
}
173-
174+
175+
func copyItemsMatching(queries: [Query]) throws -> [AnyObject] {
176+
var allResults: [AnyObject] = []
177+
178+
for query in queries {
179+
var result: AnyObject?
180+
181+
let status: OSStatus = retry {
182+
SecItemCopyMatching(query as CFDictionary, &result)
183+
} validation: { status in
184+
status == errSecSuccess
185+
}
186+
187+
if status == errSecSuccess, let result = result {
188+
if let resultArray = result as? [AnyObject] {
189+
allResults.append(contentsOf: resultArray)
190+
} else {
191+
allResults.append(result)
192+
}
193+
} else if status != errSecItemNotFound {
194+
let error: KeychainError = .invalidStatusCode(code: Int(status))
195+
Logger.shared.log(withLevel: .error, message: error.localizedDescription)
196+
throw error
197+
}
198+
}
199+
200+
return allResults
201+
}
202+
174203
func addItem(withQuery query: Query) -> OSStatus {
175204
deleteItem(withQuery: query)
176205
Logger.shared.log(withLevel: .debug, message: "Added item for \(query)")

TwilioVerifySDK/TwilioSecurity/Sources/Keychain/KeychainQuery.swift

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ protocol KeychainQueryProtocol {
4141
func deleteKey(withAlias alias: String) -> Query
4242
func save(data: Data, withKey key: String, withServiceName service: String?, allowIphoneMigration: Bool) -> Query
4343
func getData(withKey key: String) -> Query
44-
func getAll(withServiceName service: String?) -> Query
44+
func getAll(withServiceName service: String?) -> [Query]
4545
func delete(withKey key: String) -> Query
4646
func deleteItems(withServiceName service: String?) -> Query
4747
}
@@ -107,15 +107,31 @@ struct KeychainQuery: KeychainQueryProtocol {
107107
])
108108
}
109109

110-
func getAll(withServiceName service: String?) -> Query {
111-
var query = [kSecClass: kSecClassGenericPassword,
112-
kSecReturnAttributes: true,
113-
kSecReturnData: true,
114-
kSecMatchLimit: kSecMatchLimitAll] as Query
115-
if let service = service {
116-
query[kSecAttrService] = service
110+
func getAll(withServiceName service: String?) -> [Query] {
111+
let accessibilityOptions = [
112+
kSecAttrAccessibleAfterFirstUnlock,
113+
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
114+
]
115+
116+
var queries: [Query] = []
117+
118+
for accessibility in accessibilityOptions {
119+
var query = [
120+
kSecClass: kSecClassGenericPassword,
121+
kSecReturnAttributes: true,
122+
kSecReturnData: true,
123+
kSecMatchLimit: kSecMatchLimitAll,
124+
kSecAttrAccessible: accessibility
125+
] as Query
126+
127+
if let service = service {
128+
query[kSecAttrService] = service
129+
}
130+
131+
queries.append(properties(query))
117132
}
118-
return properties(query)
133+
134+
return queries
119135
}
120136

121137
func delete(withKey key: String) -> Query {

TwilioVerifySDK/TwilioSecurity/Sources/Storage/SecureStorage.swift

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,15 @@ extension SecureStorage: SecureStorageProvider {
9292

9393
public func getAll(withServiceName service: String?) throws -> [Data] {
9494
Logger.shared.log(withLevel: .info, message: "Getting all values")
95-
let query = keychainQuery.getAll(withServiceName: service)
95+
let queries = keychainQuery.getAll(withServiceName: service)
9696
do {
97-
let result = try keychain.copyItemMatching(query: query)
98-
guard let resultArray = result as? [Any] else {
99-
return []
97+
let results = try keychain.copyItemsMatching(queries: queries)
98+
99+
let objectsData = results.compactMap { result -> Data? in
100+
guard let dict = result as? [String: Any] else { return nil }
101+
return dict[kSecValueData as String] as? Data
100102
}
101-
let objectsData = resultArray.map {
102-
(($0 as? [String: Any])?[kSecValueData as String]) as? Data
103-
}.compactMap { $0 }
103+
104104
Logger.shared.log(withLevel: .info, message: "Return all values")
105105
return objectsData
106106
} catch {

TwilioVerifySDKTests/TwilioSecurity/Sources/Keychain/KeychainQueryTests.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,18 @@ class KeychainQueryTests: XCTestCase {
9898
}
9999

100100
func testGetAllData_shouldReturnValidQuery() {
101-
let query = keychainQuery.getAll(withServiceName: nil)
102-
let keyClass = query[kSecClass] as! CFString
103-
let returnAttributes = query[kSecReturnAttributes] as! CFBoolean
104-
let returnData = query[kSecReturnData] as! CFBoolean
105-
let matchLimit = query[kSecMatchLimit] as! CFString
106-
107-
XCTAssertEqual(keyClass, kSecClassGenericPassword)
108-
XCTAssertTrue(returnAttributes as! Bool)
109-
XCTAssertTrue(returnData as! Bool)
110-
XCTAssertEqual(matchLimit, kSecMatchLimitAll)
101+
let queries = keychainQuery.getAll(withServiceName: nil)
102+
for query in queries {
103+
let keyClass = query[kSecClass] as! CFString
104+
let returnAttributes = query[kSecReturnAttributes] as! CFBoolean
105+
let returnData = query[kSecReturnData] as! CFBoolean
106+
let matchLimit = query[kSecMatchLimit] as! CFString
107+
108+
XCTAssertEqual(keyClass, kSecClassGenericPassword)
109+
XCTAssertTrue(returnAttributes as! Bool)
110+
XCTAssertTrue(returnData as! Bool)
111+
XCTAssertEqual(matchLimit, kSecMatchLimitAll)
112+
}
111113
}
112114

113115
func testDelete_withKey_shouldReturnValidQuery() {

TwilioVerifySDKTests/TwilioSecurity/Sources/Keychain/Mocks/KeychainMock.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ enum KeychainMethods {
3030
case addItem
3131
case updateItem
3232
case deleteItem
33+
case copyItemsMatching
3334
}
3435

3536
class KeychainMock {
@@ -42,12 +43,13 @@ class KeychainMock {
4243
var deleteItemStatus: OSStatus!
4344
var keyPair: KeyPair!
4445
var keys: [AnyObject]!
46+
var multiQueryResults: [[AnyObject]]!
4547
var copyItemMitmatchingHandler: (() -> Void)?
4648
private(set) var callsToAddItem = 0
4749
private(set) var callsToUpdateItem = 0
4850
private(set) var callOrder = [KeychainMethods]()
4951
private(set) var callsToCopyItemMatching = -1
50-
52+
private(set) var callsToCopyItemsMatching = 0
5153
}
5254

5355
extension KeychainMock: KeychainProtocol {
@@ -102,7 +104,22 @@ extension KeychainMock: KeychainProtocol {
102104

103105
return keys[callsToCopyItemMatching]
104106
}
105-
107+
108+
func copyItemsMatching(queries: [Query]) throws -> [AnyObject] {
109+
callsToCopyItemsMatching += 1
110+
callOrder.append(.copyItemsMatching)
111+
112+
if let error = error {
113+
throw error
114+
}
115+
116+
if let multiQueryResults = multiQueryResults {
117+
return multiQueryResults.flatMap { $0 }
118+
}
119+
120+
return []
121+
}
122+
106123
func addItem(withQuery query: Query) -> OSStatus {
107124
callsToAddItem += 1
108125
callOrder.append(.addItem)

TwilioVerifySDKTests/TwilioSecurity/Sources/Storage/SecureStorageTests.swift

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -109,17 +109,19 @@ class SecureStorageTests: XCTestCase {
109109
let expectedData2 = "data2".data(using: .utf8)!
110110
let valueData2 = [kSecValueData as String: expectedData2]
111111
var data: [Data?]!
112-
keychain.keys = [[valueData1, valueData2] as AnyObject]
112+
keychain.multiQueryResults = [[valueData1 as AnyObject, valueData2 as AnyObject] ]
113113
XCTAssertNoThrow(data = try storage.getAll(withServiceName: nil), "Get all should not throw")
114114
XCTAssertEqual(data.first, expectedData1, "Data should be \(expectedData1) but was \(data!.first!!)")
115115
XCTAssertEqual(data.last, expectedData2, "Data should be \(expectedData2) but was \(data!.last!!)")
116+
XCTAssertTrue(keychain.callOrder.contains(.copyItemsMatching), "copyItemsMatching should be called")
116117
}
117-
118+
118119
func testGetAllData_withoutDataSaved_shouldReturnEmptyArray() {
119120
var data: [Data?]!
120-
keychain.keys = ["data".data(using: .utf8)! as AnyObject]
121+
keychain.multiQueryResults = [["data".data(using: .utf8)! as AnyObject]]
121122
XCTAssertNoThrow(data = try storage.getAll(withServiceName: nil), "Get all should not throw")
122123
XCTAssertTrue(data.isEmpty)
124+
XCTAssertTrue(keychain.callOrder.contains(.copyItemsMatching), "copyItemsMatching should be called")
123125
}
124126

125127
func testRemoveValue_valueExistsForKey_shouldReturnValue() {
@@ -137,23 +139,25 @@ class SecureStorageTests: XCTestCase {
137139
func testClear_itemsExist_shouldClearKeychain() {
138140
let expectedData2 = "data2".data(using: .utf8)!
139141
let valueData2 = [kSecValueData as String: expectedData2]
140-
keychain.keys = [[valueData2] as AnyObject]
142+
keychain.multiQueryResults = [[valueData2 as AnyObject]]
141143
keychain.deleteItemStatus = errSecSuccess
142144
XCTAssertNoThrow(try storage.clear(withServiceName: "service"), "Clear should not throw")
143145
XCTAssertTrue(keychain.callOrder.contains(.deleteItem), "Delete should be called")
146+
XCTAssertTrue(keychain.callOrder.contains(.copyItemsMatching), "copyItemsMatching should be called")
144147
}
145148

146149
func testClear_noItems_shouldNotClearKeychain() {
147150
keychain.error = NSError(domain: "No items", code: Int(errSecItemNotFound), userInfo: nil)
148151
XCTAssertNoThrow(try storage.clear(withServiceName: "service"), "Clear should not throw")
149152
XCTAssertFalse(keychain.callOrder.contains(.deleteItem), "Delete should not be called")
153+
XCTAssertTrue(keychain.callOrder.contains(.copyItemsMatching), "copyItemsMatching should be called")
150154
}
151155

152156
func testClear_itemsExistAndDeleteStatusError_shouldThrowError() {
153157
let expectedLocalizedDescription = "Invalid status code operation received: -25300"
154158
let expectedData2 = "data2".data(using: .utf8)!
155159
let valueData2 = [kSecValueData as String: expectedData2]
156-
keychain.keys = [[valueData2] as AnyObject]
160+
keychain.multiQueryResults = [[valueData2 as AnyObject]]
157161
keychain.deleteItemStatus = errSecItemNotFound
158162

159163
XCTAssertThrowsError(
@@ -214,6 +218,51 @@ class SecureStorageTests: XCTestCase {
214218
XCTAssertEqual(data, expectedData, "Data should be \(expectedData) but was \(data)")
215219
}
216220

221+
func testGetAllData_withMultipleAccessibilityTypes_shouldReturnAllValues() throws {
222+
// Given
223+
let expectedData1 = "data1".data(using: .utf8)!
224+
let valueData1 = [kSecValueData as String: expectedData1]
225+
let expectedData2 = "data2".data(using: .utf8)!
226+
let valueData2 = [kSecValueData as String: expectedData2]
227+
let expectedData3 = "data3".data(using: .utf8)!
228+
let valueData3 = [kSecValueData as String: expectedData3]
229+
230+
keychain.multiQueryResults = [
231+
[valueData1 as AnyObject, valueData2 as AnyObject],
232+
[valueData3 as AnyObject]
233+
]
234+
235+
// When
236+
var data: [Data?]!
237+
XCTAssertNoThrow(data = try storage.getAll(withServiceName: "testService"), "Get all should not throw")
238+
239+
// Then
240+
XCTAssertEqual(data.count, 3, "Should return 3 items from both queries")
241+
XCTAssertTrue(data.contains(expectedData1), "Results should contain data1")
242+
XCTAssertTrue(data.contains(expectedData2), "Results should contain data2")
243+
XCTAssertTrue(data.contains(expectedData3), "Results should contain data3")
244+
XCTAssertTrue(keychain.callOrder.contains(.copyItemsMatching), "copyItemsMatching should be called")
245+
XCTAssertEqual(keychain.callsToCopyItemsMatching, 1, "copyItemsMatching should be called once")
246+
}
247+
248+
func testGetAllData_errorFromKeychain_shouldThrow() {
249+
// Given
250+
keychain.error = TestError.operationFailed
251+
252+
// When/Then
253+
XCTAssertThrowsError(
254+
try storage.getAll(withServiceName: nil),
255+
"Get all should throw when keychain fails"
256+
) { error in
257+
XCTAssertEqual(
258+
error as! TestError,
259+
TestError.operationFailed,
260+
"Error should be \(TestError.operationFailed), but was \(error)"
261+
)
262+
}
263+
XCTAssertEqual(keychain.callsToCopyItemsMatching, 1, "copyItemsMatching should be called once")
264+
}
265+
217266
func testGet_valueExistsForMultipleTries_shouldThrowIfTheErrorPersist() throws {
218267
// Given
219268
guard let expectedData = "data".data(using: .utf8) else {

TwilioVerifySDKTests/TwilioVerify/Sources/Data/Mocks/SecureStorageMock.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ extension SecureStorageMock: SecureStorageProvider {
5555
if let error = error {
5656
throw error
5757
}
58+
5859
return Array(objectsData.values)
5960
}
6061

fastlane/Fastfile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
output_directory = './fastlane/Test Output/'
22
coverage_directory = '/coverage'
33
complete_suite = 'CompleteSuite'
4-
single_ftl_device = [{ios_model_id: 'iphone8', ios_version_id: '16.6'}]
4+
single_ftl_device = [{ios_model_id: 'iphone13pro', ios_version_id: '16.6'}]
55
all_ftl_devices = [
6-
{ios_model_id: 'iphone11pro', ios_version_id: '16.6'},
76
{ios_model_id: 'ipad10', ios_version_id: '16.6'},
8-
{ios_model_id: 'iphone8', ios_version_id: '15.7'}
7+
{ios_model_id: 'iphone13pro', ios_version_id: '16.6'}
98
]
109

1110
default_platform(:ios)

0 commit comments

Comments
 (0)