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.
184 lines
7.3 KiB
Swift
184 lines
7.3 KiB
Swift
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")
|
|
}
|