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.
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The device key factory. Two things must hold for the enrollment path to
|
||||
// work at all: (1) a PERMANENT tagged key survives and is found again by tag —
|
||||
// that is what lets the enrolled leaf bind to it and `kSecClassIdentity`
|
||||
// assemble; (2) when the Secure Enclave is not usable (Simulator, missing
|
||||
// entitlement) the failure is CLASSIFIED as `.secureEnclaveUnavailable`, because
|
||||
// that is the signal callers use to fall back to a software key. A
|
||||
// `.keyGenerationFailed` there would look like a bug instead of a platform
|
||||
// limit and the fallback would never happen.
|
||||
//
|
||||
// Runs against the REAL keychain (no shim exists in this package): each test
|
||||
// uses a UUID-scoped tag and deletes it again, so nothing leaks between runs.
|
||||
|
||||
/// A per-test keychain tag — never collides with another test or another run.
|
||||
private func uniqueTag() -> Data {
|
||||
Data("com.yaojia.webterm.clienttls.test.key-\(UUID().uuidString)".utf8)
|
||||
}
|
||||
|
||||
@Test("a software key exposes a 65-byte uncompressed X9.63 point and signs verifiably")
|
||||
func softwareKeySignsVerifiably() throws {
|
||||
// Arrange
|
||||
let key = try SecureEnclaveKeyFactory.generateSoftware()
|
||||
|
||||
// Act
|
||||
let point = try key.publicKeyX963()
|
||||
let message = Data("certificationRequestInfo".utf8)
|
||||
let signature = try key.sign(message)
|
||||
|
||||
// Assert — X9.63 uncompressed form, and an X9.62 DER ECDSA signature that
|
||||
// verifies under the same algorithm the server's PoP check uses.
|
||||
#expect(point.count == 65)
|
||||
#expect(point.first == 0x04)
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
point as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
#expect(
|
||||
SecKeyVerifySignature(
|
||||
publicKey, .ecdsaSignatureMessageX962SHA256,
|
||||
message as CFData, signature as CFData, nil
|
||||
)
|
||||
)
|
||||
#expect(signature.first == 0x30) // SEQUENCE { r, s }
|
||||
}
|
||||
|
||||
@Test("a non-permanent key is NOT stored in the keychain", .keychainSerialized)
|
||||
func nonPermanentKeyIsNotPersisted() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
|
||||
// Act — tagged but permanent: false (the unit-test / Simulator shape).
|
||||
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: false)
|
||||
|
||||
// Assert
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||
}
|
||||
|
||||
@Test("a permanent tagged key is found again by tag and deleted idempotently", .keychainSerialized)
|
||||
func permanentKeyRoundtripsByTag() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) // pre-enroll state
|
||||
|
||||
// Act
|
||||
let generated = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: true)
|
||||
let loaded = try SecureEnclaveKeyFactory.load(tag: tag)
|
||||
|
||||
// Assert — the SAME key comes back (rotation must re-sign with it, never
|
||||
// mint a new one).
|
||||
let reloaded = try #require(loaded)
|
||||
#expect(try reloaded.publicKeyX963() == (try generated.publicKeyX963()))
|
||||
|
||||
// Act — delete, then delete again.
|
||||
try SecureEnclaveKeyFactory.delete(tag: tag)
|
||||
|
||||
// Assert — gone, and a second delete is a no-op (not a throw).
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||
#expect(throws: Never.self) { try SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
}
|
||||
|
||||
@Test("two keys under different tags stay independent", .keychainSerialized)
|
||||
func tagsScopeKeysIndependently() async throws {
|
||||
// Arrange
|
||||
let tagA = uniqueTag()
|
||||
let tagB = uniqueTag()
|
||||
defer {
|
||||
try? SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||
try? SecureEnclaveKeyFactory.delete(tag: tagB)
|
||||
}
|
||||
|
||||
// Act
|
||||
let keyA = try SecureEnclaveKeyFactory.generateSoftware(tag: tagA, permanent: true)
|
||||
let keyB = try SecureEnclaveKeyFactory.generateSoftware(tag: tagB, permanent: true)
|
||||
|
||||
// Assert
|
||||
#expect(try keyA.publicKeyX963() != (try keyB.publicKeyX963()))
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagA)?.publicKeyX963()
|
||||
== (try keyA.publicKeyX963()))
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagB)?.publicKeyX963()
|
||||
== (try keyB.publicKeyX963()))
|
||||
|
||||
// Deleting one leaves the other installed.
|
||||
try SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagA) == nil)
|
||||
#expect(try SecureEnclaveKeyFactory.load(tag: tagB) != nil)
|
||||
}
|
||||
|
||||
@Test("Secure-Enclave keygen without the entitlement fails as .secureEnclaveUnavailable", .keychainSerialized)
|
||||
func secureEnclaveKeygenClassifiesUnavailability() async throws {
|
||||
// Arrange
|
||||
let tag = uniqueTag()
|
||||
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||
|
||||
// Act / Assert — an UNSIGNED test binary has no
|
||||
// `com.apple.developer.kernel...`/keychain-access-group entitlement, so
|
||||
// SecKeyCreateRandomKey over the SE token fails with -34018. What is under
|
||||
// test is the CLASSIFICATION: it must be `.secureEnclaveUnavailable`
|
||||
// (callers' fallback signal), never `.keyGenerationFailed`.
|
||||
//
|
||||
// On a host that CAN mint an SE key (entitled build on real hardware) the
|
||||
// call legitimately succeeds; then the key must be a usable signer. Both
|
||||
// outcomes are asserted so this test is honest on every machine.
|
||||
do {
|
||||
let key = try SecureEnclaveKeyFactory.generateSecureEnclave(tag: tag)
|
||||
let signature = try key.sign(Data("probe".utf8))
|
||||
#expect(signature.first == 0x30)
|
||||
} catch let error as SecureEnclaveKeyError {
|
||||
guard case let .secureEnclaveUnavailable(description) = error else {
|
||||
Issue.record("SE keygen failure must classify as .secureEnclaveUnavailable: \(error)")
|
||||
return
|
||||
}
|
||||
#expect(description.isEmpty == false) // the underlying CFError is kept for logs
|
||||
}
|
||||
}
|
||||
|
||||
@Test("signing with a public-only key surfaces .signatureFailed instead of crashing")
|
||||
func signingWithPublicKeyFails() throws {
|
||||
// Arrange — wrap a PUBLIC key in the signer (the shape a mis-wired
|
||||
// composition root could produce).
|
||||
let point = try SecureEnclaveKeyFactory.generateSoftware().publicKeyX963()
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
point as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
let key = SecureEnclaveKey(privateKey: publicKey)
|
||||
|
||||
// Act / Assert
|
||||
do {
|
||||
_ = try key.sign(Data("m".utf8))
|
||||
Issue.record("signing with a public key must throw")
|
||||
} catch let error as SecureEnclaveKeyError {
|
||||
guard case let .signatureFailed(description) = error else {
|
||||
Issue.record("expected .signatureFailed, got \(error)")
|
||||
return
|
||||
}
|
||||
#expect(description.isEmpty == false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a nil CFError is described as 'unknown' rather than crashing the error path")
|
||||
func describeNilError() {
|
||||
#expect(SecureEnclaveKey.describe(nil) == "unknown")
|
||||
}
|
||||
Reference in New Issue
Block a user