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
98 lines
4.1 KiB
Swift
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 }
|
|
}
|
|
}
|