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:
Yaojia Wang
2026-07-30 12:45:26 +02:00
parent 850531fd07
commit a5fa843f00
14 changed files with 1733 additions and 46 deletions

View File

@@ -6,8 +6,9 @@
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected # the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh # per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*). # (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle, # 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
# iPhone 16 simulator): ViewModels/components of the app glue layer. # an iPhone AND an iPad simulator: ViewModels/components of the app glue
# layer, on both the compact and the regular size class.
# 3. integration-tests — Swift Testing against the REAL Node server. The # 3. integration-tests — Swift Testing against the REAL Node server. The
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral # ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1:<free-port> is always in the derived Origin # loopback port (127.0.0.1:<free-port> is always in the derived Origin
@@ -35,14 +36,15 @@ on:
jobs: jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate. # Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs # The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
# tests below without a gate (test doubles are excluded from the gate). # mTLS identity/keychain package, previously the only ungated one at 55.76%).
# TestSupport runs tests below without a gate (test doubles are excluded).
package-tests: package-tests:
runs-on: macos-15 runs-on: macos-15
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
package: [WireProtocol, SessionCore, HostRegistry, APIClient] package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
@@ -56,47 +58,66 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages) - name: swift test (TestSupport — no coverage gate; it IS the test doubles)
run: swift test --package-path ios/Packages/TestSupport run: swift test --package-path ios/Packages/TestSupport
# Layer 2: app-target unit tests (WebTermTests, hosted by the app). # Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
# compact). One matrix instead of two near-identical jobs.
#
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
# LiveServerSmokeTests, whose SimServerHarness spawns
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
# node_modules, so the harness throws
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
# regular-width layout, not to re-verify the client↔server contract, which is
# already covered three times over (this leg, integration-tests, ui-test).
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
# test plans live in ios/project.yml, owned by another task.
app-tests: app-tests:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
needsNode: true
extraArgs: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
needsNode: false
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- uses: actions/setup-node@v4
if: matrix.needsNode
with:
node-version: 22
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
if: matrix.needsNode
run: npm ci
- name: Install XcodeGen - name: Install XcodeGen
run: brew install xcodegen run: brew install xcodegen
- name: Generate project - name: Generate project
run: cd ios && xcodegen generate run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPhone 16 simulator) - name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
# real data-protection keychain, which returns -34018 for UNSIGNED test # real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix # hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.) # handoff finding, verified locally in the ui-test leg runs.)
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPhone 16' \ -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
test ${{ matrix.extraArgs }} \
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
# exercised in CI, not only the compact iPhone path.
ipad-tests:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
- name: Install XcodeGen
run: brew install xcodegen
- name: Generate project
run: cd ios && xcodegen generate
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
test test
# Layer 3: contract tests against the real Node server (T-iOS-16 test list). # Layer 3: contract tests against the real Node server (T-iOS-16 test list).
@@ -121,9 +142,27 @@ jobs:
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's # runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the # TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission). # server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
#
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
# reveal), so running it on iPad would fail for a missing-feature reason rather
# than a regression — making the happy path iPad-adaptive is app-layer work,
# tracked separately.
ui-test: ui-test:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45 timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- leg: iphone
device: iPhone 16
suites: ""
- leg: ipad
device: iPad Pro 11-inch (M4)
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 - name: Select Xcode 16.3
@@ -159,7 +198,7 @@ jobs:
# inputAccessoryView — it only exists while the SOFT keyboard is up. # inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard) - name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator) - name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it # TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects <VAR> into the test-runner process); # strips the prefix and injects <VAR> into the test-runner process);
# passing it as a KEY=VALUE argument makes it a build setting, which # passing it as a KEY=VALUE argument makes it a build setting, which
@@ -172,7 +211,8 @@ jobs:
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217 TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
-destination 'platform=iOS Simulator,name=iPhone 16' test -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
${{ matrix.suites }} test
- name: Server log + shutdown - name: Server log + shutdown
if: always() if: always()
run: | run: |
@@ -181,15 +221,21 @@ jobs:
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the # Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# WebTermTests unit bundle on an iOS 17.x simulator runtime. # WebTermTests unit bundle on an iOS 17.x simulator runtime.
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose #
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so # CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
# Xcode 16.x can build against it. Local machines are NOT expected to # runtime, so this leg is never reproducible on a dev box.
# download the ~8 GB runtime. If the runner image drops the 17.x runtime, #
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures # NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
# fail the job (no silent pass). # runner image had no iOS 17.x runtime, and the test step was gated on
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
# deployment-floor round vanished and the job still reported GREEN. A leg that
# can silently stop testing is worse than no leg. Now: if the runtime is
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
# -buildVersion`); if the download does not produce a usable runtime, the job
# FAILS. The test step is unconditional.
ios17-floor-tests: ios17-floor-tests:
runs-on: macos-15 runs-on: macos-15
timeout-minutes: 45 timeout-minutes: 90 # the runtime download alone can take ~15 min
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain) - name: Select Xcode 16.3 (build toolchain)
@@ -198,23 +244,40 @@ jobs:
run: brew install xcodegen run: brew install xcodegen
- name: Generate project - name: Generate project
run: cd ios && xcodegen generate run: cd ios && xcodegen generate
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent) - name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
id: sim17 id: sim17
env:
IOS17_FALLBACK_VERSION: "17.5"
run: | run: |
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)" find_runtime() {
xcrun simctl list runtimes \
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
| tail -n 1
}
runtime="$(find_runtime || true)"
if [ -z "$runtime" ]; then if [ -z "$runtime" ]; then
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run" echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
echo "runtime=" >> "$GITHUB_OUTPUT" sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
exit 0 runtime="$(find_runtime || true)"
fi
if [ -z "$runtime" ]; then
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
xcrun simctl list runtimes >&2
exit 1
fi fi
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")" udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime" echo "created $udid with $runtime"
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT" echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed # No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018. # (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
#
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
# iOS 17), not the server contract, so it stays Node-free.
- name: xcodebuild test (WebTermTests on the iOS 17.x floor) - name: xcodebuild test (WebTermTests on the iOS 17.x floor)
if: steps.sim17.outputs.runtime != ''
run: | run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \ xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test -destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
-skip-testing:WebTermTests/LiveServerSmokeTests \
test

View File

@@ -5,6 +5,12 @@
# Usage: coverage-gate.sh <PackageName> # e.g. coverage-gate.sh WireProtocol # Usage: coverage-gate.sh <PackageName> # e.g. coverage-gate.sh WireProtocol
# Env: COVERAGE_THRESHOLD=<percent> # default 80 (red/green demo knob) # Env: COVERAGE_THRESHOLD=<percent> # default 80 (red/green demo knob)
# #
# GATED PACKAGES (kept in sync with .github/workflows/ios.yml's package-tests
# matrix): WireProtocol, SessionCore, HostRegistry, APIClient — the four of plan
# §9 — plus ClientTLS, added by B4 (it holds the mTLS identity/keychain code, the
# most security-sensitive package in the tree, and was the only ungated one).
# TestSupport stays ungated: it IS the test doubles.
#
# WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command # WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command
# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/' # xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/'
# | jq '.data[0].totals.lines.percent >= 80' # | jq '.data[0].totals.lines.percent >= 80'
@@ -20,7 +26,7 @@
set -euo pipefail set -euo pipefail
PKG="${1:?usage: coverage-gate.sh <PackageName> (WireProtocol|SessionCore|HostRegistry|APIClient)}" PKG="${1:?usage: coverage-gate.sh <PackageName> (WireProtocol|SessionCore|HostRegistry|APIClient|ClientTLS)}"
THRESHOLD="${COVERAGE_THRESHOLD:-80}" THRESHOLD="${COVERAGE_THRESHOLD:-80}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

View File

@@ -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])
)
}

View File

@@ -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])
}
}

View File

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

View File

@@ -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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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