diff --git a/App/Tests/DevKeychainAppTests/GenericPasswordIntegrationTests.swift b/App/Tests/DevKeychainAppTests/GenericPasswordIntegrationTests.swift index e5f1be3..0c1290b 100644 --- a/App/Tests/DevKeychainAppTests/GenericPasswordIntegrationTests.swift +++ b/App/Tests/DevKeychainAppTests/GenericPasswordIntegrationTests.swift @@ -36,12 +36,14 @@ struct GenericPasswordIntegrationTests: RandomValueGenerating { service: service, account: account, data: randomData(), + accessibility: .whenUnlocked, ) let addedItem = try keychain.addItem(with: attributes) #expect(addedItem.service == attributes.service) #expect(addedItem.account == attributes.account) #expect(addedItem.data == attributes.data) + #expect(addedItem.accessibility == attributes.accessibility) let queryResults = try keychain.items(matching: addedItem.query, options: .init(limit: 1)) #expect(queryResults == [addedItem]) diff --git a/App/Tests/DevKeychainAppTests/InternetPasswordIntegrationTests.swift b/App/Tests/DevKeychainAppTests/InternetPasswordIntegrationTests.swift index ba7408a..956bb3d 100644 --- a/App/Tests/DevKeychainAppTests/InternetPasswordIntegrationTests.swift +++ b/App/Tests/DevKeychainAppTests/InternetPasswordIntegrationTests.swift @@ -36,12 +36,14 @@ struct InternetPasswordIntegrationTests: RandomValueGenerating { server: server, account: account, data: randomData(), + accessibility: .whenUnlocked, ) let addedItem = try keychain.addItem(with: attributes) #expect(addedItem.server == attributes.server) #expect(addedItem.account == attributes.account) #expect(addedItem.data == attributes.data) + #expect(addedItem.accessibility == attributes.accessibility) let queryResults = try keychain.items(matching: addedItem.query, options: .init(limit: 1)) #expect(queryResults == [addedItem]) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9caf003..f12d30e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # DevKeychain Changelog +## 1.2.0: July 28, 2026 + +This update adds support for specifying Keychain item accessibility values. + + ## 1.1.0: September 24, 2025 This update bumps the minimum supported version of Apple’s OSes to 26. diff --git a/Sources/DevKeychain/Core/KeychainItemAccessibility.swift b/Sources/DevKeychain/Core/KeychainItemAccessibility.swift new file mode 100644 index 0000000..9cce17d --- /dev/null +++ b/Sources/DevKeychain/Core/KeychainItemAccessibility.swift @@ -0,0 +1,78 @@ +// +// KeychainItemAccessibility.swift +// DevKeychain +// +// Created by Prachi Gauriar on 7/28/26. +// + +import Foundation +import Security + +/// The conditions under which a keychain item can be accessed. +/// +/// Each case corresponds to a `kSecAttrAccessible*` constant from the Security framework. When adding a keychain item, +/// set its accessibility to control when the item can be read. +public enum KeychainItemAccessibility: String, CaseIterable, Codable, Sendable { + /// The item is accessible after the device has been unlocked once following a restart. + /// + /// This is the recommended default for most items. Items with this accessibility are backed up to iCloud and + /// transferred to new devices. + case afterFirstUnlock + + /// The item is accessible after the device has been unlocked once following a restart, but is not backed up or + /// transferred to other devices. + case afterFirstUnlockThisDeviceOnly + + /// The item is only accessible when the device has a passcode set. + /// + /// Items with this accessibility are not backed up or transferred to other devices. If the device passcode is + /// removed, matching items are deleted from the keychain. + case whenPasscodeSetThisDeviceOnly + + /// The item is only accessible while the device is unlocked. + /// + /// Items with this accessibility are backed up to iCloud and transferred to new devices. + case whenUnlocked + + /// The item is only accessible while the device is unlocked, but is not backed up or transferred to other devices. + case whenUnlockedThisDeviceOnly + + + /// Creates an instance from a Security framework accessibility string. + /// + /// - Parameter string: A Security framework accessibility string, such as `kSecAttrAccessibleWhenUnlocked`. + /// - Returns: An instance corresponding to `string`, or `nil` if no case matches. + public init?(string: String) { + switch string as CFString { + case kSecAttrAccessibleAfterFirstUnlock: + self = .afterFirstUnlock + case kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: + self = .afterFirstUnlockThisDeviceOnly + case kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly: + self = .whenPasscodeSetThisDeviceOnly + case kSecAttrAccessibleWhenUnlocked: + self = .whenUnlocked + case kSecAttrAccessibleWhenUnlockedThisDeviceOnly: + self = .whenUnlockedThisDeviceOnly + default: + return nil + } + } + + + /// The value’s corresponding Security framework accessibility constant. + var attributeValue: Any { + switch self { + case .afterFirstUnlock: + return kSecAttrAccessibleAfterFirstUnlock + case .afterFirstUnlockThisDeviceOnly: + return kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + case .whenPasscodeSetThisDeviceOnly: + return kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly + case .whenUnlocked: + return kSecAttrAccessibleWhenUnlocked + case .whenUnlockedThisDeviceOnly: + return kSecAttrAccessibleWhenUnlockedThisDeviceOnly + } + } +} diff --git a/Sources/DevKeychain/Keychain Items/GenericPassword.swift b/Sources/DevKeychain/Keychain Items/GenericPassword.swift index ffc834f..ecdbda9 100644 --- a/Sources/DevKeychain/Keychain Items/GenericPassword.swift +++ b/Sources/DevKeychain/Keychain Items/GenericPassword.swift @@ -20,6 +20,9 @@ public struct GenericPassword: Hashable, Sendable { /// If this data is textual, you can use ``password(using:)`` to easily access it. public let data: Data + /// The conditions under which the item can be accessed. + public let accessibility: KeychainItemAccessibility + /// Returns the item’s secret data as a string. /// @@ -42,10 +45,19 @@ extension GenericPassword { /// /// - Parameter attributes: A dictionary of attributes from the keychain services API. init(attributes: [CFString: Any]) throws { + let accessibilityString = try attributes.value(forKeychainAttribute: kSecAttrAccessible, type: String.self) + guard let accessibility = KeychainItemAccessibility(string: accessibilityString) else { + throw KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: KeychainItemAccessibility.self, + ) + } + self.init( service: try attributes.value(forKeychainAttribute: kSecAttrService, type: String.self), account: try attributes.value(forKeychainAttribute: kSecAttrAccount, type: String.self), data: try attributes.value(forKeychainAttribute: kSecValueData, type: Data.self), + accessibility: accessibility, ) } } @@ -63,6 +75,11 @@ extension GenericPassword { /// The new item’s secret data. public var data: Data + /// The conditions under which the new item can be accessed. + /// + /// If `nil`, the keychain uses its default accessibility. `nil` by default. + public var accessibility: KeychainItemAccessibility? + /// Creates generic password addition attributes with secret data. /// @@ -70,10 +87,18 @@ extension GenericPassword { /// - service: The new item’s service. /// - account: The new item’s acocunt. /// - data: The new item’s secret data. - public init(service: String, account: String, data: Data) { + /// - accessibility: The conditions under which the new item can be accessed. If `nil`, the keychain + /// uses its default accessibility. `nil` by default. + public init( + service: String, + account: String, + data: Data, + accessibility: KeychainItemAccessibility? = nil, + ) { self.service = service self.account = account self.data = data + self.accessibility = accessibility } @@ -86,17 +111,25 @@ extension GenericPassword { /// - account: The new item’s acocunt. /// - password: The new item’s secret data as a string. /// - encoding: The string encoding to use when converting `password` to `Data`. Defaults to `.utf8`. - public init?(service: String, account: String, password: String, encoding: String.Encoding = .utf8) { + /// - accessibility: The conditions under which the new item can be accessed. If `nil`, the keychain + /// uses its default accessibility. `nil` by default. + public init?( + service: String, + account: String, + password: String, + encoding: String.Encoding = .utf8, + accessibility: KeychainItemAccessibility? = nil, + ) { guard let data = password.data(using: encoding) else { return nil } - self.init(service: service, account: account, data: data) + self.init(service: service, account: account, data: data, accessibility: accessibility) } public var attributesDictionary: [CFString: Any] { - return [ + var dictionary: [CFString: Any] = [ kSecAttrAccount: account, kSecAttrService: service, kSecClass: kSecClassGenericPassword, @@ -105,6 +138,12 @@ extension GenericPassword { kSecUseDataProtectionKeychain: true, kSecValueData: data, ] + + if let accessibility { + dictionary[kSecAttrAccessible] = accessibility.attributeValue + } + + return dictionary } @@ -132,6 +171,11 @@ extension GenericPassword { /// If `nil`, matching items can have any account. `nil` by default. public var account: String? + /// The accessibility that matching items must have. + /// + /// If `nil`, matching items can have any accessibility. `nil` by default. + public var accessibility: KeychainItemAccessibility? + /// Creates a new generic password query. /// @@ -140,9 +184,16 @@ extension GenericPassword { /// `nil` by default. /// - account: The account that matching items must have. If `nil`, matching items can have any account. /// `nil` by default. - public init(service: String? = nil, account: String? = nil) { + /// - accessibility: The accessibility that matching items must have. If `nil`, matching items can have + /// any accessibility. `nil` by default. + public init( + service: String? = nil, + account: String? = nil, + accessibility: KeychainItemAccessibility? = nil, + ) { self.service = service self.account = account + self.accessibility = accessibility } @@ -152,14 +203,18 @@ extension GenericPassword { kSecUseDataProtectionKeychain: true, ] - if let account = account { + if let account { dictionary[kSecAttrAccount] = account } - if let service = service { + if let service { dictionary[kSecAttrService] = service } + if let accessibility { + dictionary[kSecAttrAccessible] = accessibility.attributeValue + } + return dictionary } diff --git a/Sources/DevKeychain/Keychain Items/InternetPassword.swift b/Sources/DevKeychain/Keychain Items/InternetPassword.swift index d4d5c1e..018d87f 100644 --- a/Sources/DevKeychain/Keychain Items/InternetPassword.swift +++ b/Sources/DevKeychain/Keychain Items/InternetPassword.swift @@ -20,6 +20,9 @@ public struct InternetPassword: Hashable, Sendable { /// If this data is textual, you can use ``password(using:)`` to easily access it. public let data: Data + /// The conditions under which the item can be accessed. + public let accessibility: KeychainItemAccessibility + /// Returns the item’s secret data as a string. /// @@ -42,10 +45,19 @@ extension InternetPassword { /// /// - Parameter attributes: A dictionary of attributes from the keychain services API. init(attributes: [CFString: Any]) throws { + let accessibilityString = try attributes.value(forKeychainAttribute: kSecAttrAccessible, type: String.self) + guard let accessibility = KeychainItemAccessibility(string: accessibilityString) else { + throw KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: KeychainItemAccessibility.self, + ) + } + self.init( server: try attributes.value(forKeychainAttribute: kSecAttrServer, type: String.self), account: try attributes.value(forKeychainAttribute: kSecAttrAccount, type: String.self), data: try attributes.value(forKeychainAttribute: kSecValueData, type: Data.self), + accessibility: accessibility, ) } } @@ -63,6 +75,11 @@ extension InternetPassword { /// The new item’s secret data. public var data: Data + /// The conditions under which the new item can be accessed. + /// + /// If `nil`, the keychain uses its default accessibility. `nil` by default. + public var accessibility: KeychainItemAccessibility? + /// Creates internet password addition attributes with secret data. /// @@ -70,10 +87,18 @@ extension InternetPassword { /// - server: The new item’s server. /// - account: The new item’s acocunt. /// - data: The new item’s secret data. - public init(server: String, account: String, data: Data) { + /// - accessibility: The conditions under which the new item can be accessed. If `nil`, the keychain + /// uses its default accessibility. `nil` by default. + public init( + server: String, + account: String, + data: Data, + accessibility: KeychainItemAccessibility? = nil, + ) { self.server = server self.account = account self.data = data + self.accessibility = accessibility } @@ -86,17 +111,25 @@ extension InternetPassword { /// - account: The new item’s acocunt. /// - password: The new item’s secret data as a string. /// - encoding: The string encoding to use when converting `password` to `Data`. Defaults to `.utf8`. - public init?(server: String, account: String, password: String, encoding: String.Encoding = .utf8) { + /// - accessibility: The conditions under which the new item can be accessed. If `nil`, the keychain + /// uses its default accessibility. `nil` by default. + public init?( + server: String, + account: String, + password: String, + encoding: String.Encoding = .utf8, + accessibility: KeychainItemAccessibility? = nil, + ) { guard let data = password.data(using: encoding) else { return nil } - self.init(server: server, account: account, data: data) + self.init(server: server, account: account, data: data, accessibility: accessibility) } public var attributesDictionary: [CFString: Any] { - return [ + var dictionary: [CFString: Any] = [ kSecAttrAccount: account, kSecAttrServer: server, kSecClass: kSecClassInternetPassword, @@ -105,6 +138,12 @@ extension InternetPassword { kSecUseDataProtectionKeychain: true, kSecValueData: data, ] + + if let accessibility { + dictionary[kSecAttrAccessible] = accessibility.attributeValue + } + + return dictionary } @@ -132,6 +171,11 @@ extension InternetPassword { /// If `nil`, matching items can have any account. `nil` by default. public var account: String? + /// The accessibility that matching items must have. + /// + /// If `nil`, matching items can have any accessibility. `nil` by default. + public var accessibility: KeychainItemAccessibility? + /// Creates a new internet password query. /// @@ -140,9 +184,16 @@ extension InternetPassword { /// `nil` by default. /// - account: The account that matching items must have. If `nil`, matching items can have any account. /// `nil` by default. - public init(server: String? = nil, account: String? = nil) { + /// - accessibility: The accessibility that matching items must have. If `nil`, matching items can have + /// any accessibility. `nil` by default. + public init( + server: String? = nil, + account: String? = nil, + accessibility: KeychainItemAccessibility? = nil, + ) { self.server = server self.account = account + self.accessibility = accessibility } @@ -152,14 +203,18 @@ extension InternetPassword { kSecUseDataProtectionKeychain: true, ] - if let account = account { + if let account { dictionary[kSecAttrAccount] = account } - if let server = server { + if let server { dictionary[kSecAttrServer] = server } + if let accessibility { + dictionary[kSecAttrAccessible] = accessibility.attributeValue + } + return dictionary } diff --git a/Tests/DevKeychainTests/Core/KeychainItemAccessibilityTests.swift b/Tests/DevKeychainTests/Core/KeychainItemAccessibilityTests.swift new file mode 100644 index 0000000..c9c489a --- /dev/null +++ b/Tests/DevKeychainTests/Core/KeychainItemAccessibilityTests.swift @@ -0,0 +1,63 @@ +// +// KeychainItemAccessibilityTests.swift +// DevKeychain +// +// Created by Prachi Gauriar on 7/28/26. +// + +import DevTesting +import Foundation +import Security +import Testing + +@testable import DevKeychain + +struct KeychainItemAccessibilityTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + mutating func initWithStringReturnsNilForUnknownString() { + // set up + let unknownString = randomAlphanumericString() + + // exercise / expect + #expect(KeychainItemAccessibility(string: unknownString) == nil) + } + + + @Test + func initWithStringSetsCorrectCaseForEachKnownValue() { + // set up + let cases: [(CFString, KeychainItemAccessibility)] = [ + (kSecAttrAccessibleAfterFirstUnlock, .afterFirstUnlock), + (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, .afterFirstUnlockThisDeviceOnly), + (kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly, .whenPasscodeSetThisDeviceOnly), + (kSecAttrAccessibleWhenUnlocked, .whenUnlocked), + (kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .whenUnlockedThisDeviceOnly), + ] + + // exercise / expect + for (string, expectedCase) in cases { + #expect(KeychainItemAccessibility(string: string as String) == expectedCase) + } + } + + + @Test + func attributeValueReturnsCorrectValueForEachCase() { + // set up + let cases: [(KeychainItemAccessibility, CFString)] = [ + (.afterFirstUnlock, kSecAttrAccessibleAfterFirstUnlock), + (.afterFirstUnlockThisDeviceOnly, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly), + (.whenPasscodeSetThisDeviceOnly, kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly), + (.whenUnlocked, kSecAttrAccessibleWhenUnlocked), + (.whenUnlockedThisDeviceOnly, kSecAttrAccessibleWhenUnlockedThisDeviceOnly), + ] + + // exercise / expect + for (accessibility, expectedValue) in cases { + #expect(accessibility.attributeValue as! CFString == expectedValue) + } + } +} diff --git a/Tests/DevKeychainTests/Keychain Items/GenericPasswordTests.swift b/Tests/DevKeychainTests/Keychain Items/GenericPasswordTests.swift index b0e2c08..29a9566 100644 --- a/Tests/DevKeychainTests/Keychain Items/GenericPasswordTests.swift +++ b/Tests/DevKeychainTests/Keychain Items/GenericPasswordTests.swift @@ -23,7 +23,12 @@ struct GenericPasswordTests: RandomValueGenerating { let password = randomAlphanumericString() let data = try #require(password.data(using: .utf16)) - let genericPassword = GenericPassword(service: service, account: account, data: data) + let genericPassword = GenericPassword( + service: service, + account: account, + data: data, + accessibility: randomAccessibility(), + ) #expect(genericPassword.password(using: .utf16) == password) } @@ -34,7 +39,12 @@ struct GenericPasswordTests: RandomValueGenerating { let account = randomAlphanumericString() let data = randomData(count: 5) - let genericPassword = GenericPassword(service: service, account: account, data: data) + let genericPassword = GenericPassword( + service: service, + account: account, + data: data, + accessibility: randomAccessibility(), + ) #expect(genericPassword.password(using: .utf32) == nil) } @@ -44,7 +54,12 @@ struct GenericPasswordTests: RandomValueGenerating { let service = randomAlphanumericString() let account = randomAlphanumericString() - let genericPassword = GenericPassword(service: service, account: account, data: randomData()) + let genericPassword = GenericPassword( + service: service, + account: account, + data: randomData(), + accessibility: randomAccessibility(), + ) let query = genericPassword.query #expect(query.service == service) @@ -55,6 +70,7 @@ struct GenericPasswordTests: RandomValueGenerating { @Test mutating func initWithAttributesThrowsErrorIfKeyIsMissing() { let dictionary: [CFString: Any] = [ + kSecAttrAccessible: randomAccessibility().attributeValue, kSecAttrService: randomAlphanumericString(), kSecAttrAccount: randomAlphanumericString(), kSecValueData: randomData(), @@ -72,8 +88,9 @@ struct GenericPasswordTests: RandomValueGenerating { @Test - mutating func initWithAttributesThrowsErrorIfKeyIsIncorrectlyTypes() { + mutating func initWithAttributesThrowsErrorIfKeyIsIncorrectlyTyped() { let dictionary: [CFString: Any] = [ + kSecAttrAccessible: randomAccessibility().attributeValue, kSecAttrService: randomAlphanumericString(), kSecAttrAccount: randomAlphanumericString(), kSecValueData: randomData(), @@ -93,18 +110,51 @@ struct GenericPasswordTests: RandomValueGenerating { _ = try GenericPassword(attributes: attributes) } } + + var attributes = dictionary + attributes[kSecAttrAccessible] = randomInt(in: .min ... .max) + #expect( + throws: KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: String.self, + ) + ) { + _ = try GenericPassword(attributes: attributes) + } + } + + + @Test + mutating func initWithAttributesThrowsErrorIfAccessibilityIsUnrecognized() { + let attributes: [CFString: Any] = [ + kSecAttrAccessible: randomAlphanumericString(), + kSecAttrService: randomAlphanumericString(), + kSecAttrAccount: randomAlphanumericString(), + kSecValueData: randomData(), + ] + + #expect( + throws: KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: KeychainItemAccessibility.self, + ) + ) { + _ = try GenericPassword(attributes: attributes) + } } @Test mutating func initWithAttributesSetsProperties() throws { + let accessibility = randomAccessibility() let service = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() - let expected = GenericPassword(service: service, account: account, data: data) + let expected = GenericPassword(service: service, account: account, data: data, accessibility: accessibility) let actual = try GenericPassword( attributes: [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrService: service, kSecAttrAccount: account, kSecValueData: data, @@ -130,6 +180,21 @@ struct GenericPassword_AdditionAttributesTests: RandomValueGenerating { #expect(attributes.service == service) #expect(attributes.account == account) #expect(attributes.data == data) + #expect(attributes.accessibility == nil) + } + + + @Test + mutating func initWithAccessibilitySetsAccessibility() throws { + let accessibility = randomAccessibility() + + let attributes = GenericPassword.AdditionAttributes( + service: randomAlphanumericString(), + account: randomAlphanumericString(), + data: randomData(), + accessibility: accessibility, + ) + #expect(attributes.accessibility == accessibility) } @@ -172,7 +237,7 @@ struct GenericPassword_AdditionAttributesTests: RandomValueGenerating { @Test - mutating func attributesDictionaryIsCorrect() { + mutating func attributesDictionaryIsCorrectWhenAccessibilityIsNil() { let service = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() @@ -193,6 +258,35 @@ struct GenericPassword_AdditionAttributesTests: RandomValueGenerating { } + @Test + mutating func attributesDictionaryIncludesAccessibilityWhenSet() { + let accessibility = randomAccessibility() + let service = randomAlphanumericString() + let account = randomAlphanumericString() + let data = randomData() + + let attributes = GenericPassword.AdditionAttributes( + service: service, + account: account, + data: data, + accessibility: accessibility, + ) + + let expectedDictionary: [CFString: Any] = [ + kSecAttrAccessible: accessibility.attributeValue, + kSecAttrAccount: account, + kSecAttrService: service, + kSecClass: kSecClassGenericPassword, + kSecReturnAttributes: true, + kSecReturnData: true, + kSecUseDataProtectionKeychain: true, + kSecValueData: data, + ] + + #expect(attributes.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) + } + + @Test mutating func mapThrowsWhenObjectIsNonDictionary() { let attributes = GenericPassword.AdditionAttributes( @@ -223,14 +317,16 @@ struct GenericPassword_AdditionAttributesTests: RandomValueGenerating { @Test mutating func mapReturnsInitializedValue() throws { + let accessibility = randomAccessibility() let service = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() let attributes = GenericPassword.AdditionAttributes(service: service, account: account, data: data) - let expected = GenericPassword(service: service, account: account, data: data) + let expected = GenericPassword(service: service, account: account, data: data, accessibility: accessibility) let actual = try attributes.mapAddedItem( [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrService: service, kSecAttrAccount: account, kSecValueData: data, @@ -250,42 +346,53 @@ struct GenericPassword_QueryTests: RandomValueGenerating { mutating func initSetsProperties() throws { let service = randomOptional(randomAlphanumericString()) let account = randomOptional(randomAlphanumericString()) + let accessibility = randomOptional(randomAccessibility()) - let query = GenericPassword.Query(service: service, account: account) + let query = GenericPassword.Query(service: service, account: account, accessibility: accessibility) #expect(query.service == service) #expect(query.account == account) + #expect(query.accessibility == accessibility) } @Test mutating func attributesDictionaryIsCorrect() throws { + let accessibility = randomAccessibility() let service = randomAlphanumericString() let account = randomAlphanumericString() let fullAttributesDictionary: [CFString: Any] = [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrAccount: account, kSecAttrService: service, kSecClass: kSecClassGenericPassword, kSecUseDataProtectionKeychain: true, ] - for isServiceNil in [false, true] { - for isAccountNil in [false, true] { - let query = GenericPassword.Query( - service: isServiceNil ? nil : service, - account: isAccountNil ? nil : account, - ) - - var expectedDictionary = fullAttributesDictionary - if isServiceNil { - expectedDictionary.removeValue(forKey: kSecAttrService) + for isAccessibilityNil in [false, true] { + for isServiceNil in [false, true] { + for isAccountNil in [false, true] { + let query = GenericPassword.Query( + service: isServiceNil ? nil : service, + account: isAccountNil ? nil : account, + accessibility: isAccessibilityNil ? nil : accessibility, + ) + + var expectedDictionary = fullAttributesDictionary + if isAccessibilityNil { + expectedDictionary.removeValue(forKey: kSecAttrAccessible) + } + + if isServiceNil { + expectedDictionary.removeValue(forKey: kSecAttrService) + } + + if isAccountNil { + expectedDictionary.removeValue(forKey: kSecAttrAccount) + } + + #expect(query.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) } - - if isAccountNil { - expectedDictionary.removeValue(forKey: kSecAttrAccount) - } - - #expect(query.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) } } } @@ -332,11 +439,13 @@ struct GenericPassword_QueryTests: RandomValueGenerating { service: randomAlphanumericString(), account: randomAlphanumericString(), data: randomData(), + accessibility: randomAccessibility(), ) } let rawItems: [[CFString: Any]] = expectedItems.map { item in [ + kSecAttrAccessible: item.accessibility.attributeValue, kSecAttrAccount: item.account, kSecAttrService: item.service, kSecValueData: item.data, diff --git a/Tests/DevKeychainTests/Keychain Items/InternetPasswordTests.swift b/Tests/DevKeychainTests/Keychain Items/InternetPasswordTests.swift index 87de407..d0a68fb 100644 --- a/Tests/DevKeychainTests/Keychain Items/InternetPasswordTests.swift +++ b/Tests/DevKeychainTests/Keychain Items/InternetPasswordTests.swift @@ -23,7 +23,12 @@ struct InternetPasswordTests: RandomValueGenerating { let password = randomAlphanumericString() let data = try #require(password.data(using: .utf16)) - let internetPassword = InternetPassword(server: server, account: account, data: data) + let internetPassword = InternetPassword( + server: server, + account: account, + data: data, + accessibility: randomAccessibility(), + ) #expect(internetPassword.password(using: .utf16) == password) } @@ -34,7 +39,12 @@ struct InternetPasswordTests: RandomValueGenerating { let account = randomAlphanumericString() let data = randomData(count: 5) - let internetPassword = InternetPassword(server: server, account: account, data: data) + let internetPassword = InternetPassword( + server: server, + account: account, + data: data, + accessibility: randomAccessibility(), + ) #expect(internetPassword.password(using: .utf32) == nil) } @@ -44,7 +54,12 @@ struct InternetPasswordTests: RandomValueGenerating { let server = randomAlphanumericString() let account = randomAlphanumericString() - let internetPassword = InternetPassword(server: server, account: account, data: randomData()) + let internetPassword = InternetPassword( + server: server, + account: account, + data: randomData(), + accessibility: randomAccessibility(), + ) let query = internetPassword.query #expect(query.server == server) @@ -55,6 +70,7 @@ struct InternetPasswordTests: RandomValueGenerating { @Test mutating func initWithAttributesThrowsErrorIfKeyIsMissing() { let dictionary: [CFString: Any] = [ + kSecAttrAccessible: randomAccessibility().attributeValue, kSecAttrServer: randomAlphanumericString(), kSecAttrAccount: randomAlphanumericString(), kSecValueData: randomData(), @@ -72,8 +88,9 @@ struct InternetPasswordTests: RandomValueGenerating { @Test - mutating func initWithAttributesThrowsErrorIfKeyIsIncorrectlyTypes() { + mutating func initWithAttributesThrowsErrorIfKeyIsIncorrectlyTyped() { let dictionary: [CFString: Any] = [ + kSecAttrAccessible: randomAccessibility().attributeValue, kSecAttrServer: randomAlphanumericString(), kSecAttrAccount: randomAlphanumericString(), kSecValueData: randomData(), @@ -93,18 +110,51 @@ struct InternetPasswordTests: RandomValueGenerating { _ = try InternetPassword(attributes: attributes) } } + + var attributes = dictionary + attributes[kSecAttrAccessible] = randomInt(in: .min ... .max) + #expect( + throws: KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: String.self, + ) + ) { + _ = try InternetPassword(attributes: attributes) + } + } + + + @Test + mutating func initWithAttributesThrowsErrorIfAccessibilityIsUnrecognized() { + let attributes: [CFString: Any] = [ + kSecAttrAccessible: randomAlphanumericString(), + kSecAttrServer: randomAlphanumericString(), + kSecAttrAccount: randomAlphanumericString(), + kSecValueData: randomData(), + ] + + #expect( + throws: KeychainItemMappingError.attributeTypeMismatch( + attribute: kSecAttrAccessible as String, + type: KeychainItemAccessibility.self, + ) + ) { + _ = try InternetPassword(attributes: attributes) + } } @Test mutating func initWithAttributesSetsProperties() throws { + let accessibility = randomAccessibility() let server = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() - let expected = InternetPassword(server: server, account: account, data: data) + let expected = InternetPassword(server: server, account: account, data: data, accessibility: accessibility) let actual = try InternetPassword( attributes: [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrServer: server, kSecAttrAccount: account, kSecValueData: data, @@ -130,6 +180,21 @@ struct InternetPassword_AdditionAttributesTests: RandomValueGenerating { #expect(attributes.server == server) #expect(attributes.account == account) #expect(attributes.data == data) + #expect(attributes.accessibility == nil) + } + + + @Test + mutating func initWithAccessibilitySetsAccessibility() throws { + let accessibility = randomAccessibility() + + let attributes = InternetPassword.AdditionAttributes( + server: randomAlphanumericString(), + account: randomAlphanumericString(), + data: randomData(), + accessibility: accessibility, + ) + #expect(attributes.accessibility == accessibility) } @@ -172,7 +237,7 @@ struct InternetPassword_AdditionAttributesTests: RandomValueGenerating { @Test - mutating func attributesDictionaryIsCorrect() { + mutating func attributesDictionaryIsCorrectWhenAccessibilityIsNil() { let server = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() @@ -193,6 +258,35 @@ struct InternetPassword_AdditionAttributesTests: RandomValueGenerating { } + @Test + mutating func attributesDictionaryIncludesAccessibilityWhenSet() { + let accessibility = randomAccessibility() + let server = randomAlphanumericString() + let account = randomAlphanumericString() + let data = randomData() + + let attributes = InternetPassword.AdditionAttributes( + server: server, + account: account, + data: data, + accessibility: accessibility, + ) + + let expectedDictionary: [CFString: Any] = [ + kSecAttrAccessible: accessibility.attributeValue, + kSecAttrAccount: account, + kSecAttrServer: server, + kSecClass: kSecClassInternetPassword, + kSecReturnAttributes: true, + kSecReturnData: true, + kSecUseDataProtectionKeychain: true, + kSecValueData: data, + ] + + #expect(attributes.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) + } + + @Test mutating func mapThrowsWhenObjectIsNonDictionary() { let attributes = InternetPassword.AdditionAttributes( @@ -223,14 +317,16 @@ struct InternetPassword_AdditionAttributesTests: RandomValueGenerating { @Test mutating func mapReturnsInitializedValue() throws { + let accessibility = randomAccessibility() let server = randomAlphanumericString() let account = randomAlphanumericString() let data = randomData() let attributes = InternetPassword.AdditionAttributes(server: server, account: account, data: data) - let expected = InternetPassword(server: server, account: account, data: data) + let expected = InternetPassword(server: server, account: account, data: data, accessibility: accessibility) let actual = try attributes.mapAddedItem( [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrServer: server, kSecAttrAccount: account, kSecValueData: data, @@ -250,42 +346,53 @@ struct InternetPassword_QueryTests: RandomValueGenerating { mutating func initSetsProperties() throws { let server = randomOptional(randomAlphanumericString()) let account = randomOptional(randomAlphanumericString()) + let accessibility = randomOptional(randomAccessibility()) - let query = InternetPassword.Query(server: server, account: account) + let query = InternetPassword.Query(server: server, account: account, accessibility: accessibility) #expect(query.server == server) #expect(query.account == account) + #expect(query.accessibility == accessibility) } @Test mutating func attributesDictionaryIsCorrect() throws { + let accessibility = randomAccessibility() let server = randomAlphanumericString() let account = randomAlphanumericString() let fullAttributesDictionary: [CFString: Any] = [ + kSecAttrAccessible: accessibility.attributeValue, kSecAttrAccount: account, kSecAttrServer: server, kSecClass: kSecClassInternetPassword, kSecUseDataProtectionKeychain: true, ] - for isServerNil in [false, true] { - for isAccountNil in [false, true] { - let query = InternetPassword.Query( - server: isServerNil ? nil : server, - account: isAccountNil ? nil : account, - ) - - var expectedDictionary = fullAttributesDictionary - if isServerNil { - expectedDictionary.removeValue(forKey: kSecAttrServer) + for isAccessibilityNil in [false, true] { + for isServerNil in [false, true] { + for isAccountNil in [false, true] { + let query = InternetPassword.Query( + server: isServerNil ? nil : server, + account: isAccountNil ? nil : account, + accessibility: isAccessibilityNil ? nil : accessibility, + ) + + var expectedDictionary = fullAttributesDictionary + if isAccessibilityNil { + expectedDictionary.removeValue(forKey: kSecAttrAccessible) + } + + if isServerNil { + expectedDictionary.removeValue(forKey: kSecAttrServer) + } + + if isAccountNil { + expectedDictionary.removeValue(forKey: kSecAttrAccount) + } + + #expect(query.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) } - - if isAccountNil { - expectedDictionary.removeValue(forKey: kSecAttrAccount) - } - - #expect(query.attributesDictionary as CFDictionary == expectedDictionary as CFDictionary) } } } @@ -332,11 +439,13 @@ struct InternetPassword_QueryTests: RandomValueGenerating { server: randomAlphanumericString(), account: randomAlphanumericString(), data: randomData(), + accessibility: randomAccessibility(), ) } let rawItems: [[CFString: Any]] = expectedItems.map { item in [ + kSecAttrAccessible: item.accessibility.attributeValue, kSecAttrAccount: item.account, kSecAttrServer: item.server, kSecValueData: item.data, diff --git a/Tests/DevKeychainTests/Testing Helpers/RandomValueGenerating+DevKeychain.swift b/Tests/DevKeychainTests/Testing Helpers/RandomValueGenerating+DevKeychain.swift index 626e7f3..7a1b72b 100644 --- a/Tests/DevKeychainTests/Testing Helpers/RandomValueGenerating+DevKeychain.swift +++ b/Tests/DevKeychainTests/Testing Helpers/RandomValueGenerating+DevKeychain.swift @@ -10,6 +10,11 @@ import DevTesting import Foundation extension RandomValueGenerating { + mutating func randomAccessibility() -> KeychainItemAccessibility { + return randomCase(of: KeychainItemAccessibility.self)! + } + + mutating func randomError() -> MockError { return randomCase(of: MockError.self)! }