Files
hapi/ios/Packages/HapiKit/Sources/HapiClient/Auth/KeychainStore.swift
T
weishu 3510a0f4f1 fix(ios): Linux compile + full package test round — fixtures green locally (A-CI-local)
ios/scripts/linux-test.sh stages HapiKit + shared/fixtures at repo depth
into a persistent tmp dir and runs swift test in swift:6.1-noble; the
manifest drops HapiUI (SwiftUI/swift-markdown/Highlightr) under
#if os(Linux). 458 tests green, including all 48 chat and 11 pagination
golden fixtures.

Fixes that fell out of the first real compile of the blind-written port:
- ChatTypes.CodexReview.wireValue + SummaryPatching legacy id fallback:
  split expressions that exceeded the Swift 6 type-checker budget
- FileEndpointsTests: raw string containing "# terminated the literal
  early (never compiled anywhere) — now ##-delimited
- SSEClient.backoffSleep: Task { try? ... } inferred Task<()?, Never>
- Darwin gates: Security/Keychain behind canImport(Security) (tests use
  InMemoryCredentialStore via CredentialStoring), CryptoKit digest with
  FNV-1a filename fallback, FoundationNetworking imports for URLSession
  types, corelibs URLCache diskPath: initializer, get-only
  waitsForConnectivity, delegate-based SSE transport where
  URLSession.bytes(for:) does not exist
2026-08-18 11:51:57 +08:00

145 lines
5.4 KiB
Swift

import Foundation
#if canImport(Security)
import Security
#endif
/// The per-hub credential record (`docs/api/client-contract/auth.md`).
///
/// The access token is the durable secret; the JWT is a 4-hour cache
/// persisted only to save one `/api/auth` round-trip at cold start.
public struct HubCredentials: Codable, Equatable, Sendable {
/// Normalized hub origin (see ``HubURLNormalization``); also the storage key.
public var hubUrl: String
/// Long-lived pairing secret, passed verbatim to `POST /api/auth`.
public var accessToken: String
/// Last issued JWT, if any.
public var jwt: String?
/// When `jwt` was obtained, epoch ms.
public var jwtObtainedAt: Int?
public init(hubUrl: String, accessToken: String, jwt: String? = nil, jwtObtainedAt: Int? = nil) {
self.hubUrl = hubUrl
self.accessToken = accessToken
self.jwt = jwt
self.jwtObtainedAt = jwtObtainedAt
}
}
/// Storage seam so `AuthManager` and the pairing flow are testable without
/// touching the real Keychain.
public protocol CredentialStoring: Sendable {
/// Returns the record for a normalized hub origin, or `nil` when unpaired.
func credentials(forHub hubUrl: String) throws -> HubCredentials?
/// Inserts or replaces the record keyed by `credentials.hubUrl`.
func store(_ credentials: HubCredentials) throws
/// Removes the record; deleting a non-existent record is not an error.
func deleteCredentials(forHub hubUrl: String) throws
}
// The Keychain-backed implementation is Darwin-only (Security framework).
// On Linux the `CredentialStoring` seam keeps everything testable via
// `InMemoryCredentialStore`; there is no production credential store there.
#if canImport(Security)
/// A Keychain operation failed with the given `SecItem` status.
public struct KeychainError: Error, Equatable, Sendable {
public let status: OSStatus
public init(status: OSStatus) {
self.status = status
}
}
/// Keychain-backed credential store: one generic-password item per hub under
/// service `run.hapi.companion`, account = normalized hub origin, value =
/// JSON-encoded ``HubCredentials``.
///
/// Items use `kSecAttrAccessibleAfterFirstUnlock` so a background refresh
/// after reboot (pre-unlock) fails gracefully instead of silently losing
/// credentials, and the data-protection keychain so behavior matches across
/// iOS and macOS (CI).
public struct KeychainCredentialStore: CredentialStoring {
public static let service = "run.hapi.companion"
public init() {}
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
var query = Self.baseQuery(account: hubUrl)
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
switch status {
case errSecSuccess:
guard let data = item as? Data else {
throw KeychainError(status: errSecInternalError)
}
return try JSONDecoder().decode(HubCredentials.self, from: data)
case errSecItemNotFound:
return nil
default:
throw KeychainError(status: status)
}
}
public func store(_ credentials: HubCredentials) throws {
let data = try JSONEncoder().encode(credentials)
let query = Self.baseQuery(account: credentials.hubUrl)
let update: [String: Any] = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if updateStatus == errSecSuccess { return }
guard updateStatus == errSecItemNotFound else {
throw KeychainError(status: updateStatus)
}
var add = query
add[kSecValueData as String] = data
add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let addStatus = SecItemAdd(add as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw KeychainError(status: addStatus)
}
}
public func deleteCredentials(forHub hubUrl: String) throws {
let status = SecItemDelete(Self.baseQuery(account: hubUrl) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError(status: status)
}
}
private static func baseQuery(account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
}
}
#endif
/// Dictionary-backed test double (also handy for previews).
public final class InMemoryCredentialStore: CredentialStoring, @unchecked Sendable {
private let lock = NSLock()
private var records: [String: HubCredentials] = [:]
public init() {}
public func credentials(forHub hubUrl: String) throws -> HubCredentials? {
lock.lock()
defer { lock.unlock() }
return records[hubUrl]
}
public func store(_ credentials: HubCredentials) throws {
lock.lock()
defer { lock.unlock() }
records[credentials.hubUrl] = credentials
}
public func deleteCredentials(forHub hubUrl: String) throws {
lock.lock()
defer { lock.unlock() }
records[hubUrl] = nil
}
}