mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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