diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml
index e6c3090..3593edf 100644
--- a/.github/workflows/ios.yml
+++ b/.github/workflows/ios.yml
@@ -6,8 +6,9 @@
# 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
# (jq keeps only Packages/
/Sources/, excluding *Placeholder*).
-# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
-# iPhone 16 simulator): ViewModels/components of the app glue layer.
+# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
+# 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
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
# loopback port (127.0.0.1: is always in the derived Origin
@@ -35,14 +36,15 @@ on:
jobs:
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
- # The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
- # tests below without a gate (test doubles are excluded from the gate).
+ # The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
+ # 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:
runs-on: macos-15
strategy:
fail-fast: false
matrix:
- package: [WireProtocol, SessionCore, HostRegistry, APIClient]
+ package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
@@ -56,47 +58,66 @@ jobs:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
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
- # 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:
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:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
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
run: brew install xcodegen
- name: Generate project
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
# real data-protection keychain, which returns -34018 for UNSIGNED test
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
# handoff finding, verified locally in the ui-test leg runs.)
run: |
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
- -destination 'platform=iOS Simulator,name=iPhone 16' \
- test
-
- # 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)' \
+ -destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
+ ${{ matrix.extraArgs }} \
test
# 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
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
# 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:
runs-on: macos-15
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:
- uses: actions/checkout@v4
- name: Select Xcode 16.3
@@ -159,7 +198,7 @@ jobs:
# inputAccessoryView — it only exists while the SOFT keyboard is up.
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
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_ must be an ENV VAR of the xcodebuild process (it
# strips the prefix and injects into the test-runner process);
# 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
run: |
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
if: always()
run: |
@@ -181,15 +221,21 @@ jobs:
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
# 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
- # Xcode 16.x can build against it. Local machines are NOT expected to
- # 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
- # fail the job (no silent pass).
+ #
+ # CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
+ # runtime, so this leg is never reproducible on a dev box.
+ #
+ # NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
+ # 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:
runs-on: macos-15
- timeout-minutes: 45
+ timeout-minutes: 90 # the runtime download alone can take ~15 min
steps:
- uses: actions/checkout@v4
- name: Select Xcode 16.3 (build toolchain)
@@ -198,23 +244,40 @@ jobs:
run: brew install xcodegen
- name: Generate project
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
+ env:
+ IOS17_FALLBACK_VERSION: "17.5"
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
- 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 "runtime=" >> "$GITHUB_OUTPUT"
- exit 0
+ echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
+ sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
+ 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
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
echo "created $udid with $runtime"
- echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
echo "udid=$udid" >> "$GITHUB_OUTPUT"
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
# (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)
- if: steps.sim17.outputs.runtime != ''
run: |
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
diff --git a/ios/IntegrationTests/scripts/coverage-gate.sh b/ios/IntegrationTests/scripts/coverage-gate.sh
index 4c63555..7832552 100755
--- a/ios/IntegrationTests/scripts/coverage-gate.sh
+++ b/ios/IntegrationTests/scripts/coverage-gate.sh
@@ -5,6 +5,12 @@
# Usage: coverage-gate.sh # e.g. coverage-gate.sh WireProtocol
# Env: COVERAGE_THRESHOLD= # 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
# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/'
# | jq '.data[0].totals.lines.percent >= 80'
@@ -20,7 +26,7 @@
set -euo pipefail
-PKG="${1:?usage: coverage-gate.sh (WireProtocol|SessionCore|HostRegistry|APIClient)}"
+PKG="${1:?usage: coverage-gate.sh (WireProtocol|SessionCore|HostRegistry|APIClient|ClientTLS)}"
THRESHOLD="${COVERAGE_THRESHOLD:-80}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift
new file mode 100644
index 0000000..8a26584
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift
@@ -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.. bitString.valueStart + 1 else { return nil }
+ let point = Data(bytes[(bitString.valueStart + 1)..?
+ 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 .
+ 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])
+ }
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift
new file mode 100644
index 0000000..e94651e
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift
@@ -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)
+ }
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift
new file mode 100644
index 0000000..c49287b
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift
@@ -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
+ }
+ }
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift
new file mode 100644
index 0000000..d11525e
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift
@@ -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 `). 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)
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift
new file mode 100644
index 0000000..b9578bc
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift
@@ -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)
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift
new file mode 100644
index 0000000..c57d066
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift
@@ -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")
+ }
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift
new file mode 100644
index 0000000..2c48fba
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift
@@ -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)
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift
new file mode 100644
index 0000000..f8cee46
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift
@@ -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)
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift
new file mode 100644
index 0000000..f316082
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift
@@ -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] = []
+
+ 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
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift
new file mode 100644
index 0000000..869ac23
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift
@@ -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?
+ guard let key = SecKeyCreateWithData(blob as CFData, attributes as CFDictionary, &error)
+ else {
+ throw SecureEnclaveKeyError.keyGenerationFailed(
+ String(describing: error?.takeRetainedValue())
+ )
+ }
+ return SecureEnclaveKey(privateKey: key)
+ }
+}
diff --git a/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift b/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift
new file mode 100644
index 0000000..30c28e9
--- /dev/null
+++ b/ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift
@@ -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")
+}