From a5fa843f00f5f92a24f8eaccec8aac1755624bc0 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 30 Jul 2026 12:45:26 +0200 Subject: [PATCH] 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. --- .github/workflows/ios.yml | 153 ++++++--- ios/IntegrationTests/scripts/coverage-gate.sh | 8 +- .../Tests/ClientTLSTests/CSRTestParser.swift | 52 +++ ...CertificateSigningRequestVectorTests.swift | 121 +++++++ .../ClientTLSSessionDelegateTests.swift | 92 +++++ .../ClientTLSTests/EnrolledLeafFixtures.swift | 103 ++++++ .../ClientTLSTests/EnrollmentStubs.swift | 95 ++++++ ...inClientIdentityStoreEnrollmentTests.swift | 315 ++++++++++++++++++ ...KeychainClientIdentityStoreLeafTests.swift | 151 +++++++++ ...KeychainClientIdentityStoreLiveTests.swift | 210 ++++++++++++ .../ClientTLSTests/KeychainTestProbe.swift | 134 ++++++++ .../KeychainTestSerialization.swift | 92 +++++ .../ClientTLSTests/KnownGoodCSRVector.swift | 70 ++++ .../SecureEnclaveKeyTests.swift | 183 ++++++++++ 14 files changed, 1733 insertions(+), 46 deletions(-) create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/CSRTestParser.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/CertificateSigningRequestVectorTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/ClientTLSSessionDelegateTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrolledLeafFixtures.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/EnrollmentStubs.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreEnrollmentTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLeafTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainClientIdentityStoreLiveTests.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestProbe.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KeychainTestSerialization.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/KnownGoodCSRVector.swift create mode 100644 ios/Packages/ClientTLS/Tests/ClientTLSTests/SecureEnclaveKeyTests.swift 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") +}