Files
web-terminal/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.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

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")
}