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,52 @@
|
||||
import Foundation
|
||||
|
||||
// B4 · Read back the fields of a PKCS#10 CSR so tests can assert on what was
|
||||
// ACTUALLY sent to the control plane (subject CN, embedded public key) instead
|
||||
// of trusting the encoder's own view. Uses the throwaway `TestDER` reader from
|
||||
// CertificateSigningRequestTests.
|
||||
|
||||
/// The two CSR fields the store/rotation invariants are stated in terms of.
|
||||
struct ParsedCSR: Equatable {
|
||||
/// `CertificationRequestInfo.subject` — the single CN RDN.
|
||||
let subjectCommonName: String
|
||||
/// `subjectPKInfo` BIT STRING content: the X9.63 uncompressed point.
|
||||
let publicPointX963: Data
|
||||
/// The exact `CertificationRequestInfo` DER (the bytes that were signed).
|
||||
let certificationRequestInfoDER: Data
|
||||
}
|
||||
|
||||
/// Parse a `CertificationRequest` DER. `nil` when the shape is not the canonical
|
||||
/// `SEQUENCE { info, algId, BIT STRING }` this package emits.
|
||||
func parseCSR(_ der: Data) -> ParsedCSR? {
|
||||
let bytes = [UInt8](der)
|
||||
guard let outer = TestDER.read(bytes, at: 0), outer.end == bytes.count else { return nil }
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
guard parts.count == 3 else { return nil }
|
||||
|
||||
let info = parts[0]
|
||||
let infoChildren = TestDER.children(bytes, info)
|
||||
guard infoChildren.count == 4 else { return nil }
|
||||
|
||||
// subject: SEQUENCE { SET { SEQUENCE { OID commonName, UTF8String } } }
|
||||
let rdnSequence = TestDER.children(bytes, infoChildren[1])
|
||||
guard let rdnSet = rdnSequence.first else { return nil }
|
||||
let attributes = TestDER.children(bytes, rdnSet)
|
||||
guard let attribute = attributes.first else { return nil }
|
||||
let attributeParts = TestDER.children(bytes, attribute)
|
||||
guard attributeParts.count == 2 else { return nil }
|
||||
let commonNameBytes = Array(bytes[attributeParts[1].valueStart..<attributeParts[1].valueEnd])
|
||||
guard let commonName = String(bytes: commonNameBytes, encoding: .utf8) else { return nil }
|
||||
|
||||
// subjectPKInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||
let spkiChildren = TestDER.children(bytes, infoChildren[2])
|
||||
guard spkiChildren.count == 2 else { return nil }
|
||||
let bitString = spkiChildren[1]
|
||||
guard bitString.valueEnd > bitString.valueStart + 1 else { return nil }
|
||||
let point = Data(bytes[(bitString.valueStart + 1)..<bitString.valueEnd])
|
||||
|
||||
return ParsedCSR(
|
||||
subjectCommonName: commonName,
|
||||
publicPointX963: point,
|
||||
certificationRequestInfoDER: Data(bytes[info.start..<info.end])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Pins the hand-written PKCS#10 encoder against an EXTERNAL known-good
|
||||
// vector (OpenSSL) plus the two boundary behaviours the structural tests in
|
||||
// CertificateSigningRequestTests don't reach: a signer that hands back a
|
||||
// non-X9.63 public key, and a subject long enough to need a long-form DER
|
||||
// length. `verifyCsrPoPEc` re-serializes `CertificationRequestInfo` server-side
|
||||
// before checking the PoP, so a single byte of drift breaks enrollment.
|
||||
|
||||
@Test("CertificationRequestInfo is byte-identical to OpenSSL's for the same key + subject")
|
||||
func csrRequestInfoMatchesOpenSSLVector() throws {
|
||||
// Arrange — the exact key OpenSSL used for the embedded vector.
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||
)
|
||||
|
||||
// Assert — the signed half must match OpenSSL byte for byte.
|
||||
let parsed = try #require(parseCSR(der))
|
||||
#expect(parsed.certificationRequestInfoDER == KnownGoodCSR.certificationRequestInfoDER)
|
||||
}
|
||||
|
||||
@Test("the vector's self-signature verifies over the exact CertificationRequestInfo bytes")
|
||||
func csrVectorSignatureVerifies() throws {
|
||||
// Arrange
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
let signature = Data(bytes[(parts[2].valueStart + 1)..<parts[2].valueEnd])
|
||||
|
||||
// Act — verify against the public point embedded in the CSR itself.
|
||||
let parsed = try #require(parseCSR(der))
|
||||
let publicKey = try #require(
|
||||
SecKeyCreateWithData(
|
||||
parsed.publicPointX963 as CFData,
|
||||
[
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
] as CFDictionary,
|
||||
nil
|
||||
)
|
||||
)
|
||||
var error: Unmanaged<CFError>?
|
||||
let verified = SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.ecdsaSignatureMessageX962SHA256,
|
||||
KnownGoodCSR.certificationRequestInfoDER as CFData,
|
||||
signature as CFData,
|
||||
&error
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(verified, "\(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
|
||||
@Test("a signer whose public key is not a 65-byte uncompressed point is rejected")
|
||||
func csrRejectsNonX963PublicKey() {
|
||||
// Arrange — a compressed (33-byte) point: valid EC, wrong encoding for SPKI.
|
||||
let compressed = Data([0x02] + [UInt8](repeating: 0x11, count: 32))
|
||||
let signer = StubSigner(publicKey: compressed)
|
||||
|
||||
// Act / Assert — must fail BEFORE signing (no PoP over a bogus SPKI).
|
||||
#expect(throws: CertificateSigningRequest.CSRError.invalidPublicKey) {
|
||||
_ = try CertificateSigningRequest.der(subjectCommonName: "device", signer: signer)
|
||||
}
|
||||
#expect(signer.signCallCount == 0)
|
||||
}
|
||||
|
||||
@Test("a subject long enough to need a 2-byte DER length still round-trips")
|
||||
func csrLongSubjectUsesLongFormLength() throws {
|
||||
// Arrange — 300 chars pushes CertificationRequestInfo past 255 bytes, so its
|
||||
// length must be encoded long-form as 0x82 <hi> <lo>.
|
||||
let longCommonName = String(repeating: "d", count: 300)
|
||||
let signer = try KnownGoodCSR.fixedKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: longCommonName, signer: signer
|
||||
)
|
||||
|
||||
// Assert
|
||||
let parsed = try #require(parseCSR(der))
|
||||
#expect(parsed.subjectCommonName == longCommonName)
|
||||
let infoBytes = [UInt8](parsed.certificationRequestInfoDER)
|
||||
#expect(infoBytes[1] == 0x82) // long form, two length bytes
|
||||
let declaredLength = Int(infoBytes[2]) << 8 | Int(infoBytes[3])
|
||||
#expect(declaredLength == infoBytes.count - 4) // minimal + accurate
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// A `P256HardwareKey` that returns a caller-chosen public key and records
|
||||
/// whether it was ever asked to sign.
|
||||
private final class StubSigner: P256HardwareKey, @unchecked Sendable {
|
||||
private let publicKey: Data
|
||||
private let lock = NSLock()
|
||||
private var signCalls = 0
|
||||
|
||||
init(publicKey: Data) {
|
||||
self.publicKey = publicKey
|
||||
}
|
||||
|
||||
var signCallCount: Int { lock.withLock { signCalls } }
|
||||
|
||||
func publicKeyX963() throws -> Data { publicKey }
|
||||
|
||||
func sign(_ message: Data) throws -> Data {
|
||||
lock.withLock { signCalls += 1 }
|
||||
return Data([0x30, 0x00])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The URLSession seam. `ClientTLSSessionDelegate` is the object every HTTP
|
||||
// call to an mTLS host hangs off, so the one behaviour that matters is that the
|
||||
// session-level callback ALWAYS invokes the completion handler exactly once with
|
||||
// the responder's decision — a delegate that forgets to call back hangs the
|
||||
// request forever (no timeout maps to a user-visible error).
|
||||
|
||||
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||
let space = URLProtectionSpace(
|
||||
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||
realm: nil, authenticationMethod: method
|
||||
)
|
||||
return URLAuthenticationChallenge(
|
||||
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||
failureResponse: nil, error: nil, sender: DelegateTestSender()
|
||||
)
|
||||
}
|
||||
|
||||
/// The responder never calls back into the sender; it only reads the protection
|
||||
/// space. Present because the designated initializer demands a non-optional one.
|
||||
private final class DelegateTestSender: NSObject, URLAuthenticationChallengeSender {
|
||||
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||
}
|
||||
|
||||
/// Drive the delegate and capture what it handed back.
|
||||
private func resolve(
|
||||
identity: ClientIdentity?, method: String
|
||||
) -> (calls: Int, disposition: URLSession.AuthChallengeDisposition?, credential: URLCredential?) {
|
||||
let delegate = ClientTLSSessionDelegate(identity: identity)
|
||||
var calls = 0
|
||||
var disposition: URLSession.AuthChallengeDisposition?
|
||||
var credential: URLCredential?
|
||||
delegate.urlSession(
|
||||
URLSession.shared, didReceive: makeChallenge(method: method)
|
||||
) { receivedDisposition, receivedCredential in
|
||||
calls += 1
|
||||
disposition = receivedDisposition
|
||||
credential = receivedCredential
|
||||
}
|
||||
return (calls, disposition, credential)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge answers once with the installed identity")
|
||||
func delegateAnswersClientCertificateWithIdentity() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act
|
||||
let result = resolve(
|
||||
identity: identity, method: NSURLAuthenticationMethodClientCertificate
|
||||
)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1) // exactly once — never zero (hang) or twice (crash)
|
||||
#expect(result.disposition == .useCredential)
|
||||
#expect(result.credential?.identity != nil)
|
||||
}
|
||||
|
||||
@Test("a ClientCertificate challenge with no identity cancels instead of hanging")
|
||||
func delegateCancelsWithoutIdentity() {
|
||||
// Act
|
||||
let result = resolve(identity: nil, method: NSURLAuthenticationMethodClientCertificate)
|
||||
|
||||
// Assert
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .cancelAuthenticationChallenge)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
|
||||
@Test("server-trust challenges fall through to the system evaluation")
|
||||
func delegateDefersServerTrust() throws {
|
||||
// Arrange
|
||||
let identity = try PKCS12Importer.importIdentity(
|
||||
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||
)
|
||||
|
||||
// Act / Assert — the client cert must never be offered as a server-trust
|
||||
// answer, and pinning is NOT this package's job (plan §5: default handling).
|
||||
for candidate in [identity, nil] as [ClientIdentity?] {
|
||||
let result = resolve(identity: candidate, method: NSURLAuthenticationMethodServerTrust)
|
||||
#expect(result.calls == 1)
|
||||
#expect(result.disposition == .performDefaultHandling)
|
||||
#expect(result.credential == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
// B4 · Two real, DER-decodable leaf certificates for the enrolled-identity
|
||||
// persistence tests, plus the cleanup they need.
|
||||
//
|
||||
// Both carry the SAME public key (the `KnownGoodCSR` fixed key) and the SAME
|
||||
// subject, and differ only in serial + validity — exactly the shape of a
|
||||
// rotation: `SecItemAdd` treats them as two distinct items, while re-adding one
|
||||
// of them byte-for-byte is `errSecDuplicateItem`.
|
||||
//
|
||||
// CLEANUP, and why it needs its own path: on macOS the file-based keychain
|
||||
// OVERRIDES the `kSecAttrLabel` supplied at add time with the certificate's own
|
||||
// subject summary (verified 2026-07-30: added under
|
||||
// `…test-XYZ.device-leaf`, found only under `enrolled-device-fixture`). So the
|
||||
// production `KeychainClientIdentityStore` label-keyed delete cannot remove them
|
||||
// on macOS, and the tests must sweep by subject instead — `purgeFixtureLeaves()`
|
||||
// runs both BEFORE (in case a previous run was killed) and AFTER every test that
|
||||
// installs one. `SecItemDelete` removes ONE match per call here, hence the loop.
|
||||
//
|
||||
// Regenerated with (OpenSSL 3.0.18, key = KnownGoodCSR.privateKeyX963Base64):
|
||||
// openssl req -x509 -new -key fixed.key.pem -subj "/CN=enrolled-device-fixture" \
|
||||
// -days 3650 -sha256 -outform DER -out leaf1.der # then -days 3651 → leaf2
|
||||
// openssl req -x509 -new -key chain.key.pem \
|
||||
// -subj "/CN=webterm-enrollment-ca-fixture" -days 3650 -sha256 -outform DER
|
||||
enum EnrolledLeafFixtures {
|
||||
/// The subject summary macOS files these certificates under.
|
||||
static let subjectSummary = "enrolled-device-fixture"
|
||||
|
||||
static var leaf: Data { der(leafBase64) }
|
||||
/// A second, DISTINCT leaf (different serial) for the rotation case.
|
||||
static var rotatedLeaf: Data { der(rotatedLeafBase64) }
|
||||
/// An issuer certificate for the stored `caChain`.
|
||||
static var issuer: Data { der(issuerBase64) }
|
||||
|
||||
private static func der(_ base64: String) -> Data {
|
||||
Data(base64Encoded: base64, options: .ignoreUnknownCharacters)!
|
||||
}
|
||||
|
||||
private static let leafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUcHFyXdi5QFelGKhdzcsbFlgnYzUwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAxWhcNMzYwNzI3MDc1NTAx\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCIQCoLSypPlXvtMsQRMpztt/RpbYKc6iTmBLypqOZc0MxTAIgF/MVWpaLLYVCmcKBPxf7y9yQ\
|
||||
NX3c6BrYB/fi/WzJsmA=
|
||||
"""
|
||||
|
||||
private static let rotatedLeafBase64 = """
|
||||
MIIBmTCCAT+gAwIBAgIUarD2zMEMcxZJNQevMMJenCAlLpkwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI4MDc1NTAy\
|
||||
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||
MEUCID2FFmKH2+qHB8sICyDuxQPtyj/wOLE24t4ghBEitLRvAiEAnTQQ2FP8Wei+8ITL/K57UP2Z\
|
||||
YtYbEokKkBCj2bMr3vk=
|
||||
"""
|
||||
|
||||
private static let issuerBase64 = """
|
||||
MIIBpTCCAUugAwIBAgIUIkl7VDf6o9lBxtWxh7zFxPYTpo8wCgYIKoZIzj0EAwIwKDEmMCQGA1UE\
|
||||
Awwdd2VidGVybS1lbnJvbGxtZW50LWNhLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI3\
|
||||
MDc1NTAyWjAoMSYwJAYDVQQDDB13ZWJ0ZXJtLWVucm9sbG1lbnQtY2EtZml4dHVyZTBZMBMGByqG\
|
||||
SM49AgEGCCqGSM49AwEHA0IABM4iWgauVe86+SKZg+jlHkh8VbVf70pgMdGOcpJW+xWb5oqfzxY3\
|
||||
fW6pIdn3SCo9PG7X22qGtq/PLgpv97wtJvajUzBRMB0GA1UdDgQWBBTT9mdiVZTWcatuYz3NkxCb\
|
||||
DxDSAjAfBgNVHSMEGDAWgBTT9mdiVZTWcatuYz3NkxCbDxDSAjAPBgNVHRMBAf8EBTADAQH/MAoG\
|
||||
CCqGSM49BAMCA0gAMEUCIQCxfF8zyRHMW7nSmieDoBxIsOXkMFVPko86THW3TbwrUwIgMZlBP5rz\
|
||||
FT4Y/G2oA+87xceEDjCrA7FnRZrCrZ4AADk=
|
||||
"""
|
||||
|
||||
private static func labelQuery() -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassCertificate,
|
||||
kSecAttrLabel as String: subjectSummary,
|
||||
]
|
||||
}
|
||||
|
||||
/// How many fixture leaves are currently installed.
|
||||
static func installedCount() -> Int {
|
||||
var query = labelQuery()
|
||||
query[kSecReturnRef as String] = true
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||
var result: CFTypeRef?
|
||||
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
|
||||
return 0
|
||||
}
|
||||
if let array = result as? [Any] { return array.count }
|
||||
return result == nil ? 0 : 1
|
||||
}
|
||||
|
||||
/// Remove every installed fixture leaf. Idempotent; safe to call when none
|
||||
/// are installed.
|
||||
static func purgeFixtureLeaves() {
|
||||
var guardCounter = 0
|
||||
let maxSweeps = 16 // a leaf per enroll in a test; never unbounded
|
||||
while SecItemDelete(labelQuery() as CFDictionary) == errSecSuccess,
|
||||
guardCounter < maxSweeps {
|
||||
guardCounter += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Foundation
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · Shared enrollment-transport double for the store / flow tests.
|
||||
//
|
||||
// Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the
|
||||
// LAST request), the store tests must re-parse the CSR that was actually sent —
|
||||
// that is how "renew re-signs with the SAME device key" is pinned — so this one
|
||||
// records every request body and replays a scripted reply per call.
|
||||
|
||||
/// A recording, scriptable `EnrollmentTransport`.
|
||||
///
|
||||
/// `@unchecked Sendable`: all mutable state is behind `lock`.
|
||||
final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable {
|
||||
struct Reply {
|
||||
let status: Int
|
||||
let body: Data
|
||||
}
|
||||
|
||||
private let lock = NSLock()
|
||||
private var pendingReplies: [Reply]
|
||||
private var recorded: [URLRequest] = []
|
||||
|
||||
init(replies: [Reply]) {
|
||||
pendingReplies = replies
|
||||
}
|
||||
|
||||
convenience init(status: Int, body: Data) {
|
||||
self.init(replies: [Reply(status: status, body: body)])
|
||||
}
|
||||
|
||||
var requests: [URLRequest] { lock.withLock { recorded } }
|
||||
var callCount: Int { lock.withLock { recorded.count } }
|
||||
|
||||
/// The JSON body of call `index`, decoded as a `[String: Any]` object.
|
||||
func jsonBody(at index: Int) -> [String: Any]? {
|
||||
guard let data = requests.indices.contains(index)
|
||||
? requests[index].httpBody : nil
|
||||
else { return nil }
|
||||
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||
}
|
||||
|
||||
/// The `csr` field of call `index`, base64-decoded back to DER.
|
||||
func csrDER(at index: Int) -> Data? {
|
||||
guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil }
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
let reply: Reply = lock.withLock {
|
||||
recorded.append(request)
|
||||
guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) }
|
||||
return pendingReplies.removeFirst()
|
||||
}
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
return (reply.body, response)
|
||||
}
|
||||
}
|
||||
|
||||
/// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE).
|
||||
///
|
||||
/// Every store test that drives `enroll` / `renew` to the persistence step uses
|
||||
/// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf`
|
||||
/// fail at the very first line — BEFORE any `SecItemAdd(kSecClassCertificate)`.
|
||||
/// That matters on macOS, where `swift test` talks to the file-based login
|
||||
/// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the
|
||||
/// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed
|
||||
/// delete can never remove it again (verified 2026-07-30: adding the leaf, then
|
||||
/// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: …)` →
|
||||
/// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with
|
||||
/// `security delete-certificate -Z <sha1>`). A test that installed a leaf would
|
||||
/// therefore permanently pollute the developer's login keychain.
|
||||
let undecodableCertificateDER = Data([0x30, 0x01, 0x02])
|
||||
|
||||
/// A `POST /device/enroll` / `…/renew` 201 body in the A4 wire shape.
|
||||
func enrollmentResponseJSON(
|
||||
deviceId: String = "dev-b4",
|
||||
certDER: Data = undecodableCertificateDER,
|
||||
caChain: [Data] = [],
|
||||
notBefore: String? = "2026-07-30T00:00:00.000Z",
|
||||
notAfter: String? = "2026-10-28T00:00:00.000Z",
|
||||
renewAfter: String? = "2026-09-27T00:00:00.000Z"
|
||||
) -> Data {
|
||||
var json: [String: Any] = [
|
||||
"deviceId": deviceId,
|
||||
"cert": certDER.base64EncodedString(),
|
||||
"caChain": caChain.map { $0.base64EncodedString() },
|
||||
]
|
||||
if let notBefore { json["notBefore"] = notBefore }
|
||||
if let notAfter { json["notAfter"] = notAfter }
|
||||
if let renewAfter { json["renewAfter"] = renewAfter }
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`:
|
||||
// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION —
|
||||
// which key signs the CSR, which endpoint it goes to, what the request body is
|
||||
// allowed to contain, and what happens to local state when the server's answer
|
||||
// is unusable. The signing key is injected (`keyProvider`) or pre-installed
|
||||
// under the store's derived tag, so no Secure Enclave is needed.
|
||||
//
|
||||
// Every test stops at the leaf-installation step by having the server return an
|
||||
// undecodable certificate (`undecodableCertificateDER`) — see the note there for
|
||||
// why installing a real leaf is not possible in a macOS `swift test` run. The
|
||||
// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly
|
||||
// stays a device/simulator concern.
|
||||
|
||||
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
@Test("renewalState is nil before any enrollment", .keychainSerialized)
|
||||
func renewalStateNilWhenNotEnrolled() async throws {
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized)
|
||||
func renewalStateReadsPersistedRecord() async throws {
|
||||
// Arrange — the record layout is a migration contract, so it is seeded
|
||||
// directly and read back through the public API.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||
let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(
|
||||
deviceId: "dev-42", deviceName: "Yaojia iPhone",
|
||||
caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter
|
||||
)
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — deviceId drives POST /device/:id/renew; the dates drive the
|
||||
// scheduler's decision.
|
||||
#expect(state.deviceId == "dev-42")
|
||||
#expect(state.notAfter == notAfter)
|
||||
#expect(state.renewAfter == renewAfter)
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1)))
|
||||
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false)
|
||||
}
|
||||
|
||||
@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized)
|
||||
func renewalStateWithoutRenewAfterNeverDue() async throws {
|
||||
// Arrange — an older server that omitted the advisory field.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act
|
||||
let state = try #require(try store.renewalState())
|
||||
|
||||
// Assert — the TLS stack stays the real gate; the scheduler must not guess.
|
||||
#expect(state.notAfter == nil)
|
||||
#expect(state.renewAfter == nil)
|
||||
#expect(state.isRenewalDue(asOf: Date.distantFuture) == false)
|
||||
}
|
||||
|
||||
@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized)
|
||||
func renewalStateCorruptRecord() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: Data("{\"deviceId\":".utf8) // truncated JSON
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
|
||||
// Act / Assert — never a silent "not enrolled" (that would make the
|
||||
// scheduler skip rotation forever on a device that IS enrolled).
|
||||
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try store.renewalState()
|
||||
}
|
||||
}
|
||||
|
||||
@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized)
|
||||
func enrollPostsCSRSignedByTheDeviceKey() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let deviceKey = try SecureEnclaveKeyFactory.generateSoftware()
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act — the response's leaf is undecodable, so persistence fails; the CSR has
|
||||
// already been built and sent, which is what this test is about.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||
keyProvider: { deviceKey }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — one request, to the enroll endpoint, carrying the A4 body.
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(body["subdomain"] as? String == "yaojia")
|
||||
#expect(body["deviceName"] as? String == "Yaojia iPhone")
|
||||
#expect(body["keyAlg"] as? String == "ec-p256")
|
||||
|
||||
// …and the CSR is bound to the injected key, with the device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPhone")
|
||||
#expect(csr.publicPointX963 == (try deviceKey.publicKeyX963()))
|
||||
}
|
||||
|
||||
@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized)
|
||||
func enrollDoesNotPersistOnUndecodableLeaf() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — a half-written record would make `renewalState` claim an
|
||||
// enrollment that has no leaf, and the scheduler would renew a phantom.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
#expect(try store.renewalState() == nil)
|
||||
}
|
||||
|
||||
@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized)
|
||||
func enrollPropagatesServerRejection() async throws {
|
||||
// Arrange — subdomain not owned by the account.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"])
|
||||
let transport = RecordingEnrollmentTransport(status: 403, body: body)
|
||||
let client = DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
|
||||
// Act / Assert
|
||||
await #expect(
|
||||
throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned")
|
||||
) {
|
||||
_ = try await store.enroll(
|
||||
using: client, subdomain: "someone-else", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
|
||||
@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutRecordDoesNotCallServer() async throws {
|
||||
// Arrange
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — nothing to renew: a fresh install or a legacy .p12-only
|
||||
// device. The scheduler must not fire a request it cannot authenticate.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew without the device key fails locally and never calls the server", .keychainSerialized)
|
||||
func renewWithoutDeviceKeyDoesNotCallServer() async throws {
|
||||
// Arrange — a record exists but the Secure-Enclave key is gone (restored
|
||||
// backup / manually cleared keychain).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount,
|
||||
data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone")
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act / Assert — a renew CSR signed by a NEW key would be rejected by the
|
||||
// server (PoP against the enrolled key), so it must not be attempted.
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
#expect(transport.callCount == 0)
|
||||
}
|
||||
|
||||
@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized)
|
||||
func renewReusesTheEnrolledDeviceKey() async throws {
|
||||
// Arrange — an enrolled device: record + the permanent key under the store's
|
||||
// derived tag (the shape `enroll` leaves behind).
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware(
|
||||
tag: keys.deviceKeyTag, permanent: true
|
||||
)
|
||||
let originalRecord = storedEnrollmentJSON(
|
||||
deviceId: "dev-77", deviceName: "Yaojia iPad",
|
||||
notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000),
|
||||
renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||
)
|
||||
KeychainProbe.write(
|
||||
service: keys.service, account: keys.enrollmentAccount, data: originalRecord
|
||||
)
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||
// Nil bearer: the renew endpoint authenticates by the CURRENT client cert.
|
||||
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||
|
||||
// Act
|
||||
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||
_ = try await store.renew(using: client)
|
||||
}
|
||||
|
||||
// Assert — the endpoint carries the stored deviceId…
|
||||
#expect(transport.callCount == 1)
|
||||
let request = try #require(transport.requests.first)
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
|
||||
// …the body is `{ csr }` ONLY (a stray field 400s every silent renewal)…
|
||||
let body = try #require(transport.jsonBody(at: 0))
|
||||
#expect(Set(body.keys) == ["csr"])
|
||||
|
||||
// …and the CSR re-uses the ENROLLED key (a new key would fail the server's
|
||||
// PoP-against-the-enrolled-key check) with the stored device name as CN.
|
||||
let csrDER = try #require(transport.csrDER(at: 0))
|
||||
let csr = try #require(parseCSR(csrDER))
|
||||
#expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963()))
|
||||
#expect(csr.subjectCommonName == "Yaojia iPad")
|
||||
|
||||
// …and a failed rotation leaves the EXISTING enrollment intact.
|
||||
let state = try #require(try store.renewalState())
|
||||
#expect(state.deviceId == "dev-77")
|
||||
#expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000))
|
||||
}
|
||||
|
||||
@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized)
|
||||
func enrollmentFlowWiresTheKeychainStore() async throws {
|
||||
// Arrange — the `store:` convenience initializer is the composition the app
|
||||
// uses: login → bearer → enroll → install. Its install step deliberately
|
||||
// takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key.
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
defer { KeychainProbe.purge(keys) }
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
let loginBody = try JSONSerialization.data(withJSONObject: [
|
||||
"enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300,
|
||||
])
|
||||
let transport = RecordingEnrollmentTransport(replies: [
|
||||
.init(status: 201, body: loginBody),
|
||||
.init(status: 201, body: enrollmentResponseJSON()),
|
||||
])
|
||||
let flow = DeviceEnrollmentFlow(
|
||||
baseURL: controlPlaneURL, transport: transport, store: store
|
||||
)
|
||||
|
||||
// Act
|
||||
var thrown: Error?
|
||||
do {
|
||||
_ = try await flow.run(
|
||||
password: "account-password", subdomain: "yaojia", deviceName: "iPhone"
|
||||
)
|
||||
} catch {
|
||||
thrown = error
|
||||
}
|
||||
|
||||
// Assert — login happened first, with the password and nothing else…
|
||||
let loginRequest = try #require(transport.requests.first)
|
||||
#expect(loginRequest.url?.path == "/auth/login")
|
||||
#expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"])
|
||||
|
||||
// …and the failure came from the INSTALL step, not the login step: the
|
||||
// convenience initializer really does route into the keychain store. Off
|
||||
// device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint
|
||||
// a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled
|
||||
// host gets as far as the undecodable leaf (`.corruptStoredBlob`).
|
||||
let error = try #require(thrown)
|
||||
#expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError)
|
||||
#expect(error is ControlPlaneLoginError == false)
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// B4 · The persistence half of enrollment: what `storeEnrolledLeaf` actually
|
||||
// leaves in the keychain. Three cases, all reachable off-device because the
|
||||
// server's answer is a REAL certificate here (`EnrolledLeafFixtures`):
|
||||
//
|
||||
// 1. first enroll → leaf installed + enrollment record ADDED
|
||||
// 2. rotation (new leaf) → distinct leaf added, record UPDATED in place
|
||||
// 3. retry (same leaf) → errSecDuplicateItem tolerated, record still correct
|
||||
//
|
||||
// Case 2/3 matter because the record write and the leaf write are separate
|
||||
// keychain operations: a device whose record says "dev-B" while the installed
|
||||
// leaf is "dev-A" cannot renew (the server checks the PoP against the enrolled
|
||||
// key for THAT deviceId).
|
||||
//
|
||||
// These tests install certificates into the real keychain, so each one sweeps the
|
||||
// fixture leaves before AND after itself — see `EnrolledLeafFixtures`.
|
||||
|
||||
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
|
||||
/// Run `body` with the fixture leaves swept on both sides. Actor-isolated like
|
||||
/// its callers so nothing crosses a concurrency boundary.
|
||||
private func withCleanLeafKeychain(
|
||||
_ body: (StoreKeychainKeys, KeychainClientIdentityStore) async throws -> Void
|
||||
) async throws {
|
||||
let keys = StoreKeychainKeys.unique()
|
||||
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||
defer {
|
||||
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||
KeychainProbe.purge(keys)
|
||||
}
|
||||
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||
try await body(keys, store)
|
||||
}
|
||||
|
||||
private func enrollmentClient(
|
||||
_ transport: RecordingEnrollmentTransport
|
||||
) -> DeviceEnrollmentClient {
|
||||
DeviceEnrollmentClient(
|
||||
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||
)
|
||||
}
|
||||
|
||||
@Test("a first enroll installs the leaf and records the server's rotation timing", .keychainSerialized)
|
||||
func enrollInstallsLeafAndRecord() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange
|
||||
#expect(try store.renewalState() == nil)
|
||||
let transport = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-first",
|
||||
certDER: EnrolledLeafFixtures.leaf,
|
||||
caChain: [EnrolledLeafFixtures.issuer],
|
||||
notAfter: "2026-10-28T00:00:00.000Z",
|
||||
renewAfter: "2026-09-27T00:00:00.000Z"
|
||||
)
|
||||
)
|
||||
|
||||
// Act — the returned summary is NOT asserted: reading it goes through
|
||||
// `SecItemCopyMatching(kSecClassIdentity)`, which on macOS ignores the
|
||||
// key tag and can hand back an unrelated login-keychain identity (see
|
||||
// `defaultKeychainYieldsForeignIdentity`). What is persisted IS asserted.
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(transport), subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Assert — exactly one leaf installed…
|
||||
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||
// …and the record carries what the server said, so a later renew can
|
||||
// address the right device.
|
||||
let state = try #require(try store.renewalState())
|
||||
#expect(state.deviceId == "dev-first")
|
||||
#expect(state.notAfter != nil)
|
||||
#expect(state.renewAfter != nil)
|
||||
#expect(state.isRenewalDue(asOf: try #require(state.renewAfter)))
|
||||
}
|
||||
}
|
||||
|
||||
@Test("rotation installs the new leaf and updates the record in place", .keychainSerialized)
|
||||
func rotationUpdatesRecordInPlace() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange — already enrolled.
|
||||
let first = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-old", certDER: EnrolledLeafFixtures.leaf
|
||||
)
|
||||
)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-old")
|
||||
|
||||
// Act — a rotation returns a DISTINCT leaf.
|
||||
let second = RecordingEnrollmentTransport(
|
||||
status: 201,
|
||||
body: enrollmentResponseJSON(
|
||||
deviceId: "dev-new", certDER: EnrolledLeafFixtures.rotatedLeaf
|
||||
)
|
||||
)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(second), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Assert — the record is UPDATED (one item, new value), never duplicated:
|
||||
// two records at the same (service, account) would make renew pick one at
|
||||
// random.
|
||||
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 1)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-new")
|
||||
// …and a leaf is installed at every step — the anti-lockout invariant
|
||||
// (ordering itself is pinned by KeychainItemReplaceTests).
|
||||
#expect(EnrolledLeafFixtures.installedCount() >= 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("re-storing a byte-identical leaf is tolerated and keeps the record correct", .keychainSerialized)
|
||||
func reEnrollingSameLeafIsIdempotent() async throws {
|
||||
try await withCleanLeafKeychain { keys, store in
|
||||
// Arrange — an interrupted enroll that is retried: the server re-issues
|
||||
// the SAME certificate.
|
||||
let body = enrollmentResponseJSON(
|
||||
deviceId: "dev-retry", certDER: EnrolledLeafFixtures.leaf
|
||||
)
|
||||
let first = RecordingEnrollmentTransport(status: 201, body: body)
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
|
||||
// Act — `SecItemAdd` now answers errSecDuplicateItem, which must NOT be
|
||||
// treated as a failure (the leaf we wanted installed IS installed).
|
||||
let retry = RecordingEnrollmentTransport(status: 201, body: body)
|
||||
await #expect(throws: Never.self) {
|
||||
_ = try await store.enroll(
|
||||
using: enrollmentClient(retry), subdomain: "yaojia", deviceName: "iPhone",
|
||||
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||
)
|
||||
}
|
||||
|
||||
// Assert — still one leaf, and the record is intact.
|
||||
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||
#expect(try store.renewalState()?.deviceId == "dev-retry")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
|
||||
// B4 · Why every real-keychain test in this package runs one at a time.
|
||||
//
|
||||
// `KeychainClientIdentityStore` / `SecureEnclaveKeyFactory` call `SecItem*` and
|
||||
// `SecKeyCreateRandomKey` WITHOUT `kSecUseDataProtectionKeychain`, so on macOS
|
||||
// `swift test` reaches the FILE-BASED login keychain. Two problems follow from
|
||||
// Swift Testing's default parallel execution:
|
||||
//
|
||||
// 1. That backend is not safe for concurrent writes from one process: two
|
||||
// simultaneous permanent-key generations intermittently fail with
|
||||
// keyGenerationFailed("… Code=-25300 \"failed to generate CDSA key\" …")
|
||||
// (observed 2026-07-30 — green under `--no-parallel`, red in parallel).
|
||||
// 2. Certificate items are keyed by the cert's own subject on macOS, so the
|
||||
// fixture-leaf sweep is GLOBAL: one test's cleanup would delete a
|
||||
// concurrently-running test's freshly installed leaf.
|
||||
//
|
||||
// A global actor is NOT enough: an actor releases its executor at every `await`,
|
||||
// and these tests await network-stub calls in the middle of their critical
|
||||
// section (proved by exactly failure mode 2 appearing when a `@globalActor` was
|
||||
// used). What is needed is a mutex held ACROSS suspension points — applied as a
|
||||
// custom trait so the whole test body, setup and cleanup included, is inside it.
|
||||
|
||||
/// A FIFO async mutex.
|
||||
actor KeychainTestMutex {
|
||||
static let shared = KeychainTestMutex()
|
||||
|
||||
private var isHeld = false
|
||||
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||
|
||||
func acquire() async {
|
||||
guard isHeld else {
|
||||
isHeld = true
|
||||
return
|
||||
}
|
||||
await withCheckedContinuation { waiters.append($0) }
|
||||
}
|
||||
|
||||
func release() {
|
||||
guard waiters.isEmpty else {
|
||||
waiters.removeFirst().resume() // hand the lock straight to the next waiter
|
||||
return
|
||||
}
|
||||
isHeld = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes a test against every other keychain-touching test.
|
||||
struct KeychainSerializedTrait: TestTrait, SuiteTrait, TestScoping {
|
||||
func provideScope(
|
||||
for test: Test,
|
||||
testCase: Test.Case?,
|
||||
performing function: @Sendable () async throws -> Void
|
||||
) async throws {
|
||||
await KeychainTestMutex.shared.acquire()
|
||||
do {
|
||||
try await function()
|
||||
} catch {
|
||||
await KeychainTestMutex.shared.release()
|
||||
throw error
|
||||
}
|
||||
await KeychainTestMutex.shared.release()
|
||||
}
|
||||
}
|
||||
|
||||
extension Trait where Self == KeychainSerializedTrait {
|
||||
/// Apply to every test that touches the real keychain.
|
||||
static var keychainSerialized: Self { Self() }
|
||||
}
|
||||
|
||||
/// Does the default keychain hand back an identity for a tag that was never
|
||||
/// installed?
|
||||
///
|
||||
/// On macOS's file-based keychain, `SecItemCopyMatching(kSecClass:
|
||||
/// kSecClassIdentity, kSecAttrApplicationTag: …)` IGNORES the tag and returns an
|
||||
/// arbitrary identity from the login keychain (verified 2026-07-30 with a random
|
||||
/// tag: `errSecSuccess` + an unrelated `localhost` identity). On iOS's
|
||||
/// data-protection keychain the tag IS honored, which is why the production code
|
||||
/// is correct on the shipping platform — but it means the `.p12` fallback branch
|
||||
/// of `loadIdentity()` is only reachable in a `swift test` run whose default
|
||||
/// keychain holds no identities (a clean CI runner). Tests that depend on that
|
||||
/// branch are gated on this probe instead of being silently wrong.
|
||||
func defaultKeychainYieldsForeignIdentity() -> Bool {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassIdentity,
|
||||
kSecAttrApplicationTag as String: Data("clienttls-probe-\(UUID().uuidString)".utf8),
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Foundation
|
||||
import Security
|
||||
@testable import ClientTLS
|
||||
|
||||
/// B4 · A KNOWN-GOOD PKCS#10 vector produced by OpenSSL, used to pin the
|
||||
/// hand-written DER encoder in `CertificateSigningRequest` byte-for-byte.
|
||||
///
|
||||
/// The ECDSA signature is randomized, so the stable part of a CSR is its
|
||||
/// `CertificationRequestInfo` — for a fixed key + fixed subject it is fully
|
||||
/// deterministic. That is what is pinned here: if our encoder ever drifts (a
|
||||
/// non-minimal length, a PrintableString instead of UTF8String, a wrong curve
|
||||
/// OID, an attributes SET that is not `A0 00`), the bytes stop matching
|
||||
/// OpenSSL's and this test fails.
|
||||
///
|
||||
/// Regenerate (OpenSSL 3.0.18, 2026-07-30):
|
||||
/// openssl ecparam -name prime256v1 -genkey -noout -out fixed.key.pem
|
||||
/// openssl req -new -key fixed.key.pem -subj "/CN=web-terminal-device" \
|
||||
/// -outform DER -out csr.der
|
||||
/// # CertificationRequestInfo = the first child of the outer SEQUENCE:
|
||||
/// openssl asn1parse -inform DER -in csr.der -i # → offset 3, hl 3, l 128
|
||||
/// dd if=csr.der bs=1 skip=3 count=131 | base64
|
||||
/// # private key as X9.63 (0x04||X||Y||K) for SecKeyCreateWithData:
|
||||
/// openssl ec -in fixed.key.pem -text -noout # → pub(65) || priv(32)
|
||||
///
|
||||
/// SECURITY: `privateKeyX963Base64` is a THROWAWAY key generated solely for this
|
||||
/// vector. It protects nothing, is never enrolled, and is never written to a
|
||||
/// keychain (`SecKeyCreateWithData` keeps it in-process). Same convention as the
|
||||
/// embedded `device.p12` + passphrase in `ClientTLSFixtures`.
|
||||
enum KnownGoodCSR {
|
||||
static let subjectCommonName = "web-terminal-device"
|
||||
|
||||
/// OpenSSL's `CertificationRequestInfo` DER for `privateKeyX963Base64` +
|
||||
/// `CN=web-terminal-device` (131 bytes).
|
||||
static let certificationRequestInfoBase64 = """
|
||||
MIGAAgEAMB4xHDAaBgNVBAMME3dlYi10ZXJtaW5hbC1kZXZpY2UwWTATBgcqhkjOPQIBBggqhkjO\
|
||||
PQMBBwNCAARiJ8iQGXzCgho1TRYPNNcnqtgK/mrsNf+CAvoPbSH+12v1Q2OfVRgVPVYeS2AO3y87\
|
||||
Oel9Uxe2QsHluJnGoifSoAA=
|
||||
"""
|
||||
|
||||
/// `0x04 || X(32) || Y(32) || K(32)` — the format `SecKeyCreateWithData`
|
||||
/// expects for an EC private key.
|
||||
static let privateKeyX963Base64 = """
|
||||
BGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs56X1TF7ZC\
|
||||
weW4mcaiJ9Ko07ifqUA6io//Czd0XRMztqg+nY0OJcA1Y1LwoBMBbA==
|
||||
"""
|
||||
|
||||
static var certificationRequestInfoDER: Data {
|
||||
Data(base64Encoded: certificationRequestInfoBase64, options: .ignoreUnknownCharacters)!
|
||||
}
|
||||
|
||||
/// The fixed key as a `P256HardwareKey`, in-process only (no keychain item).
|
||||
static func fixedKey() throws -> SecureEnclaveKey {
|
||||
let blob = Data(
|
||||
base64Encoded: privateKeyX963Base64, options: .ignoreUnknownCharacters
|
||||
)!
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
]
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateWithData(blob as CFData, attributes as CFDictionary, &error)
|
||||
else {
|
||||
throw SecureEnclaveKeyError.keyGenerationFailed(
|
||||
String(describing: error?.takeRetainedValue())
|
||||
)
|
||||
}
|
||||
return SecureEnclaveKey(privateKey: key)
|
||||
}
|
||||
}
|
||||
@@ -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