ForgeCrypt
Secure storage backed by the iOS Keychain with hardware encryption. Unlike KVStoring, keys have no default values — a missing item returns nil, and errors are thrown explicitly. You never want a "default" password or token.
import ForgeCrypt CryptKey
Each secret is accessed through a CryptKey<Value>. The value must conform to Codable & Sendable. Each key specifies its accessibility level, controlling when the item can be read.
public struct CryptKey<Value: Codable & Sendable>: Sendable, Equatable {
public let key: String
public let accessibility: CryptAccessibility
public init(
key: String,
accessibility: CryptAccessibility = .afterFirstUnlock
)
} Defining Keys
enum TaskFlowAuthKeys {
static let accessToken = CryptKey<String>(
key: "taskflow.auth.accessToken",
accessibility: .afterFirstUnlock
)
static let refreshToken = CryptKey<String>(
key: "taskflow.auth.refreshToken",
accessibility: .afterFirstUnlock
)
static let credentials = CryptKey<OAuthCredentials>(
key: "taskflow.auth.oauth",
accessibility: .afterFirstUnlock
)
static let biometricSecret = CryptKey<Data>(
key: "taskflow.auth.biometric",
accessibility: .whenUnlockedThisDeviceOnly
)
} CryptAccessibility
Controls when a Keychain item can be read, based on device lock state. Maps to Apple's kSecAttrAccessible constants.
| Level | Readable When | Syncs to iCloud | Use Case |
|---|---|---|---|
.whenUnlocked | Device unlocked | Yes | Sensitive data viewed in foreground |
.afterFirstUnlock | After first unlock | Yes | Tokens for background sync |
.whenUnlockedThisDeviceOnly | Device unlocked | No | Biometric-gated secrets |
.afterFirstUnlockThisDeviceOnly | After first unlock | No | Device-specific tokens |
// Background-safe token (most common)
static let accessToken = CryptKey<String>(
key: "auth.token",
accessibility: .afterFirstUnlock
)
// Biometric secret — never leaves this device
static let biometricKey = CryptKey<Data>(
key: "auth.biometric",
accessibility: .whenUnlockedThisDeviceOnly
) CryptStoring Protocol
All Keychain stores conform to CryptStoring.
public protocol CryptStoring: Sendable {
func get<T: Codable>(_ key: CryptKey<T>) throws -> T?
func set<T: Codable>(_ value: T, for key: CryptKey<T>) throws
func delete<T: Codable>(_ key: CryptKey<T>) throws
func contains<T: Codable>(_ key: CryptKey<T>) throws -> Bool
func deleteAll() throws
} API Reference
get(_:)
Retrieves a value. Returns nil if the item does not exist. Throws on Keychain errors.
let token: String? = try keychain.get(AuthKeys.accessToken)
let creds: OAuthCredentials? = try keychain.get(AuthKeys.credentials)
if let token = try keychain.get(AuthKeys.accessToken) {
setAuthHeader(token)
} set(_:for:)
Stores a value. Overwrites if the item already exists (update-or-insert).
try keychain.set("eyJhbGciOiJIUzI1NiJ9...", for: AuthKeys.accessToken)
try keychain.set(oauthCredentials, for: AuthKeys.credentials) delete(_:)
Deletes an item. No-op if the item does not exist.
try keychain.delete(AuthKeys.accessToken) contains(_:)
Checks whether an item exists without loading the value.
let isLoggedIn = try keychain.contains(AuthKeys.accessToken) deleteAll()
Deletes all items managed by this store, scoped to its service and access group.
// Logout — remove all tokens
try keychain.deleteAll() CryptStore
Production implementation backed by the iOS Keychain. Uses kSecClassGenericPassword for all items. Fully Sendable with no mutable state.
// App-private Keychain
let keychain = CryptStore(service: "com.taskflow")
// Shared with widget/extension via access group
let shared = CryptStore(
service: "com.taskflow",
accessGroup: "group.com.taskflow.shared"
)
// With iCloud Keychain sync
let synced = CryptStore(
service: "com.taskflow",
synchronizable: true
) Configuration Parameters
| Parameter | Purpose | Default |
|---|---|---|
service | Scopes items (kSecAttrService). Typically your bundle ID. | Required |
accessGroup | Shares items with extensions/widgets. Must match your Keychain Sharing entitlement. | nil |
synchronizable | Syncs items via iCloud Keychain across devices. Do not enable for device-specific secrets. | false |
Encoding Strategy
The store picks the optimal encoding per type to minimize overhead.
| Type | Encoding | Overhead |
|---|---|---|
String | Raw UTF-8 bytes | None |
Data | Raw passthrough | None |
| Everything else | JSON via Codable | Minimal |
Difference from KVStoring
| KVStoring | CryptStoring | |
|---|---|---|
| Purpose | Settings, flags, cached data | Secrets, tokens, credentials |
| Missing value | Returns nil | Returns nil |
| Encryption | No (plaintext plist/JSON) | Yes (hardware-backed) |
| Observation | Via ReactiveKVStore wrapper | Not available |
| Errors | set/remove/removeAll throw | All operations throw |
| Key type | KVKey<T> (string only) | CryptKey<T> with accessibility |
CryptError
All Keychain operations throw typed errors. This is intentional — silent failures with secrets are dangerous.
public enum CryptError: LocalizedError, Sendable {
case itemNotFound
case encodingFailed(String)
case decodingFailed(String)
case securityError(OSStatus)
} Common OSStatus codes:
-25299(errSecDuplicateItem) — item already exists-25300(errSecItemNotFound) — item not found-25293(errSecAuthFailed) — authentication/access denied-34018— missing Keychain entitlement (common in tests/CI)
do {
try keychain.set(token, for: AuthKeys.accessToken)
} catch let error as CryptError {
switch error {
case .securityError(let status):
print("Keychain error (OSStatus \(status))")
case .encodingFailed(let detail):
print("Encoding failed: \(detail)")
default:
print(error.localizedDescription)
}
} InMemoryCrypt
Dictionary-backed implementation for tests and previews. No Keychain access, no entitlement requirements. Conforms to the same CryptStoring protocol.
let keychain = InMemoryCrypt()
try keychain.set("token123", for: AuthKeys.accessToken)
let token = try keychain.get(AuthKeys.accessToken) // "token123"
try keychain.delete(AuthKeys.accessToken)
let deleted = try keychain.get(AuthKeys.accessToken) // nil
try keychain.deleteAll() Note: InMemoryCrypt uses JSON encoding for all types (including String and Data), unlike CryptStore which uses optimized encoding. This difference is invisible to consumers since the protocol returns typed values.