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,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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user