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.
135 lines
5.8 KiB
Swift
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)
|
|
}
|