Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for custom attributes #153

Merged
merged 1 commit into from Jun 24, 2022
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Expand Up @@ -7,7 +7,7 @@

Easily store your user's credentials in the Keychain. Supports sharing credentials with an **Access Group** or through **iCloud**, and integrating **Touch ID / Face ID**.

> ⚠️ This library is currently in **First Availability**. We do not recommend using this library in production yet. As we move towards General Availability, please be aware that releases may contain breaking changes.
> ⚠️ This library is currently in [**First Availability**](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages). We do not recommend using this library in production yet. As we move towards General Availability, please be aware that releases may contain breaking changes.

**Migrating from 0.x? Check the [Migration Guide](V1_MIGRATION_GUIDE.md).**

Expand All @@ -30,6 +30,7 @@ Easily store your user's credentials in the Keychain. Supports sharing credentia
+ [Remove all items](#remove-all-items)
+ [Error handling](#error-handling)
- [**Configuration**](#configuration)
+ [Include additional attributes](#include-additional-attributes)
+ [Share items with other apps and extensions using an Access Group](#share-items-with-other-apps-and-extensions-using-an-access-group)
+ [Share items with other devices through iCloud synchronization](#share-items-with-other-devices-through-icloud-synchronization)
+ [Restrict item accessibility based on device state](#restrict-item-accessibility-based-on-device-state)
Expand Down Expand Up @@ -157,6 +158,14 @@ catch let error as SimpleKeychainError {

## Configuration

### Include additional attributes

When creating the SimpleKeychain instance, specify additional attributes to include in every query.

```swift
let simpleKeychain = SimpleKeychain(attributes: [kSecUseDataProtectionKeychain as String: true])
```

### Share items with other apps and extensions using an Access Group

When creating the SimpleKeychain instance, specify the Access Group that the app may share entries with.
Expand Down
46 changes: 25 additions & 21 deletions SimpleKeychain/SimpleKeychain.swift
Expand Up @@ -15,6 +15,7 @@ public struct SimpleKeychain {
let accessibility: Accessibility
let accessControlFlags: SecAccessControlCreateFlags?
let isSynchronizable: Bool
let attributes: [String: Any]

var retrieve: RetrieveFunction = SecItemCopyMatching
var remove: RemoveFunction = SecItemDelete
Expand All @@ -27,44 +28,48 @@ public struct SimpleKeychain {
/// - Parameter service: Name of the service under which to save items. Defaults to the bundle identifier.
/// - Parameter accessGroup: Access Group for sharing Keychain items. Defaults to `nil`.
/// - Parameter accessibility: ``Accessibility`` type the stored items will have. Defaults to ``Accessibility/afterFirstUnlock``.
/// - Parameter accessControlFlags: Access control conditions for `kSecAttrAccessControl`.
/// When set, `kSecAttrAccessControl` will be used instead of `kSecAttrAccessible`. Defaults to `nil`.
/// - Parameter accessControlFlags: Access control conditions for `kSecAttrAccessControl`. Defaults to `nil`.
/// - Parameter context: `LAContext` used to access Keychain items. Defaults to a new `LAContext` instance.
/// - Parameter synchronizable: Whether the items should be synchronized through iCloud. Defaults to `false`.
/// - Parameter attributes: Additional attributes to include in every query. Defaults to an empty dictionary.
/// - Returns: A ``SimpleKeychain`` instance.
public init(service: String = Bundle.main.bundleIdentifier!,
accessGroup: String? = nil,
accessibility: Accessibility = .afterFirstUnlock,
accessControlFlags: SecAccessControlCreateFlags? = nil,
context: LAContext? = nil,
synchronizable: Bool = false) {
synchronizable: Bool = false,
attributes: [String: Any] = [:]) {
self.service = service
self.accessGroup = accessGroup
self.accessibility = accessibility
self.accessControlFlags = accessControlFlags
self.context = context
self.isSynchronizable = synchronizable
self.attributes = attributes
}
#else
/// Initializes a ``SimpleKeychain`` instance.
///
/// - Parameter service: Name of the service under which to save items. Defaults to the bundle identifier.
/// - Parameter accessGroup: Access Group for sharing Keychain items. Defaults to `nil`.
/// - Parameter accessibility: ``Accessibility`` type the stored items will have. Defaults to ``Accessibility/afterFirstUnlock``.
/// - Parameter accessControlFlags: Access control conditions for `kSecAttrAccessControl`.
/// When set, `kSecAttrAccessControl` will be used instead of `kSecAttrAccessible`. Defaults to `nil`.
/// - Parameter accessControlFlags: Access control conditions for `kSecAttrAccessControl`. Defaults to `nil`.
/// - Parameter synchronizable: Whether the items should be synchronized through iCloud. Defaults to `false`.
/// - Parameter attributes: Additional attributes to include in every query. Defaults to an empty dictionary.
/// - Returns: A ``SimpleKeychain`` instance.
public init(service: String = Bundle.main.bundleIdentifier!,
accessGroup: String? = nil,
accessibility: Accessibility = .afterFirstUnlock,
accessControlFlags: SecAccessControlCreateFlags? = nil,
synchronizable: Bool = false) {
synchronizable: Bool = false,
attributes: [String: Any] = [:]) {
self.service = service
self.accessGroup = accessGroup
self.accessibility = accessibility
self.accessControlFlags = accessControlFlags
self.isSynchronizable = synchronizable
self.attributes = attributes
}
#endif

Expand Down Expand Up @@ -152,7 +157,7 @@ public extension SimpleKeychain {

if addStatus == SimpleKeychainError.duplicateItem.status {
let updateQuery = self.baseQuery(withKey: key)
let updateAttributes = self.attributes(withData: data)
let updateAttributes: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
try assertSuccess(forStatus: updateStatus)
} else {
Expand Down Expand Up @@ -210,9 +215,11 @@ public extension SimpleKeychain {
func hasItem(forKey key: String) throws -> Bool {
let query = self.baseQuery(withKey: key)
let status = retrieve(query as CFDictionary, nil)

if status == SimpleKeychainError.itemNotFound.status {
return false
}

try assertSuccess(forStatus: status)
return true
}
Expand Down Expand Up @@ -252,27 +259,28 @@ public extension SimpleKeychain {

extension SimpleKeychain {
func baseQuery(withKey key: String? = nil, data: Data? = nil) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.service
]
if let accessGroup = self.accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
var query = self.attributes
query[kSecClass as String] = kSecClassGenericPassword
query[kSecAttrService as String] = self.service

if let key = key {
query[kSecAttrAccount as String] = key
}
if let data = data {
query[kSecValueData as String] = data
}
if self.isSynchronizable {
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
if let accessGroup = self.accessGroup {
query[kSecAttrAccessGroup as String] = accessGroup
}
#if canImport(LocalAuthentication)
if let context = self.context {
query[kSecUseAuthenticationContext as String] = context
}
#endif
if self.isSynchronizable {
query[kSecAttrSynchronizable as String] = kCFBooleanTrue
}

return query
}

Expand All @@ -298,7 +306,7 @@ extension SimpleKeychain {
query[kSecAttrAccessControl as String] = access
} else {
#if os(macOS)
if self.isSynchronizable {
if self.isSynchronizable || query[kSecUseDataProtectionKeychain as String] as? Bool == true {
query[kSecAttrAccessible as String] = self.accessibility.rawValue
}
#else
Expand All @@ -308,8 +316,4 @@ extension SimpleKeychain {

return query
}

func attributes(withData data: Data) -> [String: Any] {
return [kSecValueData as String: data]
}
}
2 changes: 1 addition & 1 deletion SimpleKeychain/SimpleKeychainError.swift
Expand Up @@ -140,7 +140,7 @@ public struct SimpleKeychainError: LocalizedError, CustomDebugStringConvertible
/// The `OSStatus` of the Keychain operation can be accessed via the ``status`` property.
public static let other: SimpleKeychainError = .init(code: .other(status: 0))

/// Unknown error.
/// Unknown error. This is not a Keychain error but a SimpleKeychain failure. For example, being unable to cast the retrieved item.
public static let unknown: SimpleKeychainError = .init(code: .unknown(message: ""))
}

Expand Down
54 changes: 40 additions & 14 deletions SimpleKeychainTests/SimpleKeychainSpec.swift
Expand Up @@ -25,19 +25,23 @@ class SimpleKeychainSpec: QuickSpec {
expect(sut.accessibility) == Accessibility.afterFirstUnlock
expect(sut.accessControlFlags).to(beNil())
expect(sut.isSynchronizable) == false
expect(sut.attributes).to(beEmpty())
}

it("should init with custom values") {
sut = SimpleKeychain(service: KeychainService,
accessGroup: "Group",
accessibility: .whenUnlocked,
accessControlFlags: .userPresence,
synchronizable: true)
synchronizable: true,
attributes: ["foo": "bar"])
expect(sut.accessGroup) == "Group"
expect(sut.service) == KeychainService
expect(sut.accessibility) == Accessibility.whenUnlocked
expect(sut.accessControlFlags) == .userPresence
expect(sut.isSynchronizable) == true
expect(sut.attributes.count) == 1
expect(sut.attributes["foo"] as? String) == "bar"
}

#if canImport(LocalAuthentication)
Expand Down Expand Up @@ -105,7 +109,6 @@ class SimpleKeychainSpec: QuickSpec {
#if os(macOS)
it("should include limit all attribute when deleting all items") {
var limit: String?
sut = SimpleKeychain(service: KeychainService)
sut.remove = { query in
let key = kSecMatchLimit as String
limit = (query as NSDictionary).value(forKey: key) as? String
Expand All @@ -117,7 +120,6 @@ class SimpleKeychainSpec: QuickSpec {
#else
it("should not include limit all attribute when deleting all items") {
var limit: String? = ""
sut = SimpleKeychain(service: KeychainService)
sut.remove = { query in
let key = kSecMatchLimit as String
limit = (query as NSDictionary).value(forKey: key) as? String
Expand Down Expand Up @@ -243,7 +245,7 @@ class SimpleKeychainSpec: QuickSpec {
}

context("base query") {
it("should should contain default attributes") {
it("should contain default attributes") {
let query = sut.baseQuery()
expect((query[kSecClass as String] as? String)) == kSecClassGenericPassword as String
expect((query[kSecAttrService as String] as? String)) == sut.service
Expand All @@ -256,6 +258,22 @@ class SimpleKeychainSpec: QuickSpec {
#endif
}

it("should include additional attributes") {
let key = "foo"
let value = "bar"
sut = SimpleKeychain(attributes: [key: value])
let query = sut.baseQuery()
expect((query[key] as? String)) == value
}

it("should supersede additional attributes") {
let key = kSecAttrService as String
let value = "foo"
sut = SimpleKeychain(attributes: [key: value])
let query = sut.baseQuery()
expect((query[key] as? String)) == sut.service
}

it("should include account attribute") {
let key = "foo"
let query = sut.baseQuery(withKey: key)
Expand All @@ -269,28 +287,28 @@ class SimpleKeychainSpec: QuickSpec {
}

it("should include access group attribute") {
sut = SimpleKeychain(service: KeychainService, accessGroup: "foo")
sut = SimpleKeychain(accessGroup: "foo")
let query = sut.baseQuery()
expect((query[kSecAttrAccessGroup as String] as? String)) == sut.accessGroup
}

it("should include synchronizable attribute") {
sut = SimpleKeychain(service: KeychainService, synchronizable: true)
sut = SimpleKeychain(synchronizable: true)
let query = sut.baseQuery()
expect((query[kSecAttrSynchronizable as String] as? Bool)) == sut.isSynchronizable
}

#if canImport(LocalAuthentication)
it("should include context attribute") {
sut = SimpleKeychain(service: KeychainService, context: LAContext())
sut = SimpleKeychain(context: LAContext())
let query = sut.baseQuery()
expect((query[kSecUseAuthenticationContext as String] as? LAContext)) == sut.context
}
#endif
}

context("get all query") {
it("should should contain the base query") {
it("should contain the base query") {
expect(sut.getAllQuery).to(containBaseQuery(sut.baseQuery()))
}

Expand All @@ -306,7 +324,7 @@ class SimpleKeychainSpec: QuickSpec {
}

context("get one query") {
it("should should contain the base query") {
it("should contain the base query") {
let key = "foo"
expect(sut.getOneQuery(byKey: key)).to(containBaseQuery(sut.baseQuery(withKey: key)))
}
Expand All @@ -323,7 +341,7 @@ class SimpleKeychainSpec: QuickSpec {
}

context("set query") {
it("should should contain the base query") {
it("should contain the base query") {
let key = "foo"
let data = Data()
let query = sut.setQuery(forKey: key, data: data)
Expand All @@ -332,22 +350,30 @@ class SimpleKeychainSpec: QuickSpec {
}

it("should include access control attribute") {
sut = SimpleKeychain(service: KeychainService, accessControlFlags: .userPresence)
sut = SimpleKeychain(accessControlFlags: .userPresence)
let query = sut.setQuery(forKey: "foo", data: Data())
expect(query[kSecAttrAccessControl as String]).toNot(beNil())
}

#if os(macOS)
it("should not include accessibility attribute by default") {
let query = sut.setQuery(forKey: "foo", data: Data())
expect((query[kSecAttrAccessible as String] as? String)).to(beNil())
}

it("should include accessibility attribute when iCloud sharing is enabled") {
sut = SimpleKeychain(service: KeychainService, synchronizable: true)
sut = SimpleKeychain(synchronizable: true)
let query = sut.setQuery(forKey: "foo", data: Data())
let expectedAccessibility = sut.accessibility.rawValue as String
expect((query[kSecAttrAccessible as String] as? String)) == expectedAccessibility
}

it("should not include accessibility attribute when iCloud sharing is disabled") {
it("should include accessibility attribute when data protection is enabled") {
let attributes = [kSecUseDataProtectionKeychain as String: kCFBooleanTrue as Any]
sut = SimpleKeychain(attributes: attributes)
let query = sut.setQuery(forKey: "foo", data: Data())
expect((query[kSecAttrAccessible as String] as? String)).to(beNil())
let expectedAccessibility = sut.accessibility.rawValue as String
expect((query[kSecAttrAccessible as String] as? String)) == expectedAccessibility
}
#else
it("should include accessibility attribute") {
Expand Down
6 changes: 6 additions & 0 deletions V1_MIGRATION_GUIDE.md
@@ -1,5 +1,11 @@
# v1 Migration Guide

SimpleKeychain v1 includes a few significant changes:

- Improved error handling.
- Support for custom attributes.
- Support for sharing items with other devices through iCloud synchronization.

As expected with a major release, SimpleKeychain v1 contains breaking changes. Please review this guide thorougly to understand the changes required to migrate your application to v1.

---
Expand Down