Files
openclaw/apps/shared/OpenClawKit/Sources/OpenClawKit/GenericPasswordKeychainStore.swift
Peter Steinberger 7853aa35a4 feat(ios): per-gateway custom headers for gateways behind authenticating proxies (#100768)
* feat(ios): support per-gateway custom headers for proxied gateway connections

Per-gateway custom request headers (Cloudflare Access-style service tokens)
for gateways behind authenticating reverse proxies. Headers are credentials:
Keychain-stored per gateway stableID, masked in settings UI, injected on the
WebSocket upgrade at connect time via a provider so edits apply on the next
reconnect without re-pairing. Reserved handshake headers and control chars
are sanitized out. TLS pinning and pairing flows unchanged. Push relay and
other non-gateway destinations proven header-free by regression test.

Related: #100698

* chore(ios): sync native i18n inventory for custom header strings

* fix(ios): harden gateway custom headers

* chore(ios): resync native i18n inventory

* fix(ios): validate custom header names
2026-07-06 17:17:07 +01:00

88 lines
3.1 KiB
Swift

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
}
@discardableResult
public static func deleteAll(service: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
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,
]
}
}