mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
0dbdf994b3
* feat(macos): isolate named app profiles * refactor(macos): isolate profile launch ownership * fix(macos): avoid overlapping approvals socket access * fix(macos): declare profile defaults concurrency ownership * fix(macos): return profiled node launch arguments * chore(i18n): refresh macOS profile source inventory * fix(macos): gate profile startup before services * test(macos): evaluate profile state before assertions * fix(daemon): skip absent launchd deactivation * fix(macos): fail closed on profile port conflicts * chore(i18n): refresh profile conflict inventory * fix(macos): ignore non-gateway launch agent claims * test(macos): stabilize profile lifecycle timing * fix(macos): remove stale dashboard URL * chore(macos): refresh native source baseline
430 lines
19 KiB
Swift
430 lines
19 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import Security
|
|
import Testing
|
|
@testable import OpenClawKit
|
|
|
|
private final class GatewayTLSFakeKeychain: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var items: [String: [String: Any]] = [:]
|
|
|
|
var operations: GatewayTLSKeychainOperations {
|
|
GatewayTLSKeychainOperations(
|
|
copyMatching: { [self] query, result in self.copyMatching(query, result: result) },
|
|
add: { [self] query in self.add(query) },
|
|
update: { [self] query, updates in self.update(query, updates: updates) },
|
|
delete: { [self] query in self.delete(query) })
|
|
}
|
|
|
|
func seed(account: String, data: Data) {
|
|
self.lock.lock()
|
|
self.items[account] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "ai.openclaw.tls-pinning",
|
|
kSecAttrAccount as String: account,
|
|
kSecValueData as String: data,
|
|
]
|
|
self.lock.unlock()
|
|
}
|
|
|
|
private func copyMatching(
|
|
_ query: CFDictionary,
|
|
result: UnsafeMutablePointer<CFTypeRef?>?) -> OSStatus
|
|
{
|
|
let query = query as NSDictionary as! [String: Any]
|
|
guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam }
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
guard let item = self.items[account] else { return errSecItemNotFound }
|
|
if query[kSecReturnAttributes as String] as? Bool == true {
|
|
result?.pointee = item as CFDictionary
|
|
} else {
|
|
guard let data = item[kSecValueData as String] as? Data else { return errSecDecode }
|
|
result?.pointee = data as CFData
|
|
}
|
|
return errSecSuccess
|
|
}
|
|
|
|
private func add(_ query: CFDictionary) -> OSStatus {
|
|
let query = query as NSDictionary as! [String: Any]
|
|
guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam }
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
guard self.items[account] == nil else { return errSecDuplicateItem }
|
|
self.items[account] = query
|
|
return errSecSuccess
|
|
}
|
|
|
|
private func update(_ query: CFDictionary, updates: CFDictionary) -> OSStatus {
|
|
let query = query as NSDictionary as! [String: Any]
|
|
let updates = updates as NSDictionary as! [String: Any]
|
|
guard let account = query[kSecAttrAccount as String] as? String else { return errSecParam }
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
guard var item = self.items[account] else { return errSecItemNotFound }
|
|
if let expected = query[kSecAttrGeneric as String] as? Data,
|
|
item[kSecAttrGeneric as String] as? Data != expected
|
|
{
|
|
return errSecItemNotFound
|
|
}
|
|
item.merge(updates) { _, replacement in replacement }
|
|
self.items[account] = item
|
|
return errSecSuccess
|
|
}
|
|
|
|
private func delete(_ query: CFDictionary) -> OSStatus {
|
|
let query = query as NSDictionary as! [String: Any]
|
|
self.lock.lock()
|
|
defer { self.lock.unlock() }
|
|
guard let account = query[kSecAttrAccount as String] as? String else {
|
|
self.items.removeAll()
|
|
return errSecSuccess
|
|
}
|
|
self.items[account] = nil
|
|
return errSecSuccess
|
|
}
|
|
}
|
|
|
|
private let gatewayTLSTestCertificateDER =
|
|
Data(
|
|
base64Encoded: "MIIDMTCCAhmgAwIBAgIUY2qs5gTY9AYGcm5Ba8TG3ooCnyowDQYJKoZIhvcNAQELBQAwGjEYMBYGA1UEAwwPZ2F0ZXdheS5leGFtcGxlMB4XDTI2MDcyNTIxNDkxM1oXDTM2MDcyMjIxNDkxM1owGjEYMBYGA1UEAwwPZ2F0ZXdheS5leGFtcGxlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtT4Nw7/K1v8hp5+rrtbfhgB3pnLGnjCi53n95Yisv1WH4osvd5oxjoS3OocLzdX5L8Czz66Caq3zX+Bd6FTtWiaAPek7Gc5hJ6lDf+UR2TBhJGgLcIZbrJz2GQGItqJl0XlkShqnhhAXw/8wScG0QdEeEq3OGm2z2IQYagtbYWB2ugb65GuTxjgIHryDISrY1pKAw3UhwhsftqpUQ5e+gVj1qTMUkj8o6+qEBqzKRWAah1mBbjBuv1/dn6dLXSJDM/XFxqQGOStpywQGHIi0EPZBNiPAE2QL9gRQg4YtgbX2gFcIdrrGUVmbDMEY+FVC4q6zsRyVmnxndDlTx791UwIDAQABo28wbTAdBgNVHQ4EFgQUjd+huKP5/FHbm0h2Tgmnjb8c2dowHwYDVR0jBBgwFoAUjd+huKP5/FHbm0h2Tgmnjb8c2dowDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgg9nYXRld2F5LmV4YW1wbGUwDQYJKoZIhvcNAQELBQADggEBAASZeHqh26eec0U30QJmI2I8+60HAGDd1Cd9XpA/13eFXqCGfev8Rk1gfZ+m0NvBDlBlary4jKGYnVA4QNzP23jL4mBEEAqlmO0QMFg4ucKiKtOLmzdnk2utCY7oMw3/Nt1tD0+qBhayL+d2e5t33fYUwEm5s832xONGJUkpJ1MIldXqMovKomlMUgzSNnkGiTv8yY/J1b2W2/LWjL/ZDLd7E/pyLwvfKY5QXlfEKFp2K+brfkkk1tFLRPir6VNm9wXz3HTZTnj2CAHchitY87MXgDVliYpsQD4AIiycrsHOcRkBF/CBX9XH1LL3iolkk8WaLHeDk2jd6+vd3FRrlsU=")!
|
|
|
|
private func gatewayTLSTestTrust(systemTrusted: Bool) throws -> SecTrust {
|
|
let certificate = try #require(SecCertificateCreateWithData(nil, gatewayTLSTestCertificateDER as CFData))
|
|
let policy = systemTrusted
|
|
? SecPolicyCreateBasicX509()
|
|
: SecPolicyCreateSSL(true, "gateway.example" as CFString)
|
|
var trust: SecTrust?
|
|
try #require(SecTrustCreateWithCertificates(certificate, policy, &trust) == errSecSuccess)
|
|
let trustValue = try #require(trust)
|
|
if systemTrusted {
|
|
try #require(SecTrustSetAnchorCertificates(trustValue, [certificate] as CFArray) == errSecSuccess)
|
|
try #require(SecTrustSetAnchorCertificatesOnly(trustValue, true) == errSecSuccess)
|
|
}
|
|
return trustValue
|
|
}
|
|
|
|
struct GatewayTLSPinningTests {
|
|
@Test func `keychain namespace configures once and fails closed after use`() {
|
|
var state = GatewayTLSKeychainNamespaceState()
|
|
let configuredWork = state.configure(suffix: ".profile.work")
|
|
let reconfiguredWork = state.configure(suffix: ".profile.work")
|
|
let configuredOther = state.configure(suffix: ".profile.other")
|
|
let workService = state.service(base: "ai.openclaw.tls-pinning")
|
|
let configuredWorkAfterUse = state.configure(suffix: ".profile.work")
|
|
let configuredDefaultAfterUse = state.configure(suffix: "")
|
|
#expect(configuredWork)
|
|
#expect(reconfiguredWork)
|
|
#expect(!configuredOther)
|
|
#expect(workService == "ai.openclaw.tls-pinning.profile.work")
|
|
#expect(configuredWorkAfterUse)
|
|
#expect(!configuredDefaultAfterUse)
|
|
|
|
var usedDefault = GatewayTLSKeychainNamespaceState()
|
|
let defaultService = usedDefault.service(base: "ai.openclaw.tls-pinning")
|
|
let configuredDefault = usedDefault.configure(suffix: "")
|
|
let configuredProfileAfterDefaultUse = usedDefault.configure(suffix: ".profile.work")
|
|
#expect(defaultService == "ai.openclaw.tls-pinning")
|
|
#expect(configuredDefault)
|
|
#expect(!configuredProfileAfterDefaultUse)
|
|
}
|
|
|
|
private func withFakeKeychain<T>(_ operation: (GatewayTLSFakeKeychain) throws -> T) rethrows -> T {
|
|
let keychain = GatewayTLSFakeKeychain()
|
|
return try GatewayTLSStore.$keychainOperations.withValue(keychain.operations) {
|
|
try operation(keychain)
|
|
}
|
|
}
|
|
|
|
private func withFakeKeychain<T>(
|
|
_ operation: (GatewayTLSFakeKeychain) async throws -> T) async rethrows -> T
|
|
{
|
|
let keychain = GatewayTLSFakeKeychain()
|
|
return try await GatewayTLSStore.$keychainOperations.withValue(keychain.operations) {
|
|
try await operation(keychain)
|
|
}
|
|
}
|
|
|
|
@Test func `first use pinning requires system trust`() {
|
|
#expect(GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: true))
|
|
#expect(!GatewayTLSFirstUsePolicy.allowsFirstUsePin(systemTrustOk: false))
|
|
}
|
|
|
|
@Test func `TLS authority includes normalized host and effective port`() throws {
|
|
let url = try #require(URL(string: "wss://Gateway.Example.com/path"))
|
|
let route = try #require(GatewayTLSAuthority(url: url))
|
|
|
|
#expect(route == GatewayTLSAuthority(host: "gateway.example.com", port: 443))
|
|
#expect(route != GatewayTLSAuthority(host: "redirect.example.com", port: 443))
|
|
#expect(route != GatewayTLSAuthority(host: "gateway.example.com", port: 8443))
|
|
}
|
|
|
|
@Test func `matching explicit pin overrides system trust`() {
|
|
let decision = GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: "expected",
|
|
observedFingerprint: "expected",
|
|
allowTOFU: false,
|
|
required: true,
|
|
systemTrustOk: false)
|
|
|
|
#expect(decision == .accept(
|
|
fingerprint: "expected",
|
|
enforcePin: true,
|
|
saveFirstUse: false))
|
|
}
|
|
|
|
@Test func `server trust evaluator accepts matching pin and rejects mismatch`() throws {
|
|
let trust = try gatewayTLSTestTrust(systemTrusted: false)
|
|
let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER)
|
|
.map { String(format: "%02x", $0) }.joined()
|
|
let matching = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: fingerprint,
|
|
allowTOFU: false,
|
|
storeKey: "profile:matching")
|
|
let mismatch = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: String(repeating: "0", count: 64),
|
|
allowTOFU: false,
|
|
storeKey: "profile:mismatch")
|
|
|
|
#expect(GatewayTLSServerTrust.evaluate(
|
|
trust: trust,
|
|
host: "gateway.example",
|
|
port: 443,
|
|
params: matching) == .accept)
|
|
#expect(GatewayTLSServerTrust.evaluate(
|
|
trust: trust,
|
|
host: "gateway.example",
|
|
port: 443,
|
|
params: mismatch) == .reject)
|
|
}
|
|
|
|
@Test func `server trust evaluator claims trusted first use`() throws {
|
|
try self.withFakeKeychain { _ in
|
|
let trust = try gatewayTLSTestTrust(systemTrusted: true)
|
|
let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER)
|
|
.map { String(format: "%02x", $0) }.joined()
|
|
let params = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: nil,
|
|
allowTOFU: true,
|
|
storeKey: "profile:first-use")
|
|
|
|
#expect(GatewayTLSServerTrust.evaluate(
|
|
trust: trust,
|
|
host: "gateway.example",
|
|
port: 443,
|
|
params: params) == .accept)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: "profile:first-use") == fingerprint)
|
|
}
|
|
}
|
|
|
|
@Test func `server trust evaluator reuses persisted first use pin`() throws {
|
|
try self.withFakeKeychain { _ in
|
|
let trust = try gatewayTLSTestTrust(systemTrusted: false)
|
|
let fingerprint = SHA256.hash(data: gatewayTLSTestCertificateDER)
|
|
.map { String(format: "%02x", $0) }.joined()
|
|
let storeKey = "profile:reconnect"
|
|
let params = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: nil,
|
|
allowTOFU: true,
|
|
storeKey: storeKey)
|
|
let claimed = GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: storeKey)
|
|
#expect(claimed == fingerprint)
|
|
|
|
#expect(GatewayTLSServerTrust.evaluate(
|
|
trust: trust,
|
|
host: "gateway.example",
|
|
port: 443,
|
|
params: params) == .accept)
|
|
}
|
|
}
|
|
|
|
@Test func `server trust evaluator rejects required untrusted first use`() throws {
|
|
let trust = try gatewayTLSTestTrust(systemTrusted: false)
|
|
let params = GatewayTLSParams(
|
|
required: true,
|
|
expectedFingerprint: nil,
|
|
allowTOFU: true,
|
|
storeKey: "profile:untrusted")
|
|
|
|
#expect(GatewayTLSServerTrust.evaluate(
|
|
trust: trust,
|
|
host: "gateway.example",
|
|
port: 443,
|
|
params: params) == .reject)
|
|
}
|
|
|
|
@Test func `explicit pin mismatch and unavailable certificate fail closed`() {
|
|
#expect(GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: "expected",
|
|
observedFingerprint: "different",
|
|
allowTOFU: false,
|
|
required: true,
|
|
systemTrustOk: true) == .reject(.pinMismatch))
|
|
#expect(GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: "expected",
|
|
observedFingerprint: nil,
|
|
allowTOFU: false,
|
|
required: true,
|
|
systemTrustOk: true) == .reject(.certificateUnavailable))
|
|
#expect(GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: nil,
|
|
observedFingerprint: nil,
|
|
allowTOFU: true,
|
|
required: true,
|
|
systemTrustOk: true) == .reject(.certificateUnavailable))
|
|
}
|
|
|
|
@Test func `trusted first use is saved and enforced`() {
|
|
let decision = GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: nil,
|
|
observedFingerprint: "observed",
|
|
allowTOFU: true,
|
|
required: true,
|
|
systemTrustOk: true)
|
|
|
|
#expect(decision == .accept(
|
|
fingerprint: "observed",
|
|
enforcePin: true,
|
|
saveFirstUse: true))
|
|
}
|
|
|
|
@Test func `concurrent first use sessions share one durable fingerprint`() async {
|
|
await self.withFakeKeychain { _ in
|
|
let stableID = "test-first-use-claim"
|
|
let results = await withTaskGroup(of: String?.self, returning: [String?].self) { group in
|
|
for fingerprint in ["first", "second"] {
|
|
group.addTask {
|
|
GatewayTLSStore.claimFirstUseFingerprint(fingerprint, stableID: stableID)
|
|
}
|
|
}
|
|
var results: [String?] = []
|
|
for await result in group {
|
|
results.append(result)
|
|
}
|
|
return results
|
|
}
|
|
let claimed = results.compactMap(\.self)
|
|
|
|
#expect(claimed.count == 2)
|
|
#expect(Set(claimed).count == 1)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == claimed.first)
|
|
}
|
|
}
|
|
|
|
@Test func `first use claim fails closed without a storage owner`() {
|
|
#expect(GatewayTLSStore.claimFirstUseFingerprint("observed", stableID: "") == nil)
|
|
}
|
|
|
|
@Test func `losing first use session adopts the shared winner`() {
|
|
var state = GatewayTLSPinningState(expectedFingerprint: nil)
|
|
|
|
state.enforceFingerprint("winner")
|
|
|
|
#expect(state.enforcedFingerprint == "winner")
|
|
#expect(state.acceptedFingerprint == nil)
|
|
}
|
|
|
|
@Test func `pin replacement compares the stored value atomically`() {
|
|
self.withFakeKeychain { _ in
|
|
let stableID = "test-pin-cas"
|
|
GatewayTLSStore.saveFingerprint("old", stableID: stableID)
|
|
|
|
#expect(!GatewayTLSStore.replaceFingerprint("wrong", ifCurrent: "missing", stableID: stableID))
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old")
|
|
#expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID))
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new")
|
|
}
|
|
}
|
|
|
|
@Test func `pin storage canonicalizes accepted fingerprint spelling`() {
|
|
self.withFakeKeychain { _ in
|
|
let stableID = "test-pin-canonical-spelling"
|
|
let uppercase = String(repeating: "AB", count: 32)
|
|
let lowercase = uppercase.lowercased()
|
|
|
|
GatewayTLSStore.saveFingerprint("SHA256: \(uppercase)", stableID: stableID)
|
|
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == lowercase)
|
|
#expect(GatewayTLSStore.replaceFingerprint(
|
|
String(repeating: "c", count: 64),
|
|
ifCurrent: uppercase,
|
|
stableID: stableID))
|
|
}
|
|
}
|
|
|
|
@Test func `canonical pin without comparison metadata is upgraded for replacement`() {
|
|
self.withFakeKeychain { keychain in
|
|
let stableID = "测试-pin-canonical-migration"
|
|
let component = Data(stableID.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
keychain.seed(account: "fingerprint.v2.\(component)", data: Data("old".utf8))
|
|
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old")
|
|
#expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID))
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new")
|
|
}
|
|
}
|
|
|
|
@Test func `unreadable v2 pin blocks a new first use claim`() {
|
|
self.withFakeKeychain { keychain in
|
|
let stableID = "test-pin-unreadable-v2"
|
|
let component = Data(stableID.utf8).base64EncodedString()
|
|
.replacingOccurrences(of: "+", with: "-")
|
|
.replacingOccurrences(of: "/", with: "_")
|
|
.replacingOccurrences(of: "=", with: "")
|
|
keychain.seed(account: "fingerprint.v2.\(component)", data: Data([0xFF]))
|
|
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == nil)
|
|
#expect(GatewayTLSStore.claimFirstUseFingerprint("new", stableID: stableID) == nil)
|
|
}
|
|
}
|
|
|
|
@Test func `legacy raw pin is migrated before conditional replacement`() {
|
|
self.withFakeKeychain { keychain in
|
|
let stableID = "test-pin-legacy-migration"
|
|
keychain.seed(account: stableID, data: Data("old".utf8))
|
|
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "old")
|
|
#expect(GatewayTLSStore.replaceFingerprint("new", ifCurrent: "old", stableID: stableID))
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: stableID) == "new")
|
|
}
|
|
}
|
|
|
|
@Test func `first use fingerprint remains enforced for reconnects`() {
|
|
var state = GatewayTLSPinningState(expectedFingerprint: nil)
|
|
|
|
state.recordAcceptance("first", enforcePin: true)
|
|
|
|
#expect(state.acceptedFingerprint == "first")
|
|
#expect(state.enforcedFingerprint == "first")
|
|
}
|
|
|
|
@Test func `untrusted first use is rejected`() {
|
|
#expect(GatewayTLSValidationPolicy.decide(
|
|
expectedFingerprint: nil,
|
|
observedFingerprint: "observed",
|
|
allowTOFU: true,
|
|
required: true,
|
|
systemTrustOk: false) == .reject(.untrustedCertificate))
|
|
}
|
|
|
|
@Test func `clear all fingerprints removes every canonical pin without live storage`() {
|
|
self.withFakeKeychain { _ in
|
|
GatewayTLSStore.saveFingerprint("11", stableID: "gateway-1")
|
|
GatewayTLSStore.saveFingerprint("22", stableID: "gateway-2")
|
|
var clearedLegacy = false
|
|
|
|
#expect(GatewayTLSStore.clearAllFingerprints(clearLegacy: { clearedLegacy = true }))
|
|
#expect(clearedLegacy)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: "gateway-1") == nil)
|
|
#expect(GatewayTLSStore.loadFingerprint(stableID: "gateway-2") == nil)
|
|
}
|
|
}
|
|
}
|