SwiftSecurity

1.4.0

Modern Swift framework for Keychain API. Supports iOS, macOS, watchOS, tvOS and visionOS
dm-zharov/swift-security

What's New

2024-04-30T09:13:04Z

• Retrieves persistent reference after storing SecDataConvertible:

if case let .persistentReference(data) = try keychain.store(
    "8e9c0a7f",
    returning: .persistentReference, 
    query: .credential(for: "VPN")
) {
    // Handle persistent reference
}

• Improves Error Handling:

do {
    let token: String? = try keychain.store("8e9c0a7f", query: .credential(for: "OpenAI"))
} catch {
    switch error as? SwiftSecurityError {
    case .duplicateItem:
        // handle duplicate
    default:
        // unhandled
    }
}

SwiftSecurity

Platforms SPM supported License

SwiftSecurity is a modern Swift API for Apple Security framework (Keychain API, SharedWebCredentials API, etc). Secure the data your app manages in a much easier way with compile-time checks.

Features

How does SwiftSecurity differ from other wrappers?

  • Supports every Keychain item class (Generic & Internet Password, Key, Certificate and Identity).
  • Prevents creation of an incorrect set of attributes for items.
  • Compatible with CryptoKit and SwiftUI.
  • Clear of deprecated and legacy calls.

Installation

Requirements

  • iOS 14.0+ / macOS 11.0+ / Mac Catalyst 14.0+ / watchOS 7.0+ / tvOS 14.0+ / visionOS 1.0+
  • Swift 5.9

Swift Package Manager

To use the SwiftSecurity, add the following dependency in your Package.swift:

.package(url: "https://github.com/dm-zharov/swift-security.git", from: "1.0.0")

Finally, add import SwiftSecurity to your source code.

Quick Start

Basic

// Choose Keychain
let keychain = Keychain.default

// Store secret
try keychain.store("8e9c0a7f", query: .credential(for: "OpenAI"))

// Retrieve secret
let token: String? = try keychain.retrieve(.credential(for: "OpenAI"))

// Remove secret
try keychain.remove(.credential(for: "OpenAI"))

Basic (SwiftUI)

struct AuthView: View {
    @Credential("OpenAI") private var token: String?

    var body: some View {
        VStack {
            Button("Save") {
                // Store secret
                try? _token.store("8e9c0a7f")
            }
            Button("Delete") {
                // Remove secret
                try? _token.remove()
            }
        }
        .onChange(of: token) {
            if let token {
                // Use secret
            }
        }
    }
} 

Web Credential

// Store password for a website
try keychain.store(
    password, query: .credential(for: "username", space: .website("https://example.com"))
)

// Retrieve password for a website
let password: String? = try keychain.retrieve(
    .credential(for: "username", space: .website("https://example.com"))
)

For example, if you need to store distinct ports credentials for the same user working on the same server, you might further characterize the query by specifying protection space.

let space1 = WebProtectionSpace(host: "example.com", port: 443)
try keychain.store(password1, query: .credential(for: user, space: space1))

let space2 = WebProtectionSpace(host: "example.com", port: 8443)
try keychain.store(password2, query: .credential(for: user, space: space2))

Get Attributes

if let info = try keychain.info(for: .credential(for: "OpenAI")) {
    // Creation date
    print(info.creationDate)
    // Comment
    print(info.comment)
    ...
}

Remove All

try keychain.removeAll()

Advanced Usage

Query

// Create query
var query = SecItemQuery<GenericPassword>()

// Customize query
query.synchronizable = true
query.service = "OpenAI"
query.label = "OpenAI Access Token"

// Perform query
try keychain.store(secret, query: query, accessPolicy: AccessPolicy(.whenUnlocked, options: .biometryAny))
_ = try keychain.retrieve(query, authenticationContext: LAContext())
try keychain.remove(query)

Query prevents the creation of an incorrect set of attributes for item:

var query = SecItemQuery<InternetPassword>()
query.synchronizable = true  // ✅ Common
query.server = "example.com" // ✅ Only for `InternetPassword`
query.service = "OpenAI"     // ❌ Only for `GenericPassword`, so not accessible
query.keySizeInBits = 2048   // ❌ Only for `SecKey`, so not accessible

Possible queries:

SecItemQuery<GenericPassword>   // kSecClassGenericPassword
SecItemQuery<InternetPassword>  // kSecClassInternetPassword
SecItemQuery<SecKey>.           // kSecClassSecKey
SecItemQuery<SecCertificate>    // kSecClassSecCertificate
SecItemQuery<SecIdentity>       // kSecClassSecIdentity

CryptoKit

// Store private key
let privateKey = P256.KeyAgreement.PrivateKey()
try Keychain.default.store(privateKey, query: .privateKey(tag: "Alice"))

// Retrieve private key (+ public key)
let privateKey: P256.KeyAgreement.PrivateKey? = try Keychain.default.retrieve(.privateKey(tag: "Alice"))
let publicKey = privateKey.publicKey

Get Data & Persistent Reference

let value = try keychain.retrieve([.data, .persistentReference], query: .credential(for: "OpenAI"))
if case let .dictionary(info) = value {
    // Data
    info.data
    // Persistent Reference
    info.persistentReference
}

Debug

// Print Query (or use LLDB po command)
print(query.debugDescription) // ["Class: GenericPassword", ..., "Service: OpenAI"]

// Print Keychain 
print(keychain.debugDescription)

Error Handling

do {
    let token: String? = try Keychain.default.store("8e9c0a7f", query: .credential(for: "OpenAI"))
} catch {
    switch error as? SwiftSecurityError {
    case .duplicateItem:
        // handle duplicate
    default:
        // unhandled
    }
}

How to Choose Keychain

Default

let keychain = Keychain.default

The system considers the first item in the list of keychain access groups to be the app’s default access group, evaluated in this order:

  • The optional Keychain Access Groups Entitlement holds an array of strings, each of which names an access group.
  • Application identifier, formed as the team identifier (team ID) plus the bundle identifier (bundle ID). For example, J42EP42PB2.com.example.app.

If the Keychain Sharing capability is not enabled, the default access group is app ID.

Note

To enable macOS support, make sure to include the Keychain Sharing (macOS) capability and create a group ${TeamIdentifierPrefix}com.example.app, to prevent errors in operations. This sharing group is automatically generated for other platforms and accessible without capability. You could refer to TestHost for information regarding project configuration.

Sharing within Keychain Group

If you prefer not to rely on the automatic behavior of default storage selection, you have the option to explicitly specify a keychain sharing group.

let keychain = Keychain(accessGroup: .keychainGroup(teamID: "J42EP42PB2", nameID: "com.example.app"))

Sharing within App Group

Sharing could also be achieved by using App Groups capability. Unlike a keychain sharing group, the app group can’t automatically became the default storage for keychain items. You might already be using an app group, so it's probably would be the most convenient choice.

let keychain = Keychain(accessGroup: .appGroupID("group.com.example.app"))

Note

Use Sharing within Keychain Group for sharing on macOS, as the described behavior is not present on this platform. There's no issue with using one sharing solution on one platform and a different one on another.

🔓 Protection with Face ID (Touch ID) and Passcode

Store protected item

// Store with specified `AccessPolicy`
try keychain.store(
    secret,
    query: .credential(for: "FBI"),
    accessPolicy: AccessPolicy(.whenUnlocked, options: .userPresence) // Requires biometry/passcode authentication
)

Retrieve protected item

If you request the protected item, an authentication screen will automatically appear.

// Retrieve value
try keychain.retrieve(.credential(for: "FBI"))

If you want to manually authenticate before making a request or customize authentication screen, provide LAContext to the retrieval method.

// Create an LAContext
var context = LAContext()

// Authenticate
do {
    let success = try await context.evaluatePolicy(
        .deviceOwnerAuthentication,
        localizedReason: "Authenticate to proceed." // Authentication prompt
    )
} else {
    // Handle LAError error
}

// Check authentication result 
if success {
    // Retrieve value
    try keychain.retrieve(.credential(for: "FBI"), authenticationContext: context)
}

Warning

Include the NSFaceIDUsageDescription key in your app’s Info.plist file. Otherwise, authentication request may fail.

🔖 Data Types

You can store, retrieve, and remove various types of values.

Foundation:
    - Data // GenericPassword, InternetPassword
    - String // GenericPassword, InternetPassword
CryptoKit:
    - SymmetricKey // GenericPassword
    - Curve25519 -> PrivateKey // GenericPassword
    - SecureEnclave.P256 -> PrivateKey // GenericPassword (Key Data is Persistent Identifier)
    - P256, P384, P521 -> PrivateKey // SecKey (ANSI x9.63 Elliptic Curves)
SwiftSecurity:
    - X509.DER.Data // SecCertificate (DER-Encoded X.509 Data)
    - PKCS12.Data // SecIdentity  (PKCS #12 Blob)

To add support for custom types, you can extend them by conforming to the following protocols.

// Store as Data (GenericPassword, InternetPassword)
extension CustomType: SecDataConvertible {}

// Store as Key (ANSI x9.63, Elliptic Curves)
extension CustomType: SecKeyConvertible {}

// Store as Certificate (X.509)
extension CustomType: SecCertificateConvertible {}

// Import as Identity (PKCS #12)
extension CustomType: SecIdentityConvertible {}

These protocols are inspired by Apple's sample code from the Storing CryptoKit Keys in the Keychain article.

🔑 Shared Web Credential

Tip

SharedWebCredentials API makes it possible to share credentials with the website counterpart. For example, a user may log in to a website in Safari and save credentials to the iCloud Keychain. Later, the user may run an app from the same developer, and instead of asking the user to reenter a username and password, it could access the existing credentials. The user can create new accounts, update passwords, or delete account from within the app. These changes should be saved from the app to be used by Safari.

// Store
SharedWebCredential.store("https://example.com", account: "username", password: "secret") { result in
    switch result {
    case .failure(let error):
        // Handle error
    case .success:
        // Handle success
    }
}

// Remove
SharedWebCredential.remove("https://example.com", account: "username") { result in
    switch result {
    case .failure(let error):
        // Handle error
    case .success:
        // Handle success
    }
}

// Retrieve
// - Use `ASAuthorizationController` to make an `ASAuthorizationPasswordRequest`.

🔒 Secure Data Generator

// Data with 20 uniformly distributed random bytes
let randomData = try SecureRandomDataGenerator(count: 20).next()

Security

The framework’s default behavior provides a reasonable trade-off between security and accessibility.

  • kSecUseDataProtectionKeychain: true helps to improve the portability of code across platforms. Can't be changed.
  • kSecAttrAccessibleWhenUnlocked makes keychain items accessible from background processes. Changeable by AccessPolicy.

Communication

  • If you found a bug, open an issue.
  • If you have a feature request, open an issue.
  • If you want to contribute, submit a pull request.

Knowledge

Author

Dmitriy Zharov, contact@zharov.dev

License

SwiftSecurity is available under the MIT license. See the LICENSE file for more info.

Description

  • Swift Tools 5.9.0
View More Packages from this Author

Dependencies

  • None
Last updated: Tue Apr 30 2024 23:55:17 GMT-0900 (Hawaii-Aleutian Daylight Time)