Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift
Yaojia Wang a5fa843f00 test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least
covered, and it was not in the coverage gate at all (the gate's 4-package set
predates it). 48 -> 84 tests against the real macOS keychain, serialized with a
custom Testing trait after a @globalActor proved insufficient (actors yield at
await, so cross-await critical sections got interleaved by other cases' cleanup).

CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which
spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout.
Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly
instead of silently reporting green.
2026-07-30 12:45:26 +02:00

135 lines
5.8 KiB
Swift

import Foundation
import Security
@testable import ClientTLS
// B4 · Direct `SecItem*` access for the store tests. Two jobs:
// 1. INSPECT what `KeychainClientIdentityStore` actually wrote the protection
// class and the no-iCloud-sync flag are security requirements (plan §5.3 /
// contract §1.1) that only a raw read can confirm.
// 2. SEED / CORRUPT the persisted records so the read-side error paths and the
// renew path can be driven without first performing a real enrollment
// (which would have to install a certificate see `undecodableCertificateDER`).
/// The keychain coordinates a `KeychainClientIdentityStore(service:account:)`
/// derives internally. Mirrored here ON PURPOSE: the persisted layout is a
/// migration contract, so the tests pin it rather than infer it.
struct StoreKeychainKeys {
let service: String
let account: String
/// `KeychainClientIdentityStore.enrollmentAccount`.
var enrollmentAccount: String { "\(account).enrollment" }
/// `KeychainClientIdentityStore.deviceKeyTag`.
var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
/// `KeychainClientIdentityStore.leafLabel`.
var leafLabel: String { "\(service).device-leaf" }
/// A fresh, collision-free coordinate set for one test.
static func unique() -> StoreKeychainKeys {
StoreKeychainKeys(
service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)",
account: "device-identity"
)
}
}
enum KeychainProbe {
private static func query(service: String, account: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
/// How many generic-password items sit at `(service, account)`. `0` = nothing
/// stored; `> 1` = a write duplicated instead of replacing.
///
/// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData`
/// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS
/// file-based keychain (verified 2026-07-30), so counting and reading are
/// separate calls.
static func count(service: String, account: String) -> Int {
var request = query(service: service, account: account)
request[kSecReturnAttributes as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitAll
var result: CFTypeRef?
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
return 0
}
if let array = result as? [Any] { return array.count }
return result == nil ? 0 : 1
}
/// The stored bytes at `(service, account)`; `nil` when no item exists.
static func data(service: String, account: String) -> Data? {
var request = query(service: service, account: account)
request[kSecReturnData as String] = true
request[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
return nil
}
return result as? Data
}
/// Write raw bytes at `(service, account)` with the store's own protection
/// class used to seed or corrupt a record.
@discardableResult
static func write(service: String, account: String, data: Data) -> OSStatus {
SecItemDelete(query(service: service, account: account) as CFDictionary)
var attributes = query(service: service, account: account)
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
return SecItemAdd(attributes as CFDictionary, nil)
}
@discardableResult
static func delete(service: String, account: String) -> OSStatus {
SecItemDelete(query(service: service, account: account) as CFDictionary)
}
/// Remove everything a test could have created under `keys`.
static func purge(_ keys: StoreKeychainKeys) {
delete(service: keys.service, account: keys.account)
delete(service: keys.service, account: keys.enrollmentAccount)
try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag)
}
}
/// The two fields of the stored `.p12` envelope, decoded from the keychain item.
///
/// Compared field-by-field rather than byte-by-byte because Foundation's
/// `JSONEncoder` serializes through an unordered dictionary on Darwin: two
/// encodings of the SAME value differ in key order (verified 2026-07-30 both
/// blobs 2109 bytes, not equal), so byte equality is not a valid invariant.
func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? {
guard let data = KeychainProbe.data(service: service, account: account),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let p12 = json["p12"] as? String,
let passphrase = json["passphrase"] as? String
else { return nil }
return (p12, passphrase)
}
/// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` base64
/// string, `Date` seconds since the reference date). Written directly so the
/// read path can be exercised without installing a leaf certificate.
func storedEnrollmentJSON(
deviceId: String,
deviceName: String,
caChain: [Data] = [],
notAfter: Date? = nil,
renewAfter: Date? = nil
) -> Data {
var json: [String: Any] = [
"deviceId": deviceId,
"deviceName": deviceName,
"caChain": caChain.map { $0.base64EncodedString() },
]
if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate }
if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate }
return try! JSONSerialization.data(withJSONObject: json)
}