// // AdminKeychain.swift // FTStore Admin // // The only place a secret is ever persisted by this app. The FTStore Admin // session token lives here — never in UserDefaults, never in the app bundle, // never in a cookie jar on disk. // // `ThisDeviceOnly` keeps the item out of iCloud Keychain and out of encrypted // backups, so a decrypted IPA (or a restored backup) on another device carries // no credential with it. `AfterFirstUnlock` rather than `WhenUnlocked` so a // relaunch straight from a notification, on a device that hasn't been unlocked // since, still finds the session. // import Foundation import Security enum AdminKeychain { /// The app's own bundle id. Until this app existed these screens lived inside /// FTStore Manager and used `app.ftstore.manager.admin`; that item is not /// readable from here (no shared keychain group), so the operator signs in /// once more and the Manager wipes its leftover on next launch. private static let _service = "app.ftstore.admin" /// The FTStore Admin session cookie value. static let sessionAccount = "session-token" @discardableResult static func set(_ value: String?, for account: String) -> Bool { guard let value, !value.isEmpty else { remove(account) return true } guard let data = value.data(using: .utf8) else { return false } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: _service, kSecAttrAccount as String: account, ] let attributes: [String: Any] = [ kSecValueData as String: data, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, ] // Update in place when the item exists; SecItemAdd would fail with // errSecDuplicateItem and silently leave the stale token behind. let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) if status == errSecSuccess { return true } guard status == errSecItemNotFound else { return false } var insert = query insert.merge(attributes) { current, _ in current } return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess } static func get(_ account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: _service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne, ] var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data, let string = String(data: data, encoding: .utf8), !string.isEmpty else { return nil } return string } static func remove(_ account: String) { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: _service, kSecAttrAccount as String: account, ] SecItemDelete(query as CFDictionary) } }