Files
web-terminal/ios/Packages/HostRegistry/Sources/HostRegistry/SecItemShim.swift
Yaojia Wang 95438cdc12 feat(ios): W1 leaf packages — ReconnectMachine/PingScheduler, GateState/AwayDigest, HostRegistry, APIClient+pairing probe
T-iOS-5: pure reconnect reducer (1s→30s cap, mirrors terminal-session.ts) + 25s PingScheduler, 16 tests
T-iOS-6: gate epoch tracker (rising-edge semantics, canDecide guard) + AwayDigest reducer, 25 tests
T-iOS-7: HostRegistry with SecItemShim keychain seam, 30 tests, 88.1% own-src coverage
T-iOS-8: APIClient (Origin iff-G invariant) + two-step pairing probe, 45 tests, 98.1% coverage
Contract ruling: probe returns Result<HostEndpoint,_> (Host{id,name} built by pairing VM) —
resolves frozen-contract contradiction reported via BLOCKED protocol; adds Tunables.pairingProbeTimeout(10s)
Verified: 178 tests green across 5 packages; coverage gates pass; zero Owns violations
2026-07-04 21:53:41 +02:00

98 lines
4.1 KiB
Swift

import Foundation
import Security
/// Testability seam over the four `SecItem*` C calls (plan §3.3 / T-iOS-7).
///
/// Why a seam: unsigned `swift test` binaries get `errSecMissingEntitlement`
/// (-34018) from the data-protection keychain, so unit tests inject a fake
/// conforming to this protocol and assert the store's dictionaries verbatim;
/// the live shim only runs inside the signed simulator/app pass (T-iOS-15/16).
public protocol SecItemShim: Sendable {
func add(_ attributes: [String: any Sendable]) -> OSStatus
func update(query: [String: any Sendable],
attributesToUpdate: [String: any Sendable]) -> OSStatus
func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?)
func delete(_ query: [String: any Sendable]) -> OSStatus
}
/// Live passthrough to Security.framework. Deliberately logic-free: every
/// attribute including the §5.3-mandated protection keys is built by
/// `KeychainItemSpec` below, so there is exactly ONE place to audit and to
/// unit-test. Adding attribute logic here would split that single source.
public struct LiveSecItemShim: SecItemShim {
public init() {}
public func add(_ attributes: [String: any Sendable]) -> OSStatus {
SecItemAdd(attributes as [String: Any] as CFDictionary, nil)
}
public func update(query: [String: any Sendable],
attributesToUpdate: [String: any Sendable]) -> OSStatus {
SecItemUpdate(query as [String: Any] as CFDictionary,
attributesToUpdate as [String: Any] as CFDictionary)
}
public func copyMatching(_ query: [String: any Sendable]) -> (status: OSStatus, data: Data?) {
var result: CFTypeRef?
let status = SecItemCopyMatching(query as [String: Any] as CFDictionary, &result)
return (status, result as? Data)
}
public func delete(_ query: [String: any Sendable]) -> OSStatus {
SecItemDelete(query as [String: Any] as CFDictionary)
}
}
/// §5.3 single source of truth for every keychain dictionary the store sends
/// through the shim. One wrong attribute here = credentials leave the device
/// with backups KeychainHostStoreTests asserts these dictionaries verbatim
/// (exact key sets), and the signed simulator pass (T-iOS-15/16) verifies the
/// real item's attributes end-to-end.
struct KeychainItemSpec: Sendable {
let service: String
let account: String
/// Identifies the item in every call. `kSecUseDataProtectionKeychain`
/// pins the iOS-style data-protection keychain on macOS too (on iOS it
/// is implicit); omitting it would silently fall back to the legacy
/// file keychain on Mac and void the §5.3 semantics.
func baseQuery() -> [String: any Sendable] {
[
kSecClass as String: kSecClassGenericPassword as String,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
kSecUseDataProtectionKeychain as String: true,
]
}
func copyQuery() -> [String: any Sendable] {
merged(baseQuery(), [
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne as String,
])
}
/// `AfterFirstUnlockThisDeviceOnly`: readable in background after first
/// unlock (reconnect/UI needs), never migrates to another device or into
/// backups; no `kSecAttrSynchronizable` never iCloud-synced (§5.3).
func addAttributes(data: Data) -> [String: any Sendable] {
merged(baseQuery(), updateAttributes(data: data))
}
/// Update re-asserts `kSecAttrAccessible` so a pre-existing item can
/// never retain a weaker protection class than the current policy.
func updateAttributes(data: Data) -> [String: any Sendable] {
[
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly as String,
kSecValueData as String: data,
]
}
private func merged(
_ base: [String: any Sendable],
_ extra: [String: any Sendable]
) -> [String: any Sendable] {
base.merging(extra) { _, new in new }
}
}