Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
78 changes: 78 additions & 0 deletions Sources/DevKeychain/Core/KeychainItemAccessibility.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
69 changes: 62 additions & 7 deletions Sources/DevKeychain/Keychain Items/GenericPassword.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand All @@ -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,
)
}
}
Expand All @@ -63,17 +75,30 @@ 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.
///
/// - Parameters:
/// - 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
}


Expand All @@ -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,
Expand All @@ -105,6 +138,12 @@ extension GenericPassword {
kSecUseDataProtectionKeychain: true,
kSecValueData: data,
]

if let accessibility {
dictionary[kSecAttrAccessible] = accessibility.attributeValue
}

return dictionary
}


Expand Down Expand Up @@ -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.
///
Expand All @@ -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
}


Expand All @@ -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
}

Expand Down
Loading
Loading