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.
211 lines
9.7 KiB
Swift
211 lines
9.7 KiB
Swift
import Foundation
|
|
import Security
|
|
import Testing
|
|
@testable import ClientTLS
|
|
|
|
// B4 · `KeychainClientIdentityStore` against the REAL keychain — the legacy
|
|
// `.p12` half (the dual-trust migration window: devices enrolled before the
|
|
// Secure-Enclave path still carry an imported `.p12`).
|
|
//
|
|
// This package has no `SecItemShim` seam (unlike HostRegistry), so these tests
|
|
// drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and
|
|
// purges it afterwards, so runs never collide and nothing is left behind.
|
|
//
|
|
// WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On
|
|
// macOS `swift test` reaches the FILE-BASED keychain, which has no
|
|
// data-protection class at all — a stored item's attributes come back as only
|
|
// `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The
|
|
// "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore
|
|
// belongs to a SIGNED simulator host, exactly as plan §9 already records for
|
|
// `KeychainHostStore`. It is NOT covered by this package's gate.
|
|
|
|
@Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized)
|
|
func keychainStoreSavePersistsSingleItem() async throws {
|
|
// Arrange
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
|
|
// Act
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
|
|
// Assert — one item, and the blob carries BOTH halves (the passphrase is
|
|
// required to re-import at every launch, so storing the .p12 alone would
|
|
// brick the identity after a relaunch).
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
|
let stored = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
|
#expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString())
|
|
#expect(stored.passphrase == ClientTLSFixtures.passphrase)
|
|
}
|
|
|
|
@Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized)
|
|
func keychainStoreSaveReplaces() async throws {
|
|
// Arrange
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
let first = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
|
|
|
// Act — same coordinates, re-saved (the rotation / re-install path).
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
|
|
// Assert — still exactly one item (a duplicate would make loads
|
|
// order-dependent, i.e. an old cert could resurface after rotation) and the
|
|
// content is intact.
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
|
let second = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
|
#expect(second == first)
|
|
}
|
|
|
|
@Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized)
|
|
func keychainStoreSaveRejectsWrongPassphrase() async throws {
|
|
// Arrange
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
|
|
// Act / Assert — validation happens BEFORE persistence.
|
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
|
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
|
}
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
|
}
|
|
|
|
@Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized)
|
|
func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws {
|
|
// Arrange — a good identity is already installed.
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
let good = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
|
|
|
// Act — a truncated file arrives (bad download / wrong file picked).
|
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data.prefix(64),
|
|
passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
}
|
|
|
|
// Assert — the working identity is untouched, not wiped by the failed install.
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
|
let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
|
#expect(survivor == good)
|
|
}
|
|
|
|
@Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized)
|
|
func keychainStoreRemoveIsIdempotent() async throws {
|
|
// Arrange
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
|
|
|
// Act
|
|
try store.remove()
|
|
|
|
// Assert — gone, and removing again must NOT throw errSecItemNotFound
|
|
// (removal is reachable from "delete host" with no identity installed).
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
|
#expect(throws: Never.self) { try store.remove() }
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
|
}
|
|
|
|
@Test("remove also clears the enrollment record and the device key", .keychainSerialized)
|
|
func keychainStoreRemoveClearsEnrolledState() async throws {
|
|
// Arrange — a device that enrolled (record + device key) AND carries a
|
|
// legacy .p12, i.e. mid-migration.
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
KeychainProbe.write(
|
|
service: keys.service, account: keys.enrollmentAccount,
|
|
data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone")
|
|
)
|
|
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true)
|
|
|
|
// Act
|
|
try store.remove()
|
|
|
|
// Assert — removal is unconditional across BOTH paths; a leftover device key
|
|
// would make a later enroll bind a new leaf to a stale key.
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
|
#expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil)
|
|
#expect(try store.renewalState() == nil)
|
|
}
|
|
|
|
// The `.p12` fallback inside `loadIdentity()` is only reachable when the default
|
|
// keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores
|
|
// `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which
|
|
// short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The
|
|
// test is gated rather than fudged — it runs on a clean runner and is reported as
|
|
// skipped on a developer machine with identities in the login keychain.
|
|
@Test(
|
|
"save → loadIdentity → loadSummary roundtrips through the real keychain",
|
|
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
|
, .keychainSerialized)
|
|
func keychainStoreLoadsBackTheStoredIdentity() async throws {
|
|
// Arrange
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
#expect(store.hasInstalledIdentity() == false)
|
|
#expect(try store.loadIdentity() == nil)
|
|
|
|
// Act
|
|
try store.save(
|
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
|
)
|
|
|
|
// Assert — re-imported from the stored bytes + passphrase.
|
|
#expect(store.hasInstalledIdentity() == true)
|
|
let identity = try #require(try store.loadIdentity())
|
|
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
|
let summary = try #require(try store.loadSummary())
|
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
|
|
|
// Act / Assert — and after removal the gate closes again.
|
|
try store.remove()
|
|
#expect(store.hasInstalledIdentity() == false)
|
|
#expect(store.loadedIdentityOrNil() == nil)
|
|
}
|
|
|
|
@Test(
|
|
"a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it",
|
|
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
|
, .keychainSerialized)
|
|
func keychainStoreCorruptBlobIsTyped() async throws {
|
|
// Arrange — bytes that are not the stored JSON envelope (a partial write, or
|
|
// an item written by an older schema).
|
|
let keys = StoreKeychainKeys.unique()
|
|
defer { KeychainProbe.purge(keys) }
|
|
KeychainProbe.write(
|
|
service: keys.service, account: keys.account, data: Data("not-json".utf8)
|
|
)
|
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
|
|
|
// Act / Assert — typed error for the install UI…
|
|
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
|
_ = try store.loadIdentity()
|
|
}
|
|
// …and launch does not crash: the convenience wrapper logs and degrades.
|
|
#expect(store.loadedIdentityOrNil() == nil)
|
|
}
|