Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayTLSPinning.swift
T
Peter Steinberger ac89350327 feat(apps): review durable approvals on mobile (#104913)
* feat(apps): Android, iPhone, and Watch approval clients

Squash-rebased #103912 segment onto the deep-links tip on current main.
Native approval surfaces: iOS approval presentation with gateway-switch
lease preservation and resolution fencing, watchOS inbox + approval
actions with shipped-shape payload codec, Android approval notices with
publication-tokened dismissal. Native i18n inventory regenerated.

(cherry picked from commit 428a76670ffeede54248b7bd7aa4438e2589851b)
(cherry picked from commit 80225d5707c3645eeea5435f266131037b50ede6)
(cherry picked from commit 2a23b714dc30d773a294aa45adc617e46b80732e)
(cherry picked from commit 9ff1153827769e2cbab7c11c153f0f8634662c28)
(cherry picked from commit 5b25723525bd562e5f97478af492f7b46ad30fd1)
(cherry picked from commit 8c80e8467b5fe89aad0d4c74a0573f1600ed3af9)
(cherry picked from commit ad4037bc9846bf6f082b41c129ee68aa344576c3)
(cherry picked from commit fdf767dd662cff3c8a5a6d571f38f153410651ca)
(cherry picked from commit 00c120376ffd992ea68ce29254a9bc0a25ed1740)
(cherry picked from commit f36a95213e561ceadc9da799e7f7803f9905844f)
(cherry picked from commit e2c25cbe2baacab44d21871d8cb6734704f065ac)
(cherry picked from commit 7c4fda519080486d341a9f4df36d63f9e24b1235)
(cherry picked from commit 1b3d4eda3dc5988012124597f9454ae21fb187a1)
(cherry picked from commit 2a606197227b0221d5f21f0fb92bdce5bf57eeec)
(cherry picked from commit 6f0c3865677f5988f4d1bccce8e46a0949c18ea2)
(cherry picked from commit 784a5857b7ade84b42866b8b7789d315ff04eadd)
(cherry picked from commit cbf294e026841c9bc2799da0fc7db666a69c52db)

* fix(apps): harden approval reconciliation and watch states
2026-07-11 19:59:07 -07:00

349 lines
13 KiB
Swift

import CryptoKit
import Foundation
import Security
public struct GatewayTLSParams: Sendable {
public let required: Bool
public let expectedFingerprint: String?
public let allowTOFU: Bool
public let storeKey: String?
public init(required: Bool, expectedFingerprint: String?, allowTOFU: Bool, storeKey: String?) {
self.required = required
self.expectedFingerprint = expectedFingerprint
self.allowTOFU = allowTOFU
self.storeKey = storeKey
}
}
public enum GatewayTLSValidationFailureKind: String, Sendable {
case pinMismatch
case certificateUnavailable
case untrustedCertificate
}
public struct GatewayTLSValidationFailure: Equatable, Sendable {
public let kind: GatewayTLSValidationFailureKind
public let host: String
public let storeKey: String?
public let expectedFingerprint: String?
public let observedFingerprint: String?
public let systemTrustOk: Bool
public init(
kind: GatewayTLSValidationFailureKind,
host: String,
storeKey: String?,
expectedFingerprint: String?,
observedFingerprint: String?,
systemTrustOk: Bool)
{
self.kind = kind
self.host = host
self.storeKey = storeKey
self.expectedFingerprint = expectedFingerprint
self.observedFingerprint = observedFingerprint
self.systemTrustOk = systemTrustOk
}
}
public struct GatewayTLSValidationError: LocalizedError, Sendable {
public let failure: GatewayTLSValidationFailure
public let context: String
public init(failure: GatewayTLSValidationFailure, context: String) {
self.failure = failure
self.context = context
}
public var errorDescription: String? {
let prefix = self.context.trimmingCharacters(in: .whitespacesAndNewlines)
switch self.failure.kind {
case .pinMismatch:
let expected = self.failure.expectedFingerprint ?? "unknown"
let observed = self.failure.observedFingerprint ?? "unknown"
let mismatch = "expected \(expected), observed \(observed)"
return "\(prefix): TLS certificate pin mismatch for \(self.failure.host) (\(mismatch))"
case .certificateUnavailable:
return "\(prefix): TLS certificate unavailable for \(self.failure.host)"
case .untrustedCertificate:
return "\(prefix): TLS certificate is not trusted for \(self.failure.host)"
}
}
}
public protocol GatewayTLSFailureProviding: AnyObject {
func consumeLastTLSFailure() -> GatewayTLSValidationFailure?
}
public protocol GatewayDeviceTokenRetryTrustProviding: AnyObject {
var allowsDeviceTokenRetryAuth: Bool { get }
}
enum GatewayTLSFirstUsePolicy {
static func allowsFirstUsePin(systemTrustOk: Bool) -> Bool {
systemTrustOk
}
}
public enum GatewayTLSStore {
private static let keychainService = "ai.openclaw.tls-pinning"
private static let keychainAccountPrefix = "fingerprint.v2."
// 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? {
guard let account = self.keychainAccount(stableID: stableID) else { return nil }
self.migrateLegacyFingerprintIfNeeded(stableID: stableID, account: account)
let raw = GenericPasswordKeychainStore.loadString(service: self.keychainService, account: account)?
.trimmingCharacters(in: .whitespacesAndNewlines)
if raw?.isEmpty == false { return raw }
return nil
}
public static func saveFingerprint(_ value: String, stableID: String) {
guard let account = self.keychainAccount(stableID: stableID),
GenericPasswordKeychainStore.saveString(
value,
service: self.keychainService,
account: account)
else { return }
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
}
@discardableResult
public static func replaceFingerprint(_ value: String, stableID: String) -> Bool {
guard let account = self.keychainAccount(stableID: stableID),
GenericPasswordKeychainStore.saveString(
value,
service: self.keychainService,
account: account)
else {
return false
}
return self.clearSafeLegacyFingerprint(stableID: stableID)
}
@discardableResult
public static func clearFingerprint(stableID: String) -> Bool {
guard let account = self.keychainAccount(stableID: stableID) else { return false }
let removedCanonical = GenericPasswordKeychainStore.delete(
service: self.keychainService,
account: account)
let removedLegacy = self.clearSafeLegacyFingerprint(stableID: stableID)
return removedCanonical && removedLegacy
}
@discardableResult
public static func clearAllFingerprints() -> Bool {
let removedKeychain = SecItemDelete([
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: self.keychainService,
] as CFDictionary)
self.clearAllLegacyFingerprints()
return removedKeychain == errSecSuccess || removedKeychain == errSecItemNotFound
}
// MARK: - Migration
/// Legacy raw Keychain/UserDefaults keys can apply Unicode equivalence without
/// embedding their owner. Only ASCII owners are safe to attribute and migrate.
private static func migrateLegacyFingerprintIfNeeded(stableID: String, account: String) {
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return }
let canonical = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString(
service: self.keychainService,
account: account))
if canonical != nil {
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
return
}
let legacyKeychain = self.normalizedFingerprint(GenericPasswordKeychainStore.loadString(
service: self.keychainService,
account: stableID))
let defaults = UserDefaults(suiteName: self.legacySuiteName)
let legacyDefaults = self.normalizedFingerprint(defaults?.string(
forKey: self.legacyKeyPrefix + stableID))
guard let existing = legacyKeychain ?? legacyDefaults,
GenericPasswordKeychainStore.saveString(
existing,
service: self.keychainService,
account: account)
else { return }
_ = self.clearSafeLegacyFingerprint(stableID: stableID)
}
private static func keychainAccount(stableID: String) -> String? {
guard !stableID.isEmpty else { return nil }
let component = Data(stableID.utf8).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
return self.keychainAccountPrefix + component
}
private static func canSafelyReadLegacyRawStorageKey(_ stableID: String) -> Bool {
!stableID.isEmpty &&
!stableID.hasPrefix(self.keychainAccountPrefix) &&
stableID.unicodeScalars.allSatisfy(\.isASCII)
}
private static func normalizedFingerprint(_ value: String?) -> String? {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return value.isEmpty ? nil : value
}
@discardableResult
private static func clearSafeLegacyFingerprint(stableID: String) -> Bool {
guard self.canSafelyReadLegacyRawStorageKey(stableID) else { return true }
let removedKeychain = GenericPasswordKeychainStore.delete(
service: self.keychainService,
account: stableID)
UserDefaults(suiteName: self.legacySuiteName)?
.removeObject(forKey: self.legacyKeyPrefix + stableID)
return removedKeychain
}
private static func clearAllLegacyFingerprints() {
guard let defaults = UserDefaults(suiteName: self.legacySuiteName) else { return }
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(self.legacyKeyPrefix) {
defaults.removeObject(forKey: key)
}
}
}
public final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate,
GatewayTLSFailureProviding, GatewayDeviceTokenRetryTrustProviding, @unchecked Sendable {
private let params: GatewayTLSParams
private let failureLock = NSLock()
private var lastTLSFailure: GatewayTLSValidationFailure?
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
public init(params: GatewayTLSParams) {
self.params = params
super.init()
}
public var allowsDeviceTokenRetryAuth: Bool {
self.params.expectedFingerprint?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
}
public func consumeLastTLSFailure() -> GatewayTLSValidationFailure? {
self.failureLock.lock()
defer { self.failureLock.unlock() }
let failure = self.lastTLSFailure
self.lastTLSFailure = nil
return failure
}
private func recordTLSFailure(_ failure: GatewayTLSValidationFailure) {
self.failureLock.lock()
self.lastTLSFailure = failure
self.failureLock.unlock()
}
private func clearTLSFailure() {
self.failureLock.lock()
self.lastTLSFailure = nil
self.failureLock.unlock()
}
public func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
self.makeWebSocketTask(request: URLRequest(url: url))
}
public func makeWebSocketTask(request: URLRequest) -> WebSocketTaskBox {
let task = self.session.webSocketTask(with: request)
task.maximumMessageSize = 16 * 1024 * 1024
return WebSocketTaskBox(task: task)
}
public func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void)
{
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
let host = challenge.protectionSpace.host
let systemTrustOk = SecTrustEvaluateWithError(trust, nil)
let expected = self.params.expectedFingerprint.map(normalizeFingerprint)
let fingerprint = certificateFingerprint(trust)
if let fingerprint {
if let expected {
if fingerprint == expected {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: .pinMismatch,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: systemTrustOk))
completionHandler(.cancelAuthenticationChallenge, nil)
}
return
}
if self.params.allowTOFU {
if GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: systemTrustOk) {
if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
}
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
}
if systemTrustOk || !self.params.required {
self.clearTLSFailure()
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
self.recordTLSFailure(GatewayTLSValidationFailure(
kind: fingerprint == nil ? .certificateUnavailable : .untrustedCertificate,
host: host,
storeKey: self.params.storeKey,
expectedFingerprint: expected,
observedFingerprint: fingerprint,
systemTrustOk: false))
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
private func certificateFingerprint(_ trust: SecTrust) -> String? {
guard let chain = SecTrustCopyCertificateChain(trust) as? [SecCertificate],
let cert = chain.first
else {
return nil
}
return sha256Hex(SecCertificateCopyData(cert) as Data)
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeFingerprint(_ raw: String) -> String {
let stripped = raw.replacingOccurrences(
of: #"(?i)^sha-?256\s*:?\s*"#,
with: "",
options: .regularExpression)
return stripped.lowercased().filter(\.isHexDigit)
}