diff --git a/CHANGELOG.md b/CHANGELOG.md index d206e228cae..24c0f6187e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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://.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/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. diff --git a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift index 49db9bb1bfc..e467659a451 100644 --- a/apps/ios/Sources/Gateway/GatewaySettingsStore.swift +++ b/apps/ios/Sources/Gateway/GatewaySettingsStore.swift @@ -25,6 +25,7 @@ enum GatewaySettingsStore { private static let instanceIdAccount = "instanceId" private static let preferredGatewayStableIDAccount = "preferredStableID" private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID" + private static let lastGatewayConnectionAccount = "lastConnection" private static let talkProviderApiKeyAccountPrefix = "provider.apiKey." static func bootstrapPersistence() { @@ -140,11 +141,20 @@ enum GatewaySettingsStore { } } - private enum LastGatewayKind: String { + private enum LastGatewayKind: String, Codable { case manual 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? { guard let providerId = self.normalizedTalkProviderID(provider) else { return nil } let account = self.talkProviderApiKeyAccount(providerId: providerId) @@ -168,47 +178,93 @@ enum GatewaySettingsStore { } static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) { - let defaults = UserDefaults.standard - defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey) - defaults.set(host, forKey: self.lastGatewayHostDefaultsKey) - defaults.set(port, forKey: self.lastGatewayPortDefaultsKey) - defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) - defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) + let payload = LastGatewayConnectionData( + kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port) + self.saveLastGatewayConnectionData(payload) } static func saveLastGatewayConnectionDiscovered(stableID: String, useTLS: Bool) { - let defaults = UserDefaults.standard - defaults.set(LastGatewayKind.discovered.rawValue, forKey: self.lastGatewayKindDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey) - defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) - defaults.set(useTLS, forKey: self.lastGatewayTlsDefaultsKey) - defaults.set(stableID, forKey: self.lastGatewayStableIDDefaultsKey) + let payload = LastGatewayConnectionData( + kind: .discovered, stableID: stableID, useTLS: useTLS) + self.saveLastGatewayConnectionData(payload) } 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 stableID = defaults.string(forKey: self.lastGatewayStableIDDefaultsKey)? .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 kindRaw = defaults.string(forKey: self.lastGatewayKindDefaultsKey)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let kind = LastGatewayKind(rawValue: kindRaw) ?? .manual - - if kind == .discovered { - return .discovered(stableID: stableID, useTLS: useTLS) - } - let host = defaults.string(forKey: self.lastGatewayHostDefaultsKey)? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let port = defaults.integer(forKey: self.lastGatewayPortDefaultsKey) + .trimmingCharacters(in: .whitespacesAndNewlines) + let port = defaults.object(forKey: self.lastGatewayPortDefaultsKey) as? Int - // Back-compat: older builds persisted manual-style host/port without a kind marker. - guard !host.isEmpty, port > 0, port <= 65535 else { return nil } - return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID) + let payload = LastGatewayConnectionData( + kind: kind, stableID: stableID, useTLS: useTLS, + 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.lastGatewayHostDefaultsKey) defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey) @@ -355,9 +411,15 @@ enum GatewayDiagnostics { private static let maxLogBytes: Int64 = 512 * 1024 private static let keepLogBytes: Int64 = 256 * 1024 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? { - FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first? + FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .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() { guard let url = fileURL else { return } queue.async { self.truncateLogIfNeeded(url: url) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let timestamp = formatter.string(from: Date()) + let timestamp = self.isoFormatter.string(from: Date()) let line = "[\(timestamp)] gateway diagnostics started\n" if let data = line.data(using: .utf8) { self.appendToLog(url: url, data: data) + self.applyFileProtection(url: url) } } } static func log(_ message: String) { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let timestamp = formatter.string(from: Date()) + let timestamp = self.isoFormatter.string(from: Date()) let line = "[\(timestamp)] \(message)" logger.info("\(line, privacy: .public)") guard let url = fileURL else { return } queue.async { - self.logWritesSinceCheck += 1 - if self.logWritesSinceCheck >= self.logSizeCheckEveryWrites { - self.logWritesSinceCheck = 0 + let shouldTruncate = self.logWritesSinceCheck.withLock { count in + count += 1 + if count >= self.logSizeCheckEveryWrites { + count = 0 + return true + } + return false + } + if shouldTruncate { self.truncateLogIfNeeded(url: url) } let entry = line + "\n" diff --git a/apps/ios/Sources/Gateway/KeychainStore.swift b/apps/ios/Sources/Gateway/KeychainStore.swift index 1377d8517ef..c4f1871eedb 100644 --- a/apps/ios/Sources/Gateway/KeychainStore.swift +++ b/apps/ios/Sources/Gateway/KeychainStore.swift @@ -1,48 +1,16 @@ import Foundation -import Security +import OpenClawKit enum KeychainStore { static func loadString(service: String, 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? - let status = SecItemCopyMatching(query as CFDictionary, &item) - guard status == errSecSuccess, let data = item as? Data else { return nil } - return String(data: data, encoding: .utf8) + GenericPasswordKeychainStore.loadString(service: service, account: account) } static func saveString(_ value: String, service: String, account: String) -> Bool { - let data = Data(value.utf8) - 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 + GenericPasswordKeychainStore.saveString(value, service: service, account: account) } static func delete(service: String, account: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - ] - let status = SecItemDelete(query as CFDictionary) - return status == errSecSuccess || status == errSecItemNotFound + GenericPasswordKeychainStore.delete(service: service, account: account) } } diff --git a/apps/ios/Tests/GatewayConnectionControllerTests.swift b/apps/ios/Tests/GatewayConnectionControllerTests.swift index 5559e42086e..6bb7ce66ddc 100644 --- a/apps/ios/Tests/GatewayConnectionControllerTests.swift +++ b/apps/ios/Tests/GatewayConnectionControllerTests.swift @@ -71,18 +71,37 @@ import UIKit } @Test @MainActor func loadLastConnectionReadsSavedValues() { - withUserDefaults([:]) { - 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")) + 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") + + 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() { + 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([ "gateway.last.kind": "manual", "gateway.last.host": "", diff --git a/apps/ios/Tests/GatewaySettingsStoreTests.swift b/apps/ios/Tests/GatewaySettingsStoreTests.swift index d7e12f02c01..e7f5ad2b59d 100644 --- a/apps/ios/Tests/GatewaySettingsStoreTests.swift +++ b/apps/ios/Tests/GatewaySettingsStoreTests.swift @@ -27,6 +27,7 @@ private let lastGatewayDefaultsKeys = [ "gateway.last.tls", "gateway.last.stableID", ] +private let lastGatewayKeychainEntry = KeychainEntry(service: gatewayService, account: "lastConnection") private func snapshotDefaults(_ keys: [String]) -> [String: Any?] { let defaults = UserDefaults.standard @@ -84,9 +85,13 @@ private func withBootstrapSnapshots(_ body: () -> Void) { body() } -private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) { - let snapshot = snapshotDefaults(lastGatewayDefaultsKeys) - defer { restoreDefaults(snapshot) } +private func withLastGatewaySnapshot(_ body: () -> Void) { + let defaultsSnapshot = snapshotDefaults(lastGatewayDefaultsKeys) + let keychainSnapshot = snapshotKeychain([lastGatewayKeychainEntry]) + defer { + restoreDefaults(defaultsSnapshot) + restoreKeychain(keychainSnapshot) + } body() } @@ -135,7 +140,7 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) { } @Test func lastGateway_manualRoundTrip() { - withLastGatewayDefaultsSnapshot { + withLastGatewaySnapshot { GatewaySettingsStore.saveLastGatewayConnectionManual( host: "example.com", port: 443, @@ -147,28 +152,24 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) { } } - @Test func lastGateway_discoveredDoesNotPersistResolvedHostPort() { - withLastGatewayDefaultsSnapshot { - // Simulate a prior manual record that included host/port. - applyDefaults([ - "gateway.last.host": "10.0.0.99", - "gateway.last.port": 18789, - "gateway.last.tls": true, - "gateway.last.stableID": "manual|10.0.0.99|18789", - "gateway.last.kind": "manual", - ]) + @Test func lastGateway_discoveredOverwritesManual() { + withLastGatewaySnapshot { + GatewaySettingsStore.saveLastGatewayConnectionManual( + host: "10.0.0.99", + port: 18789, + useTLS: true, + stableID: "manual|10.0.0.99|18789") 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)) } } - @Test func lastGateway_backCompat_manualLoadsWhenKindMissing() { - withLastGatewayDefaultsSnapshot { + @Test func lastGateway_migratesFromUserDefaults() { + withLastGatewaySnapshot { + // Clear Keychain entry and plant legacy UserDefaults values. + applyKeychain([lastGatewayKeychainEntry: nil]) applyDefaults([ "gateway.last.kind": nil, "gateway.last.host": "example.org", @@ -179,6 +180,11 @@ private func withLastGatewayDefaultsSnapshot(_ body: () -> Void) { let loaded = GatewaySettingsStore.loadLastGatewayConnection() #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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift index a0cbcd375f6..fb3a89a2493 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift @@ -17,23 +17,41 @@ public struct GatewayTLSParams: Sendable { } public enum GatewayTLSStore { - private static let suiteName = "ai.openclaw.shared" - private static let keyPrefix = "gateway.tls." + private static let keychainService = "ai.openclaw.tls-pinning" - private static var defaults: UserDefaults { - UserDefaults(suiteName: suiteName) ?? .standard - } + // Legacy UserDefaults location used before Keychain migration. + private static let legacySuiteName = "ai.openclaw.shared" + private static let legacyKeyPrefix = "gateway.tls." public static func loadFingerprint(stableID: String) -> String? { - let key = self.keyPrefix + stableID - let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines) + self.migrateFromUserDefaultsIfNeeded(stableID: stableID) + let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: stableID)? + .trimmingCharacters(in: .whitespacesAndNewlines) if raw?.isEmpty == false { return raw } return nil } public static func saveFingerprint(_ value: String, stableID: String) { - let key = self.keyPrefix + stableID - self.defaults.set(value, forKey: key) + _ = GenericPasswordKeychainStore.saveString(value, service: self.keychainService, account: stableID) + } + + // 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) } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/GenericPasswordKeychainStore.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/GenericPasswordKeychainStore.swift new file mode 100644 index 00000000000..01603f7848b --- /dev/null +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/GenericPasswordKeychainStore.swift @@ -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, + ] + } +}