iOS Security Stack 1/5: Keychain Migrations + Tests (#33029)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: da2f8f6141
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-03-03 16:15:20 +00:00
committed by GitHub
parent 606cd0d591
commit ec0eb9f8c3
7 changed files with 268 additions and 108 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow. - Discord/channel resolution: default bare numeric recipients to channels, harden allowlist numeric ID handling with safe fallbacks, and avoid inbound WS heartbeat stalls. (#33142) Thanks @thewilloftheshadow.
- Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf - Exec heartbeat routing: scope exec-triggered heartbeat wakes to agent session keys so unrelated agents are no longer awakened by exec events, while preserving legacy unscoped behavior for non-canonical session keys. (#32724) thanks @altaywtf
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman. - macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
- iOS/Gateway keychain hardening: move gateway metadata and TLS fingerprints to device keychain storage with safer migration behavior and rollback-safe writes to reduce credential loss risk during upgrades. (#33029) thanks @mbelinky.
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
- Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee. - Telegram/plugin outbound hook parity: run `message_sending` + `message_sent` in Telegram reply delivery, include reply-path hook metadata (`mediaUrls`, `threadId`), and report `message_sent.success=false` when hooks blank text and no outbound message is delivered. (#32649) Thanks @KimGLee.
- Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.

View File

@@ -25,6 +25,7 @@ enum GatewaySettingsStore {
private static let instanceIdAccount = "instanceId" private static let instanceIdAccount = "instanceId"
private static let preferredGatewayStableIDAccount = "preferredStableID" private static let preferredGatewayStableIDAccount = "preferredStableID"
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
private static let lastGatewayConnectionAccount = "lastConnection"
private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." private static let talkProviderApiKeyAccountPrefix = "provider.apiKey."
static func bootstrapPersistence() { static func bootstrapPersistence() {
@@ -140,11 +141,20 @@ enum GatewaySettingsStore {
} }
} }
private enum LastGatewayKind: String { private enum LastGatewayKind: String, Codable {
case manual case manual
case discovered case discovered
} }
/// JSON-serializable envelope stored as a single Keychain entry.
private struct LastGatewayConnectionData: Codable {
var kind: LastGatewayKind
var stableID: String
var useTLS: Bool
var host: String?
var port: Int?
}
static func loadTalkProviderApiKey(provider: String) -> String? { static func loadTalkProviderApiKey(provider: String) -> String? {
guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } guard let providerId = self.normalizedTalkProviderID(provider) else { return nil }
let account = self.talkProviderApiKeyAccount(providerId: providerId) let account = self.talkProviderApiKeyAccount(providerId: providerId)
@@ -168,47 +178,93 @@ enum GatewaySettingsStore {
} }
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let defaults = UserDefaults.standard let payload = LastGatewayConnectionData(
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
defaults.set(host, forKey: self.lastGatewayHostDefaultsKey) self.saveLastGatewayConnectionData(payload)
defaults.set(port, forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
} }
static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) { static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) {
let defaults = UserDefaults.standard let payload = LastGatewayConnectionData(
defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey) kind: .discovered, stableID: stableID, useTLS: useTLS)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) self.saveLastGatewayConnectionData(payload)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey)
defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey)
} }
static func loadLastGatewayConnection() -> LastGatewayConnection? { static func loadLastGatewayConnection() -> LastGatewayConnection? {
// Migrate legacy UserDefaults entries on first access.
self.migrateLastGatewayFromUserDefaultsIfNeeded()
guard let json = KeychainStore.loadString(
service: self.gatewayService, account: self.lastGatewayConnectionAccount),
let data = json.data(using: .utf8),
let stored = try? JSONDecoder().decode(LastGatewayConnectionData.self, from: data)
else { return nil }
let stableID = stored.stableID.trimmingCharacters(in: .whitespacesAndNewlines)
guard !stableID.isEmpty else { return nil }
if stored.kind == .discovered {
return .discovered(stableID: stableID, useTLS: stored.useTLS)
}
let host = (stored.host ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
let port = stored.port ?? 0
guard !host.isEmpty, port > 0, port <= 65535 else { return nil }
return .manual(host: host, port: port, useTLS: stored.useTLS, stableID: stableID)
}
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
_ = KeychainStore.delete(
service: self.gatewayService, account: self.lastGatewayConnectionAccount)
// Clean up any legacy UserDefaults entries.
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
}
@discardableResult
private static func saveLastGatewayConnectionData(_ payload: LastGatewayConnectionData) -> Bool {
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else { return false }
return KeychainStore.saveString(
json, service: self.gatewayService, account: self.lastGatewayConnectionAccount)
}
/// Migrate legacy UserDefaults gateway.last.* keys into a single Keychain entry.
private static func migrateLastGatewayFromUserDefaultsIfNeeded() {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? let stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !stableID.isEmpty else { return nil } guard !stableID.isEmpty else { return }
// Already migrated if Keychain entry exists.
if KeychainStore.loadString(
service: self.gatewayService, account: self.lastGatewayConnectionAccount) != nil
{
// Clean up legacy keys.
self.removeLastGatewayDefaults(defaults)
return
}
let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey) let useTLS = defaults.bool(forKey: self.lastGatewayTlsDefaultsKey)
let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? let kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual
if kind == .discovered {
return .discovered(stableID: stableID, useTLS: useTLS)
}
let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" .trimmingCharacters(in: .whitespacesAndNewlines)
let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) let port = defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int
// Back-compat: older builds persisted manual-style host/port without a kind marker. let payload = LastGatewayConnectionData(
guard !host.isEmpty, port > 0, port <= 65535 else { return nil } kind: kind, stableID: stableID, useTLS: useTLS,
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) host: kind == .manual ? host : nil,
port: kind == .manual ? port : nil)
guard self.saveLastGatewayConnectionData(payload) else { return }
self.removeLastGatewayDefaults(defaults)
} }
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) { private static func removeLastGatewayDefaults(_ defaults: UserDefaults) {
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey) defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
@@ -355,9 +411,15 @@ enum GatewayDiagnostics {
private static let maxLogBytes: Int64 = 512 * 1024 private static let maxLogBytes: Int64 = 512 * 1024
private static let keepLogBytes: Int64 = 256 * 1024 private static let keepLogBytes: Int64 = 256 * 1024
private static let logSizeCheckEveryWrites = 50 private static let logSizeCheckEveryWrites = 50
nonisolated(unsafe) private static var logWritesSinceCheck = 0 private static let logWritesSinceCheck = OSAllocatedUnfairLock(initialState: 0)
private static let isoFormatter: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
private static var fileURL: URL? { private static var fileURL: URL? {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("openclaw-gateway.log") .appendingPathComponent("openclaw-gateway.log")
} }
@@ -404,32 +466,41 @@ enum GatewayDiagnostics {
} }
} }
private static func applyFileProtection(url: URL) {
try? FileManager.default.setAttributes(
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
ofItemAtPath: url.path)
}
static func bootstrap() { static func bootstrap() {
guard let url = fileURL else { return } guard let url = fileURL else { return }
queue.async { queue.async {
self.truncateLogIfNeeded(url: url) self.truncateLogIfNeeded(url: url)
let formatter = ISO8601DateFormatter() let timestamp = self.isoFormatter.string(from: Date())
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] gateway diagnostics started\n" let line = "[\(timestamp)] gateway diagnostics started\n"
if let data = line.data(using: .utf8) { if let data = line.data(using: .utf8) {
self.appendToLog(url: url, data: data) self.appendToLog(url: url, data: data)
self.applyFileProtection(url: url)
} }
} }
} }
static func log(_ message: String) { static func log(_ message: String) {
let formatter = ISO8601DateFormatter() let timestamp = self.isoFormatter.string(from: Date())
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let timestamp = formatter.string(from: Date())
let line = "[\(timestamp)] \(message)" let line = "[\(timestamp)] \(message)"
logger.info("\(line, privacy: .public)") logger.info("\(line, privacy: .public)")
guard let url = fileURL else { return } guard let url = fileURL else { return }
queue.async { queue.async {
self.logWritesSinceCheck += 1 let shouldTruncate = self.logWritesSinceCheck.withLock { count in
if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites { count += 1
self.logWritesSinceCheck = 0 if count >= self.logSizeCheckEveryWrites {
count = 0
return true
}
return false
}
if shouldTruncate {
self.truncateLogIfNeeded(url: url) self.truncateLogIfNeeded(url: url)
} }
let entry = line + "\n" let entry = line + "\n"

View File

@@ -1,48 +1,16 @@
import Foundation import Foundation
import Security import OpenClawKit
enum KeychainStore { enum KeychainStore {
static func loadString(service: String, account: String) -> String? { static func loadString(service: String, account: String) -> String? {
let query: [String: Any] = [ GenericPasswordKeychainStore.loadString(service: service, account: account)
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
} }
static func saveString(_ value: String, service: String, account: String) -> Bool { static func saveString(_ value: String, service: String, account: String) -> Bool {
let data = Data(value.utf8) GenericPasswordKeychainStore.saveString(value, service: service, account: account)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let update: [String: Any] = [kSecValueData as String: data]
let status = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if status == errSecSuccess { return true }
if status != errSecItemNotFound { return false }
var insert = query
insert[kSecValueData as String] = data
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
return SecItemAdd(insert as CFDictionary, nil) == errSecSuccess
} }
static func delete(service: String, account: String) -> Bool { static func delete(service: String, account: String) -> Bool {
let query: [String: Any] = [ GenericPasswordKeychainStore.delete(service: service, account: account)
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
} }
} }

View File

@@ -71,18 +71,37 @@ import UIKit
} }
@Test @MainActor func loadLastConnectionReadsSavedValues() { @Test @MainActor func loadLastConnectionReadsSavedValues() {
withUserDefaults([:]) { let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
GatewaySettingsStore.saveLastGatewayConnectionManual( defer {
host: "gateway.example.com", if let prior {
port: 443, _ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
useTLS: true, } else {
stableID: "manual|gateway.example.com|443") _ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
let loaded = GatewaySettingsStore.loadLastGatewayConnection() }
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
} }
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "gateway.example.com",
port: 443,
useTLS: true,
stableID: "manual|gateway.example.com|443")
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
} }
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() { @Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
let prior = KeychainStore.loadString(service: "ai.openclaw.gateway", account: "lastConnection")
defer {
if let prior {
_ = KeychainStore.saveString(prior, service: "ai.openclaw.gateway", account: "lastConnection")
} else {
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
}
}
_ = KeychainStore.delete(service: "ai.openclaw.gateway", account: "lastConnection")
// Plant legacy UserDefaults with invalid host/port to exercise migration + validation.
withUserDefaults([ withUserDefaults([
"gateway.last.kind": "manual", "gateway.last.kind": "manual",
"gateway.last.host": "", "gateway.last.host": "",

View File

@@ -27,6 +27,7 @@ private let lastGatewayDefaultsKeys = [
"gateway.last.tls", "gateway.last.tls",
"gateway.last.stableID", "gateway.last.stableID",
] ]
private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection")
private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { private func snapshotDefaults(_ keys: [String]) -> [String: Any?] {
let defaults = UserDefaults.standard let defaults = UserDefaults.standard
@@ -84,9 +85,13 @@ private func withBootstrapSnapshots(_ body: () -> Void) {
body() body()
} }
private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) { private func withLastGatewaySnapshot(_ body: () -> Void) {
let snapshot = snapshotDefaults(lastGatewayDefaultsKeys) let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys)
defer { restoreDefaults(snapshot) } let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry])
defer {
restoreDefaults(defaultsSnapshot)
restoreKeychain(keychainSnapshot)
}
body() body()
} }
@@ -135,7 +140,7 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
} }
@Test func lastGateway_manualRoundTrip() { @Test func lastGateway_manualRoundTrip() {
withLastGatewayDefaultsSnapshot { withLastGatewaySnapshot {
GatewaySettingsStore.saveLastGatewayConnectionManual( GatewaySettingsStore.saveLastGatewayConnectionManual(
host: "example.com", host: "example.com",
port: 443, port: 443,
@@ -147,28 +152,24 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
} }
} }
@Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() { @Test func lastGateway_discoveredOverwritesManual() {
withLastGatewayDefaultsSnapshot { withLastGatewaySnapshot {
// Simulate a prior manual record that included host/port. GatewaySettingsStore.saveLastGatewayConnectionManual(
applyDefaults([ host: "10.0.0.99",
"gateway.last.host": "10.0.0.99", port: 18789,
"gateway.last.port": 18789, useTLS: true,
"gateway.last.tls": true, stableID: "manual|10.0.0.99|18789")
"gateway.last.stableID": "manual|10.0.0.99|18789",
"gateway.last.kind": "manual",
])
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true) GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: "gw|abc", useTLS: true)
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.host") == nil)
#expect(defaults.object(forKey: "gateway.last.port") == nil)
#expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true)) #expect(GatewaySettingsStore.loadLastGatewayConnection() == .discovered(stableID: "gw|abc", useTLS: true))
} }
} }
@Test func lastGateway_backCompat_manualLoadsWhenKindMissing() { @Test func lastGateway_migratesFromUserDefaults() {
withLastGatewayDefaultsSnapshot { withLastGatewaySnapshot {
// Clear Keychain entry and plant legacy UserDefaults values.
applyKeychain([lastGatewayKeychainEntry: nil])
applyDefaults([ applyDefaults([
"gateway.last.kind": nil, "gateway.last.kind": nil,
"gateway.last.host": "example.org", "gateway.last.host": "example.org",
@@ -179,6 +180,11 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) {
let loaded = GatewaySettingsStore.loadLastGatewayConnection() let loaded = GatewaySettingsStore.loadLastGatewayConnection()
#expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789")) #expect(loaded == .manual(host: "example.org", port: 18789, useTLS: false, stableID: "manual|example.org|18789"))
// Legacy keys should be cleaned up after migration.
let defaults = UserDefaults.standard
#expect(defaults.object(forKey: "gateway.last.stableID") == nil)
#expect(defaults.object(forKey: "gateway.last.host") == nil)
} }
} }

View File

@@ -17,23 +17,41 @@ public struct GatewayTLSParams: Sendable {
} }
public enum GatewayTLSStore { public enum GatewayTLSStore {
private static let suiteName = "ai.openclaw.shared" private static let keychainService = "ai.openclaw.tls-pinning"
private static let keyPrefix = "gateway.tls."
private static var defaults: UserDefaults { // Legacy UserDefaults location used before Keychain migration.
UserDefaults(suiteName: suiteName) ?? .standard private static let legacySuiteName = "ai.openclaw.shared"
} private static let legacyKeyPrefix = "gateway.tls."
public static func loadFingerprint(stableID: String) -> String? { public static func loadFingerprint(stableID: String) -> String? {
let key = self.keyPrefix + stableID self.migrateFromUserDefaultsIfNeeded(stableID: stableID)
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if raw?.isEmpty == false { return raw } if raw?.isEmpty == false { return raw }
return nil return nil
} }
public static func saveFingerprint(_ value: String, stableID: String) { public static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID _ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID)
self.defaults.set(value, forKey: key) }
// MARK: - Migration
/// On first Keychain read for a given stableID, move any legacy UserDefaults
/// fingerprint into Keychain and remove the old entry.
private static func migrateFromUserDefaultsIfNeeded(stableID: String) {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
let legacyKey = self.legacyKeyPrefix + stableID
guard let existing = defaults.string(forKey: legacyKey)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!existing.isEmpty
else { return }
if GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID) == nil {
guard GenericPasswordKeychainStore.saveString(existing, service: self.keychainService, account: stableID) else {
return
}
}
defaults.removeObject(forKey: legacyKey)
} }
} }

View File

@@ -0,0 +1,77 @@
import Foundation
import Security
public enum GenericPasswordKeychainStore {
public static func loadString(service: String, account: String) -> String? {
guard let data = self.loadData(service: service, account: account) else { return nil }
return String(data: data, encoding: .utf8)
}
@discardableResult
public static func saveString(
_ value: String,
service: String,
account: String,
accessible: CFString = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
) -> Bool {
self.saveData(Data(value.utf8), service: service, account: account, accessible: accessible)
}
@discardableResult
public static func delete(service: String, account: String) -> Bool {
let query = self.baseQuery(service: service, account: account)
let status = SecItemDelete(query as CFDictionary)
return status == errSecSuccess || status == errSecItemNotFound
}
private static func loadData(service: String, account: String) -> Data? {
var query = self.baseQuery(service: service, account: account)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let data = item as? Data else { return nil }
return data
}
@discardableResult
private static func saveData(
_ data: Data,
service: String,
account: String,
accessible: CFString
) -> Bool {
let query = self.baseQuery(service: service, account: account)
let previousData = self.loadData(service: service, account: account)
let deleteStatus = SecItemDelete(query as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
return false
}
var insert = query
insert[kSecValueData as String] = data
insert[kSecAttrAccessible as String] = accessible
if SecItemAdd(insert as CFDictionary, nil) == errSecSuccess {
return true
}
// Best-effort rollback: preserve prior value if replacement fails.
guard let previousData else { return false }
var rollback = query
rollback[kSecValueData as String] = previousData
rollback[kSecAttrAccessible as String] = accessible
_ = SecItemDelete(query as CFDictionary)
_ = SecItemAdd(rollback as CFDictionary, nil)
return false
}
private static func baseQuery(service: String, account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
}