mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:38:38 +00:00
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:
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user