FS
ForgeStorage

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
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.

CryptKey.swift
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

Keys.swift
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
Accessibility.swift
// 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.

CryptStoring.swift
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.

get.swift
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).

set.swift
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.

delete.swift
try keychain.delete(AuthKeys.accessToken)

contains(_:)

Checks whether an item exists without loading the value.

contains.swift
let isLoggedIn = try keychain.contains(AuthKeys.accessToken)

deleteAll()

Deletes all items managed by this store, scoped to its service and access group.

deleteAll.swift
// 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.

Configuration.swift
// 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.

CryptError.swift
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)
ErrorHandling.swift
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.

InMemoryCrypt.swift
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.