test(ios): ClientTLS coverage 55.76% -> 89.49%, gate it, fix the three dead CI legs
ClientTLS was the most security-sensitive package in the tree and the least covered, and it was not in the coverage gate at all (the gate's 4-package set predates it). 48 -> 84 tests against the real macOS keychain, serialized with a custom Testing trait after a @globalActor proved insufficient (actors yield at await, so cross-await critical sections got interleaved by other cases' cleanup). CI: the app/ipad/ios17 legs ran a bundle containing LiveServerSmokeTests, which spawns tsx, with no npm ci -- a hard failure, not a skip, on a bare checkout. Adds the missing iPad UI-test leg, and makes a missing iOS 17 runtime fail loudly instead of silently reporting green.
This commit is contained in:
153
.github/workflows/ios.yml
vendored
153
.github/workflows/ios.yml
vendored
@@ -6,8 +6,9 @@
|
|||||||
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
# the test binary (a known flaw, fix assigned to T-iOS-16). The corrected
|
||||||
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
# per-package filter lives in ios/IntegrationTests/scripts/coverage-gate.sh
|
||||||
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
# (jq keeps only Packages/<P>/Sources/, excluding *Placeholder*).
|
||||||
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle,
|
# 2. app-tests — xcodegen + xcodebuild test (WebTermTests bundle) on
|
||||||
# iPhone 16 simulator): ViewModels/components of the app glue layer.
|
# an iPhone AND an iPad simulator: ViewModels/components of the app glue
|
||||||
|
# layer, on both the compact and the regular size class.
|
||||||
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
# 3. integration-tests — Swift Testing against the REAL Node server. The
|
||||||
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
# ServerHarness self-bootstraps `tsx src/server.ts` on an ephemeral
|
||||||
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
# loopback port (127.0.0.1:<free-port> is always in the derived Origin
|
||||||
@@ -35,14 +36,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
# Layer 1: pure-SwiftPM package tests + the 80% own-sources coverage gate.
|
||||||
# The gate covers exactly the 4 gated packages (plan §9); TestSupport runs
|
# The gate covers the 4 packages of plan §9 PLUS ClientTLS (added by B4 — the
|
||||||
# tests below without a gate (test doubles are excluded from the gate).
|
# mTLS identity/keychain package, previously the only ungated one at 55.76%).
|
||||||
|
# TestSupport runs tests below without a gate (test doubles are excluded).
|
||||||
package-tests:
|
package-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
package: [WireProtocol, SessionCore, HostRegistry, APIClient]
|
package: [WireProtocol, SessionCore, HostRegistry, APIClient, ClientTLS]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -56,47 +58,66 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
- name: swift test (TestSupport — no coverage gate, plan §9 gates 4 packages)
|
- name: swift test (TestSupport — no coverage gate; it IS the test doubles)
|
||||||
run: swift test --package-path ios/Packages/TestSupport
|
run: swift test --package-path ios/Packages/TestSupport
|
||||||
|
|
||||||
# Layer 2: app-target unit tests (WebTermTests, hosted by the app).
|
# Layer 2: app-target unit tests (WebTermTests, hosted by the app), on the
|
||||||
|
# compact iPhone path AND the regular iPad path (T-iPad-1: the adaptive
|
||||||
|
# NavigationSplitView layout of T-iPad-2 must be exercised in CI, not only
|
||||||
|
# compact). One matrix instead of two near-identical jobs.
|
||||||
|
#
|
||||||
|
# THE NODE DEPENDENCY (B4 fix): the WebTermTests bundle contains
|
||||||
|
# LiveServerSmokeTests, whose SimServerHarness spawns
|
||||||
|
# `node_modules/.bin/tsx src/server.ts`. On a bare checkout there is no
|
||||||
|
# node_modules, so the harness throws
|
||||||
|
# setup: 找不到 …/node_modules/.bin/tsx — 先在 repo 根目录跑 npm install/npm ci
|
||||||
|
# and the test HARD-FAILS (it is not a skip) — i.e. both legs were red on every
|
||||||
|
# run. Fix: `npm ci` on the canonical iPhone leg, which is the one that owns the
|
||||||
|
# live-server smoke; the iPad leg SKIPS that suite instead of paying a second
|
||||||
|
# node-pty native build. Rationale: the iPad leg exists to exercise the app's
|
||||||
|
# regular-width layout, not to re-verify the client↔server contract, which is
|
||||||
|
# already covered three times over (this leg, integration-tests, ui-test).
|
||||||
|
# `-skip-testing` (rather than a test plan) keeps the change inside this file:
|
||||||
|
# test plans live in ios/project.yml, owned by another task.
|
||||||
app-tests:
|
app-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
|
timeout-minutes: 45
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- leg: iphone
|
||||||
|
device: iPhone 16
|
||||||
|
needsNode: true
|
||||||
|
extraArgs: ""
|
||||||
|
- leg: ipad
|
||||||
|
device: iPad Pro 11-inch (M4)
|
||||||
|
needsNode: false
|
||||||
|
extraArgs: "-skip-testing:WebTermTests/LiveServerSmokeTests"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
if: matrix.needsNode
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- name: npm ci (LiveServerSmokeTests spawns node_modules/.bin/tsx)
|
||||||
|
if: matrix.needsNode
|
||||||
|
run: npm ci
|
||||||
- name: Install XcodeGen
|
- name: Install XcodeGen
|
||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: xcodebuild test (WebTermTests, iPhone 16 simulator)
|
- name: xcodebuild test (WebTermTests, ${{ matrix.device }} simulator)
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests exercises the
|
||||||
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
# real data-protection keychain, which returns -34018 for UNSIGNED test
|
||||||
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
# hosts; simulator ad-hoc signing needs no certificates. (W5-fix
|
||||||
# handoff finding, verified locally in the ui-test leg runs.)
|
# handoff finding, verified locally in the ui-test leg runs.)
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' \
|
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||||
test
|
${{ matrix.extraArgs }} \
|
||||||
|
|
||||||
# iPad adaptation (T-iPad-1): run the same app suite on an iPad simulator so
|
|
||||||
# the adaptive layout (regular size class / NavigationSplitView, T-iPad-2) is
|
|
||||||
# exercised in CI, not only the compact iPhone path.
|
|
||||||
ipad-tests:
|
|
||||||
runs-on: macos-15
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Select Xcode 16.3
|
|
||||||
run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer
|
|
||||||
- name: Install XcodeGen
|
|
||||||
run: brew install xcodegen
|
|
||||||
- name: Generate project
|
|
||||||
run: cd ios && xcodegen generate
|
|
||||||
- name: xcodebuild test (WebTermTests, iPad Pro 11-inch simulator)
|
|
||||||
run: |
|
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
|
||||||
-destination 'platform=iOS Simulator,name=iPad Pro 11-inch (M4)' \
|
|
||||||
test
|
test
|
||||||
|
|
||||||
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
# Layer 3: contract tests against the real Node server (T-iOS-16 test list).
|
||||||
@@ -121,9 +142,27 @@ jobs:
|
|||||||
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
# runner process reads WEBTERM_SERVER_URL (delivered via xcodebuild's
|
||||||
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
# TEST_RUNNER_ env prefix) and makes its own HTTP assertions against the
|
||||||
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
# server (/live-sessions, /live-sessions/:id/preview, held /hook/permission).
|
||||||
|
#
|
||||||
|
# iPad leg (B4 fix): ProjectsLayoutUITests has an explicit `isPad` branch
|
||||||
|
# (landscape + NavigationSplitView sidebar reveal, T-iPad-4) that NEVER ran —
|
||||||
|
# the only UI leg was iPhone. It now runs on an iPad simulator too. Only THAT
|
||||||
|
# suite: HappyPathUITests has no regular-width branch at all (no sidebar
|
||||||
|
# reveal), so running it on iPad would fail for a missing-feature reason rather
|
||||||
|
# than a regression — making the happy path iPad-adaptive is app-layer work,
|
||||||
|
# tracked separately.
|
||||||
ui-test:
|
ui-test:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- leg: iphone
|
||||||
|
device: iPhone 16
|
||||||
|
suites: ""
|
||||||
|
- leg: ipad
|
||||||
|
device: iPad Pro 11-inch (M4)
|
||||||
|
suites: "-only-testing:WebTermUITests/ProjectsLayoutUITests"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3
|
- name: Select Xcode 16.3
|
||||||
@@ -159,7 +198,7 @@ jobs:
|
|||||||
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
# inputAccessoryView — it only exists while the SOFT keyboard is up.
|
||||||
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
- name: Disable simulator hardware keyboard (KeyBar rides the soft keyboard)
|
||||||
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
run: defaults write com.apple.iphonesimulator ConnectHardwareKeyboard -bool false
|
||||||
- name: xcodebuild test (WebTermUITests, iPhone 16 simulator)
|
- name: xcodebuild test (WebTermUITests, ${{ matrix.device }} simulator)
|
||||||
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
# TEST_RUNNER_<VAR> must be an ENV VAR of the xcodebuild process (it
|
||||||
# strips the prefix and injects <VAR> into the test-runner process);
|
# strips the prefix and injects <VAR> into the test-runner process);
|
||||||
# passing it as a KEY=VALUE argument makes it a build setting, which
|
# passing it as a KEY=VALUE argument makes it a build setting, which
|
||||||
@@ -172,7 +211,8 @@ jobs:
|
|||||||
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
TEST_RUNNER_WEBTERM_SERVER_URL: http://127.0.0.1:3217
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTermUITests \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16' test
|
-destination 'platform=iOS Simulator,name=${{ matrix.device }}' \
|
||||||
|
${{ matrix.suites }} test
|
||||||
- name: Server log + shutdown
|
- name: Server log + shutdown
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -181,15 +221,21 @@ jobs:
|
|||||||
|
|
||||||
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
# Device-matrix floor (plan §9: "iOS 17 最低目标模拟器各一轮"): run the
|
||||||
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
# WebTermTests unit bundle on an iOS 17.x simulator runtime.
|
||||||
# CI-ONLY LEG: GitHub macOS runner images ship older Xcode versions whose
|
#
|
||||||
# iOS 17.x simulator runtime is registered system-wide with CoreSimulator, so
|
# CI-ONLY LEG: local machines are NOT expected to hold the ~7 GB iOS 17
|
||||||
# Xcode 16.x can build against it. Local machines are NOT expected to
|
# runtime, so this leg is never reproducible on a dev box.
|
||||||
# download the ~8 GB runtime. If the runner image drops the 17.x runtime,
|
#
|
||||||
# the leg skips WITH A LOUD NOTICE; if the runtime exists, test failures
|
# NO SILENT SKIP (B4 fix): this step used to `exit 0` with a ::notice when the
|
||||||
# fail the job (no silent pass).
|
# runner image had no iOS 17.x runtime, and the test step was gated on
|
||||||
|
# `if: steps.sim17.outputs.runtime != ''` — so on image drift the whole
|
||||||
|
# deployment-floor round vanished and the job still reported GREEN. A leg that
|
||||||
|
# can silently stop testing is worse than no leg. Now: if the runtime is
|
||||||
|
# missing, it is DOWNLOADED (`xcodebuild -downloadPlatform iOS
|
||||||
|
# -buildVersion`); if the download does not produce a usable runtime, the job
|
||||||
|
# FAILS. The test step is unconditional.
|
||||||
ios17-floor-tests:
|
ios17-floor-tests:
|
||||||
runs-on: macos-15
|
runs-on: macos-15
|
||||||
timeout-minutes: 45
|
timeout-minutes: 90 # the runtime download alone can take ~15 min
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Select Xcode 16.3 (build toolchain)
|
- name: Select Xcode 16.3 (build toolchain)
|
||||||
@@ -198,23 +244,40 @@ jobs:
|
|||||||
run: brew install xcodegen
|
run: brew install xcodegen
|
||||||
- name: Generate project
|
- name: Generate project
|
||||||
run: cd ios && xcodegen generate
|
run: cd ios && xcodegen generate
|
||||||
- name: Create iOS 17.x simulator (skip-with-notice if runtime absent)
|
- name: Ensure an iOS 17.x simulator runtime (download if the image lacks it)
|
||||||
id: sim17
|
id: sim17
|
||||||
|
env:
|
||||||
|
IOS17_FALLBACK_VERSION: "17.5"
|
||||||
run: |
|
run: |
|
||||||
runtime="$(xcrun simctl list runtimes | grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' | tail -n 1 || true)"
|
find_runtime() {
|
||||||
|
xcrun simctl list runtimes \
|
||||||
|
| grep -Eo 'com\.apple\.CoreSimulator\.SimRuntime\.iOS-17-[0-9]+' \
|
||||||
|
| tail -n 1
|
||||||
|
}
|
||||||
|
runtime="$(find_runtime || true)"
|
||||||
if [ -z "$runtime" ]; then
|
if [ -z "$runtime" ]; then
|
||||||
echo "::notice title=iOS 17 floor leg skipped::no iOS 17.x simulator runtime on this runner image (image drift) — the deployment-floor round did NOT run"
|
echo "::warning title=iOS 17 runtime absent::downloading iOS ${IOS17_FALLBACK_VERSION} simulator runtime (runner image drift)"
|
||||||
echo "runtime=" >> "$GITHUB_OUTPUT"
|
sudo xcodebuild -downloadPlatform iOS -buildVersion "$IOS17_FALLBACK_VERSION" || true
|
||||||
exit 0
|
runtime="$(find_runtime || true)"
|
||||||
|
fi
|
||||||
|
if [ -z "$runtime" ]; then
|
||||||
|
echo "::error title=iOS 17 floor leg cannot run::no iOS 17.x simulator runtime and the download failed — the deployment-floor round did NOT run, so this job fails instead of reporting a false green" >&2
|
||||||
|
xcrun simctl list runtimes >&2
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
udid="$(xcrun simctl create 'iPhone 15 iOS17' 'iPhone 15' "$runtime")"
|
||||||
echo "created $udid with $runtime"
|
echo "created $udid with $runtime"
|
||||||
echo "runtime=$runtime" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
echo "udid=$udid" >> "$GITHUB_OUTPUT"
|
||||||
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
# No CODE_SIGNING_ALLOWED=NO: KeychainHostStoreLiveTests needs a signed
|
||||||
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
# (ad-hoc, certificate-free on simulator) app host — unsigned = -34018.
|
||||||
|
#
|
||||||
|
# -skip-testing LiveServerSmokeTests: same reason as the iPad leg — that
|
||||||
|
# suite spawns node_modules/.bin/tsx and would hard-fail without `npm ci`.
|
||||||
|
# This leg's job is the DEPLOYMENT FLOOR (does the app build and behave on
|
||||||
|
# iOS 17), not the server contract, so it stays Node-free.
|
||||||
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
- name: xcodebuild test (WebTermTests on the iOS 17.x floor)
|
||||||
if: steps.sim17.outputs.runtime != ''
|
|
||||||
run: |
|
run: |
|
||||||
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
xcodebuild -project ios/WebTerm.xcodeproj -scheme WebTerm \
|
||||||
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" test
|
-destination "platform=iOS Simulator,id=${{ steps.sim17.outputs.udid }}" \
|
||||||
|
-skip-testing:WebTermTests/LiveServerSmokeTests \
|
||||||
|
test
|
||||||
|
|||||||
@@ -5,6 +5,12 @@
|
|||||||
# Usage: coverage-gate.sh <PackageName> # e.g. coverage-gate.sh WireProtocol
|
# Usage: coverage-gate.sh <PackageName> # e.g. coverage-gate.sh WireProtocol
|
||||||
# Env: COVERAGE_THRESHOLD=<percent> # default 80 (red/green demo knob)
|
# Env: COVERAGE_THRESHOLD=<percent> # default 80 (red/green demo knob)
|
||||||
#
|
#
|
||||||
|
# GATED PACKAGES (kept in sync with .github/workflows/ios.yml's package-tests
|
||||||
|
# matrix): WireProtocol, SessionCore, HostRegistry, APIClient — the four of plan
|
||||||
|
# §9 — plus ClientTLS, added by B4 (it holds the mTLS identity/keychain code, the
|
||||||
|
# most security-sensitive package in the tree, and was the only ungated one).
|
||||||
|
# TestSupport stays ungated: it IS the test doubles.
|
||||||
|
#
|
||||||
# WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command
|
# WHY THIS EXISTS (plan §9 flaw, fix assigned to T-iOS-16): the raw §9 command
|
||||||
# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/'
|
# xcrun llvm-cov export -summary-only ... -ignore-filename-regex '(Tests|TestSupport|\.build)/'
|
||||||
# | jq '.data[0].totals.lines.percent >= 80'
|
# | jq '.data[0].totals.lines.percent >= 80'
|
||||||
@@ -20,7 +26,7 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
PKG="${1:?usage: coverage-gate.sh <PackageName> (WireProtocol|SessionCore|HostRegistry|APIClient)}"
|
PKG="${1:?usage: coverage-gate.sh <PackageName> (WireProtocol|SessionCore|HostRegistry|APIClient|ClientTLS)}"
|
||||||
THRESHOLD="${COVERAGE_THRESHOLD:-80}"
|
THRESHOLD="${COVERAGE_THRESHOLD:-80}"
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// B4 · Read back the fields of a PKCS#10 CSR so tests can assert on what was
|
||||||
|
// ACTUALLY sent to the control plane (subject CN, embedded public key) instead
|
||||||
|
// of trusting the encoder's own view. Uses the throwaway `TestDER` reader from
|
||||||
|
// CertificateSigningRequestTests.
|
||||||
|
|
||||||
|
/// The two CSR fields the store/rotation invariants are stated in terms of.
|
||||||
|
struct ParsedCSR: Equatable {
|
||||||
|
/// `CertificationRequestInfo.subject` — the single CN RDN.
|
||||||
|
let subjectCommonName: String
|
||||||
|
/// `subjectPKInfo` BIT STRING content: the X9.63 uncompressed point.
|
||||||
|
let publicPointX963: Data
|
||||||
|
/// The exact `CertificationRequestInfo` DER (the bytes that were signed).
|
||||||
|
let certificationRequestInfoDER: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a `CertificationRequest` DER. `nil` when the shape is not the canonical
|
||||||
|
/// `SEQUENCE { info, algId, BIT STRING }` this package emits.
|
||||||
|
func parseCSR(_ der: Data) -> ParsedCSR? {
|
||||||
|
let bytes = [UInt8](der)
|
||||||
|
guard let outer = TestDER.read(bytes, at: 0), outer.end == bytes.count else { return nil }
|
||||||
|
let parts = TestDER.children(bytes, outer)
|
||||||
|
guard parts.count == 3 else { return nil }
|
||||||
|
|
||||||
|
let info = parts[0]
|
||||||
|
let infoChildren = TestDER.children(bytes, info)
|
||||||
|
guard infoChildren.count == 4 else { return nil }
|
||||||
|
|
||||||
|
// subject: SEQUENCE { SET { SEQUENCE { OID commonName, UTF8String } } }
|
||||||
|
let rdnSequence = TestDER.children(bytes, infoChildren[1])
|
||||||
|
guard let rdnSet = rdnSequence.first else { return nil }
|
||||||
|
let attributes = TestDER.children(bytes, rdnSet)
|
||||||
|
guard let attribute = attributes.first else { return nil }
|
||||||
|
let attributeParts = TestDER.children(bytes, attribute)
|
||||||
|
guard attributeParts.count == 2 else { return nil }
|
||||||
|
let commonNameBytes = Array(bytes[attributeParts[1].valueStart..<attributeParts[1].valueEnd])
|
||||||
|
guard let commonName = String(bytes: commonNameBytes, encoding: .utf8) else { return nil }
|
||||||
|
|
||||||
|
// subjectPKInfo: SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||||
|
let spkiChildren = TestDER.children(bytes, infoChildren[2])
|
||||||
|
guard spkiChildren.count == 2 else { return nil }
|
||||||
|
let bitString = spkiChildren[1]
|
||||||
|
guard bitString.valueEnd > bitString.valueStart + 1 else { return nil }
|
||||||
|
let point = Data(bytes[(bitString.valueStart + 1)..<bitString.valueEnd])
|
||||||
|
|
||||||
|
return ParsedCSR(
|
||||||
|
subjectCommonName: commonName,
|
||||||
|
publicPointX963: point,
|
||||||
|
certificationRequestInfoDER: Data(bytes[info.start..<info.end])
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · Pins the hand-written PKCS#10 encoder against an EXTERNAL known-good
|
||||||
|
// vector (OpenSSL) plus the two boundary behaviours the structural tests in
|
||||||
|
// CertificateSigningRequestTests don't reach: a signer that hands back a
|
||||||
|
// non-X9.63 public key, and a subject long enough to need a long-form DER
|
||||||
|
// length. `verifyCsrPoPEc` re-serializes `CertificationRequestInfo` server-side
|
||||||
|
// before checking the PoP, so a single byte of drift breaks enrollment.
|
||||||
|
|
||||||
|
@Test("CertificationRequestInfo is byte-identical to OpenSSL's for the same key + subject")
|
||||||
|
func csrRequestInfoMatchesOpenSSLVector() throws {
|
||||||
|
// Arrange — the exact key OpenSSL used for the embedded vector.
|
||||||
|
let signer = try KnownGoodCSR.fixedKey()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let der = try CertificateSigningRequest.der(
|
||||||
|
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — the signed half must match OpenSSL byte for byte.
|
||||||
|
let parsed = try #require(parseCSR(der))
|
||||||
|
#expect(parsed.certificationRequestInfoDER == KnownGoodCSR.certificationRequestInfoDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("the vector's self-signature verifies over the exact CertificationRequestInfo bytes")
|
||||||
|
func csrVectorSignatureVerifies() throws {
|
||||||
|
// Arrange
|
||||||
|
let signer = try KnownGoodCSR.fixedKey()
|
||||||
|
let der = try CertificateSigningRequest.der(
|
||||||
|
subjectCommonName: KnownGoodCSR.subjectCommonName, signer: signer
|
||||||
|
)
|
||||||
|
let bytes = [UInt8](der)
|
||||||
|
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||||
|
let parts = TestDER.children(bytes, outer)
|
||||||
|
let signature = Data(bytes[(parts[2].valueStart + 1)..<parts[2].valueEnd])
|
||||||
|
|
||||||
|
// Act — verify against the public point embedded in the CSR itself.
|
||||||
|
let parsed = try #require(parseCSR(der))
|
||||||
|
let publicKey = try #require(
|
||||||
|
SecKeyCreateWithData(
|
||||||
|
parsed.publicPointX963 as CFData,
|
||||||
|
[
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
] as CFDictionary,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
let verified = SecKeyVerifySignature(
|
||||||
|
publicKey,
|
||||||
|
.ecdsaSignatureMessageX962SHA256,
|
||||||
|
KnownGoodCSR.certificationRequestInfoDER as CFData,
|
||||||
|
signature as CFData,
|
||||||
|
&error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(verified, "\(String(describing: error?.takeRetainedValue()))")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a signer whose public key is not a 65-byte uncompressed point is rejected")
|
||||||
|
func csrRejectsNonX963PublicKey() {
|
||||||
|
// Arrange — a compressed (33-byte) point: valid EC, wrong encoding for SPKI.
|
||||||
|
let compressed = Data([0x02] + [UInt8](repeating: 0x11, count: 32))
|
||||||
|
let signer = StubSigner(publicKey: compressed)
|
||||||
|
|
||||||
|
// Act / Assert — must fail BEFORE signing (no PoP over a bogus SPKI).
|
||||||
|
#expect(throws: CertificateSigningRequest.CSRError.invalidPublicKey) {
|
||||||
|
_ = try CertificateSigningRequest.der(subjectCommonName: "device", signer: signer)
|
||||||
|
}
|
||||||
|
#expect(signer.signCallCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a subject long enough to need a 2-byte DER length still round-trips")
|
||||||
|
func csrLongSubjectUsesLongFormLength() throws {
|
||||||
|
// Arrange — 300 chars pushes CertificationRequestInfo past 255 bytes, so its
|
||||||
|
// length must be encoded long-form as 0x82 <hi> <lo>.
|
||||||
|
let longCommonName = String(repeating: "d", count: 300)
|
||||||
|
let signer = try KnownGoodCSR.fixedKey()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let der = try CertificateSigningRequest.der(
|
||||||
|
subjectCommonName: longCommonName, signer: signer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
let parsed = try #require(parseCSR(der))
|
||||||
|
#expect(parsed.subjectCommonName == longCommonName)
|
||||||
|
let infoBytes = [UInt8](parsed.certificationRequestInfoDER)
|
||||||
|
#expect(infoBytes[1] == 0x82) // long form, two length bytes
|
||||||
|
let declaredLength = Int(infoBytes[2]) << 8 | Int(infoBytes[3])
|
||||||
|
#expect(declaredLength == infoBytes.count - 4) // minimal + accurate
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
/// A `P256HardwareKey` that returns a caller-chosen public key and records
|
||||||
|
/// whether it was ever asked to sign.
|
||||||
|
private final class StubSigner: P256HardwareKey, @unchecked Sendable {
|
||||||
|
private let publicKey: Data
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var signCalls = 0
|
||||||
|
|
||||||
|
init(publicKey: Data) {
|
||||||
|
self.publicKey = publicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
var signCallCount: Int { lock.withLock { signCalls } }
|
||||||
|
|
||||||
|
func publicKeyX963() throws -> Data { publicKey }
|
||||||
|
|
||||||
|
func sign(_ message: Data) throws -> Data {
|
||||||
|
lock.withLock { signCalls += 1 }
|
||||||
|
return Data([0x30, 0x00])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · The URLSession seam. `ClientTLSSessionDelegate` is the object every HTTP
|
||||||
|
// call to an mTLS host hangs off, so the one behaviour that matters is that the
|
||||||
|
// session-level callback ALWAYS invokes the completion handler exactly once with
|
||||||
|
// the responder's decision — a delegate that forgets to call back hangs the
|
||||||
|
// request forever (no timeout maps to a user-visible error).
|
||||||
|
|
||||||
|
private func makeChallenge(method: String) -> URLAuthenticationChallenge {
|
||||||
|
let space = URLProtectionSpace(
|
||||||
|
host: "t1.terminal.yaojia.wang", port: 443, protocol: "https",
|
||||||
|
realm: nil, authenticationMethod: method
|
||||||
|
)
|
||||||
|
return URLAuthenticationChallenge(
|
||||||
|
protectionSpace: space, proposedCredential: nil, previousFailureCount: 0,
|
||||||
|
failureResponse: nil, error: nil, sender: DelegateTestSender()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The responder never calls back into the sender; it only reads the protection
|
||||||
|
/// space. Present because the designated initializer demands a non-optional one.
|
||||||
|
private final class DelegateTestSender: NSObject, URLAuthenticationChallengeSender {
|
||||||
|
func use(_ credential: URLCredential, for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func continueWithoutCredential(for challenge: URLAuthenticationChallenge) {}
|
||||||
|
func cancel(_ challenge: URLAuthenticationChallenge) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive the delegate and capture what it handed back.
|
||||||
|
private func resolve(
|
||||||
|
identity: ClientIdentity?, method: String
|
||||||
|
) -> (calls: Int, disposition: URLSession.AuthChallengeDisposition?, credential: URLCredential?) {
|
||||||
|
let delegate = ClientTLSSessionDelegate(identity: identity)
|
||||||
|
var calls = 0
|
||||||
|
var disposition: URLSession.AuthChallengeDisposition?
|
||||||
|
var credential: URLCredential?
|
||||||
|
delegate.urlSession(
|
||||||
|
URLSession.shared, didReceive: makeChallenge(method: method)
|
||||||
|
) { receivedDisposition, receivedCredential in
|
||||||
|
calls += 1
|
||||||
|
disposition = receivedDisposition
|
||||||
|
credential = receivedCredential
|
||||||
|
}
|
||||||
|
return (calls, disposition, credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a ClientCertificate challenge answers once with the installed identity")
|
||||||
|
func delegateAnswersClientCertificateWithIdentity() throws {
|
||||||
|
// Arrange
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let result = resolve(
|
||||||
|
identity: identity, method: NSURLAuthenticationMethodClientCertificate
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(result.calls == 1) // exactly once — never zero (hang) or twice (crash)
|
||||||
|
#expect(result.disposition == .useCredential)
|
||||||
|
#expect(result.credential?.identity != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a ClientCertificate challenge with no identity cancels instead of hanging")
|
||||||
|
func delegateCancelsWithoutIdentity() {
|
||||||
|
// Act
|
||||||
|
let result = resolve(identity: nil, method: NSURLAuthenticationMethodClientCertificate)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(result.calls == 1)
|
||||||
|
#expect(result.disposition == .cancelAuthenticationChallenge)
|
||||||
|
#expect(result.credential == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("server-trust challenges fall through to the system evaluation")
|
||||||
|
func delegateDefersServerTrust() throws {
|
||||||
|
// Arrange
|
||||||
|
let identity = try PKCS12Importer.importIdentity(
|
||||||
|
data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act / Assert — the client cert must never be offered as a server-trust
|
||||||
|
// answer, and pinning is NOT this package's job (plan §5: default handling).
|
||||||
|
for candidate in [identity, nil] as [ClientIdentity?] {
|
||||||
|
let result = resolve(identity: candidate, method: NSURLAuthenticationMethodServerTrust)
|
||||||
|
#expect(result.calls == 1)
|
||||||
|
#expect(result.disposition == .performDefaultHandling)
|
||||||
|
#expect(result.credential == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
|
||||||
|
// B4 · Two real, DER-decodable leaf certificates for the enrolled-identity
|
||||||
|
// persistence tests, plus the cleanup they need.
|
||||||
|
//
|
||||||
|
// Both carry the SAME public key (the `KnownGoodCSR` fixed key) and the SAME
|
||||||
|
// subject, and differ only in serial + validity — exactly the shape of a
|
||||||
|
// rotation: `SecItemAdd` treats them as two distinct items, while re-adding one
|
||||||
|
// of them byte-for-byte is `errSecDuplicateItem`.
|
||||||
|
//
|
||||||
|
// CLEANUP, and why it needs its own path: on macOS the file-based keychain
|
||||||
|
// OVERRIDES the `kSecAttrLabel` supplied at add time with the certificate's own
|
||||||
|
// subject summary (verified 2026-07-30: added under
|
||||||
|
// `…test-XYZ.device-leaf`, found only under `enrolled-device-fixture`). So the
|
||||||
|
// production `KeychainClientIdentityStore` label-keyed delete cannot remove them
|
||||||
|
// on macOS, and the tests must sweep by subject instead — `purgeFixtureLeaves()`
|
||||||
|
// runs both BEFORE (in case a previous run was killed) and AFTER every test that
|
||||||
|
// installs one. `SecItemDelete` removes ONE match per call here, hence the loop.
|
||||||
|
//
|
||||||
|
// Regenerated with (OpenSSL 3.0.18, key = KnownGoodCSR.privateKeyX963Base64):
|
||||||
|
// openssl req -x509 -new -key fixed.key.pem -subj "/CN=enrolled-device-fixture" \
|
||||||
|
// -days 3650 -sha256 -outform DER -out leaf1.der # then -days 3651 → leaf2
|
||||||
|
// openssl req -x509 -new -key chain.key.pem \
|
||||||
|
// -subj "/CN=webterm-enrollment-ca-fixture" -days 3650 -sha256 -outform DER
|
||||||
|
enum EnrolledLeafFixtures {
|
||||||
|
/// The subject summary macOS files these certificates under.
|
||||||
|
static let subjectSummary = "enrolled-device-fixture"
|
||||||
|
|
||||||
|
static var leaf: Data { der(leafBase64) }
|
||||||
|
/// A second, DISTINCT leaf (different serial) for the rotation case.
|
||||||
|
static var rotatedLeaf: Data { der(rotatedLeafBase64) }
|
||||||
|
/// An issuer certificate for the stored `caChain`.
|
||||||
|
static var issuer: Data { der(issuerBase64) }
|
||||||
|
|
||||||
|
private static func der(_ base64: String) -> Data {
|
||||||
|
Data(base64Encoded: base64, options: .ignoreUnknownCharacters)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let leafBase64 = """
|
||||||
|
MIIBmTCCAT+gAwIBAgIUcHFyXdi5QFelGKhdzcsbFlgnYzUwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||||
|
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAxWhcNMzYwNzI3MDc1NTAx\
|
||||||
|
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||||
|
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||||
|
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||||
|
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||||
|
MEUCIQCoLSypPlXvtMsQRMpztt/RpbYKc6iTmBLypqOZc0MxTAIgF/MVWpaLLYVCmcKBPxf7y9yQ\
|
||||||
|
NX3c6BrYB/fi/WzJsmA=
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let rotatedLeafBase64 = """
|
||||||
|
MIIBmTCCAT+gAwIBAgIUarD2zMEMcxZJNQevMMJenCAlLpkwCgYIKoZIzj0EAwIwIjEgMB4GA1UE\
|
||||||
|
AwwXZW5yb2xsZWQtZGV2aWNlLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI4MDc1NTAy\
|
||||||
|
WjAiMSAwHgYDVQQDDBdlbnJvbGxlZC1kZXZpY2UtZml4dHVyZTBZMBMGByqGSM49AgEGCCqGSM49\
|
||||||
|
AwEHA0IABGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs5\
|
||||||
|
6X1TF7ZCweW4mcaiJ9KjUzBRMB0GA1UdDgQWBBRAua2aufygDDq2CJTBF31OG7cNSDAfBgNVHSME\
|
||||||
|
GDAWgBRAua2aufygDDq2CJTBF31OG7cNSDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gA\
|
||||||
|
MEUCID2FFmKH2+qHB8sICyDuxQPtyj/wOLE24t4ghBEitLRvAiEAnTQQ2FP8Wei+8ITL/K57UP2Z\
|
||||||
|
YtYbEokKkBCj2bMr3vk=
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static let issuerBase64 = """
|
||||||
|
MIIBpTCCAUugAwIBAgIUIkl7VDf6o9lBxtWxh7zFxPYTpo8wCgYIKoZIzj0EAwIwKDEmMCQGA1UE\
|
||||||
|
Awwdd2VidGVybS1lbnJvbGxtZW50LWNhLWZpeHR1cmUwHhcNMjYwNzMwMDc1NTAyWhcNMzYwNzI3\
|
||||||
|
MDc1NTAyWjAoMSYwJAYDVQQDDB13ZWJ0ZXJtLWVucm9sbG1lbnQtY2EtZml4dHVyZTBZMBMGByqG\
|
||||||
|
SM49AgEGCCqGSM49AwEHA0IABM4iWgauVe86+SKZg+jlHkh8VbVf70pgMdGOcpJW+xWb5oqfzxY3\
|
||||||
|
fW6pIdn3SCo9PG7X22qGtq/PLgpv97wtJvajUzBRMB0GA1UdDgQWBBTT9mdiVZTWcatuYz3NkxCb\
|
||||||
|
DxDSAjAfBgNVHSMEGDAWgBTT9mdiVZTWcatuYz3NkxCbDxDSAjAPBgNVHRMBAf8EBTADAQH/MAoG\
|
||||||
|
CCqGSM49BAMCA0gAMEUCIQCxfF8zyRHMW7nSmieDoBxIsOXkMFVPko86THW3TbwrUwIgMZlBP5rz\
|
||||||
|
FT4Y/G2oA+87xceEDjCrA7FnRZrCrZ4AADk=
|
||||||
|
"""
|
||||||
|
|
||||||
|
private static func labelQuery() -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassCertificate,
|
||||||
|
kSecAttrLabel as String: subjectSummary,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many fixture leaves are currently installed.
|
||||||
|
static func installedCount() -> Int {
|
||||||
|
var query = labelQuery()
|
||||||
|
query[kSecReturnRef as String] = true
|
||||||
|
query[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if let array = result as? [Any] { return array.count }
|
||||||
|
return result == nil ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every installed fixture leaf. Idempotent; safe to call when none
|
||||||
|
/// are installed.
|
||||||
|
static func purgeFixtureLeaves() {
|
||||||
|
var guardCounter = 0
|
||||||
|
let maxSweeps = 16 // a leaf per enroll in a test; never unbounded
|
||||||
|
while SecItemDelete(labelQuery() as CFDictionary) == errSecSuccess,
|
||||||
|
guardCounter < maxSweeps {
|
||||||
|
guardCounter += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import Foundation
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · Shared enrollment-transport double for the store / flow tests.
|
||||||
|
//
|
||||||
|
// Unlike the per-file stubs in DeviceEnrollmentClientTests (which only need the
|
||||||
|
// LAST request), the store tests must re-parse the CSR that was actually sent —
|
||||||
|
// that is how "renew re-signs with the SAME device key" is pinned — so this one
|
||||||
|
// records every request body and replays a scripted reply per call.
|
||||||
|
|
||||||
|
/// A recording, scriptable `EnrollmentTransport`.
|
||||||
|
///
|
||||||
|
/// `@unchecked Sendable`: all mutable state is behind `lock`.
|
||||||
|
final class RecordingEnrollmentTransport: EnrollmentTransport, @unchecked Sendable {
|
||||||
|
struct Reply {
|
||||||
|
let status: Int
|
||||||
|
let body: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var pendingReplies: [Reply]
|
||||||
|
private var recorded: [URLRequest] = []
|
||||||
|
|
||||||
|
init(replies: [Reply]) {
|
||||||
|
pendingReplies = replies
|
||||||
|
}
|
||||||
|
|
||||||
|
convenience init(status: Int, body: Data) {
|
||||||
|
self.init(replies: [Reply(status: status, body: body)])
|
||||||
|
}
|
||||||
|
|
||||||
|
var requests: [URLRequest] { lock.withLock { recorded } }
|
||||||
|
var callCount: Int { lock.withLock { recorded.count } }
|
||||||
|
|
||||||
|
/// The JSON body of call `index`, decoded as a `[String: Any]` object.
|
||||||
|
func jsonBody(at index: Int) -> [String: Any]? {
|
||||||
|
guard let data = requests.indices.contains(index)
|
||||||
|
? requests[index].httpBody : nil
|
||||||
|
else { return nil }
|
||||||
|
return try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `csr` field of call `index`, base64-decoded back to DER.
|
||||||
|
func csrDER(at index: Int) -> Data? {
|
||||||
|
guard let base64 = jsonBody(at: index)?["csr"] as? String else { return nil }
|
||||||
|
return Data(base64Encoded: base64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||||
|
let reply: Reply = lock.withLock {
|
||||||
|
recorded.append(request)
|
||||||
|
guard !pendingReplies.isEmpty else { return Reply(status: 500, body: Data()) }
|
||||||
|
return pendingReplies.removeFirst()
|
||||||
|
}
|
||||||
|
let response = HTTPURLResponse(
|
||||||
|
url: request.url!, statusCode: reply.status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||||
|
)!
|
||||||
|
return (reply.body, response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DER bytes that `SecCertificateCreateWithData` rejects (a truncated SEQUENCE).
|
||||||
|
///
|
||||||
|
/// Every store test that drives `enroll` / `renew` to the persistence step uses
|
||||||
|
/// this as the server-returned leaf ON PURPOSE: it makes `storeEnrolledLeaf`
|
||||||
|
/// fail at the very first line — BEFORE any `SecItemAdd(kSecClassCertificate)`.
|
||||||
|
/// That matters on macOS, where `swift test` talks to the file-based login
|
||||||
|
/// keychain: it OVERRIDES the `kSecAttrLabel` of an added certificate with the
|
||||||
|
/// cert's own subject summary, so `KeychainClientIdentityStore`'s label-keyed
|
||||||
|
/// delete can never remove it again (verified 2026-07-30: adding the leaf, then
|
||||||
|
/// `SecItemDelete(kSecClass: kSecClassCertificate, kSecAttrLabel: …)` →
|
||||||
|
/// `errSecItemNotFound (-25300)`, and the cert had to be swept manually with
|
||||||
|
/// `security delete-certificate -Z <sha1>`). A test that installed a leaf would
|
||||||
|
/// therefore permanently pollute the developer's login keychain.
|
||||||
|
let undecodableCertificateDER = Data([0x30, 0x01, 0x02])
|
||||||
|
|
||||||
|
/// A `POST /device/enroll` / `…/renew` 201 body in the A4 wire shape.
|
||||||
|
func enrollmentResponseJSON(
|
||||||
|
deviceId: String = "dev-b4",
|
||||||
|
certDER: Data = undecodableCertificateDER,
|
||||||
|
caChain: [Data] = [],
|
||||||
|
notBefore: String? = "2026-07-30T00:00:00.000Z",
|
||||||
|
notAfter: String? = "2026-10-28T00:00:00.000Z",
|
||||||
|
renewAfter: String? = "2026-09-27T00:00:00.000Z"
|
||||||
|
) -> Data {
|
||||||
|
var json: [String: Any] = [
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"cert": certDER.base64EncodedString(),
|
||||||
|
"caChain": caChain.map { $0.base64EncodedString() },
|
||||||
|
]
|
||||||
|
if let notBefore { json["notBefore"] = notBefore }
|
||||||
|
if let notAfter { json["notAfter"] = notAfter }
|
||||||
|
if let renewAfter { json["renewAfter"] = renewAfter }
|
||||||
|
return try! JSONSerialization.data(withJSONObject: json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · The Secure-Enclave enrollment half of `KeychainClientIdentityStore`:
|
||||||
|
// `enroll`, `renew`, `renewalState`. What is pinned here is the ORCHESTRATION —
|
||||||
|
// which key signs the CSR, which endpoint it goes to, what the request body is
|
||||||
|
// allowed to contain, and what happens to local state when the server's answer
|
||||||
|
// is unusable. The signing key is injected (`keyProvider`) or pre-installed
|
||||||
|
// under the store's derived tag, so no Secure Enclave is needed.
|
||||||
|
//
|
||||||
|
// Every test stops at the leaf-installation step by having the server return an
|
||||||
|
// undecodable certificate (`undecodableCertificateDER`) — see the note there for
|
||||||
|
// why installing a real leaf is not possible in a macOS `swift test` run. The
|
||||||
|
// happy-path leaf install + `SecItemCopyMatching(kSecClassIdentity)` assembly
|
||||||
|
// stays a device/simulator concern.
|
||||||
|
|
||||||
|
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||||
|
|
||||||
|
@Test("renewalState is nil before any enrollment", .keychainSerialized)
|
||||||
|
func renewalStateNilWhenNotEnrolled() async throws {
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
#expect(try store.renewalState() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("renewalState reads back the persisted enrollment record verbatim", .keychainSerialized)
|
||||||
|
func renewalStateReadsPersistedRecord() async throws {
|
||||||
|
// Arrange — the record layout is a migration contract, so it is seeded
|
||||||
|
// directly and read back through the public API.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let notAfter = Date(timeIntervalSinceReferenceDate: 800_000_000)
|
||||||
|
let renewAfter = Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount,
|
||||||
|
data: storedEnrollmentJSON(
|
||||||
|
deviceId: "dev-42", deviceName: "Yaojia iPhone",
|
||||||
|
caChain: [Data([0x30, 0xAA])], notAfter: notAfter, renewAfter: renewAfter
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let state = try #require(try store.renewalState())
|
||||||
|
|
||||||
|
// Assert — deviceId drives POST /device/:id/renew; the dates drive the
|
||||||
|
// scheduler's decision.
|
||||||
|
#expect(state.deviceId == "dev-42")
|
||||||
|
#expect(state.notAfter == notAfter)
|
||||||
|
#expect(state.renewAfter == renewAfter)
|
||||||
|
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(1)))
|
||||||
|
#expect(state.isRenewalDue(asOf: renewAfter.addingTimeInterval(-1)) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a record with no renewAfter never reports renewal due (fail-safe)", .keychainSerialized)
|
||||||
|
func renewalStateWithoutRenewAfterNeverDue() async throws {
|
||||||
|
// Arrange — an older server that omitted the advisory field.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount,
|
||||||
|
data: storedEnrollmentJSON(deviceId: "dev-legacy", deviceName: "iPad")
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let state = try #require(try store.renewalState())
|
||||||
|
|
||||||
|
// Assert — the TLS stack stays the real gate; the scheduler must not guess.
|
||||||
|
#expect(state.notAfter == nil)
|
||||||
|
#expect(state.renewAfter == nil)
|
||||||
|
#expect(state.isRenewalDue(asOf: Date.distantFuture) == false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an undecodable enrollment record surfaces .corruptStoredBlob", .keychainSerialized)
|
||||||
|
func renewalStateCorruptRecord() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount,
|
||||||
|
data: Data("{\"deviceId\":".utf8) // truncated JSON
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act / Assert — never a silent "not enrolled" (that would make the
|
||||||
|
// scheduler skip rotation forever on a device that IS enrolled).
|
||||||
|
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try store.renewalState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("enroll signs the CSR with the injected key and posts it to /device/enroll", .keychainSerialized)
|
||||||
|
func enrollPostsCSRSignedByTheDeviceKey() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let deviceKey = try SecureEnclaveKeyFactory.generateSoftware()
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||||
|
let client = DeviceEnrollmentClient(
|
||||||
|
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act — the response's leaf is undecodable, so persistence fails; the CSR has
|
||||||
|
// already been built and sent, which is what this test is about.
|
||||||
|
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: client, subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||||
|
keyProvider: { deviceKey }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — one request, to the enroll endpoint, carrying the A4 body.
|
||||||
|
#expect(transport.callCount == 1)
|
||||||
|
let request = try #require(transport.requests.first)
|
||||||
|
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
|
||||||
|
let body = try #require(transport.jsonBody(at: 0))
|
||||||
|
#expect(body["subdomain"] as? String == "yaojia")
|
||||||
|
#expect(body["deviceName"] as? String == "Yaojia iPhone")
|
||||||
|
#expect(body["keyAlg"] as? String == "ec-p256")
|
||||||
|
|
||||||
|
// …and the CSR is bound to the injected key, with the device name as CN.
|
||||||
|
let csrDER = try #require(transport.csrDER(at: 0))
|
||||||
|
let csr = try #require(parseCSR(csrDER))
|
||||||
|
#expect(csr.subjectCommonName == "Yaojia iPhone")
|
||||||
|
#expect(csr.publicPointX963 == (try deviceKey.publicKeyX963()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("an undecodable leaf leaves NO enrollment record behind", .keychainSerialized)
|
||||||
|
func enrollDoesNotPersistOnUndecodableLeaf() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||||
|
let client = DeviceEnrollmentClient(
|
||||||
|
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: client, subdomain: "yaojia", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — a half-written record would make `renewalState` claim an
|
||||||
|
// enrollment that has no leaf, and the scheduler would renew a phantom.
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||||
|
#expect(try store.renewalState() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a rejected enrollment (403) propagates the server error and stores nothing", .keychainSerialized)
|
||||||
|
func enrollPropagatesServerRejection() async throws {
|
||||||
|
// Arrange — subdomain not owned by the account.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let body = try JSONSerialization.data(withJSONObject: ["error": "subdomain_not_owned"])
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 403, body: body)
|
||||||
|
let client = DeviceEnrollmentClient(
|
||||||
|
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act / Assert
|
||||||
|
await #expect(
|
||||||
|
throws: DeviceEnrollmentError.http(status: 403, code: "subdomain_not_owned")
|
||||||
|
) {
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: client, subdomain: "someone-else", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("renew without an enrollment record fails locally and never calls the server", .keychainSerialized)
|
||||||
|
func renewWithoutRecordDoesNotCallServer() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||||
|
|
||||||
|
// Act / Assert — nothing to renew: a fresh install or a legacy .p12-only
|
||||||
|
// device. The scheduler must not fire a request it cannot authenticate.
|
||||||
|
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try await store.renew(using: client)
|
||||||
|
}
|
||||||
|
#expect(transport.callCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("renew without the device key fails locally and never calls the server", .keychainSerialized)
|
||||||
|
func renewWithoutDeviceKeyDoesNotCallServer() async throws {
|
||||||
|
// Arrange — a record exists but the Secure-Enclave key is gone (restored
|
||||||
|
// backup / manually cleared keychain).
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount,
|
||||||
|
data: storedEnrollmentJSON(deviceId: "dev-99", deviceName: "iPhone")
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||||
|
|
||||||
|
// Act / Assert — a renew CSR signed by a NEW key would be rejected by the
|
||||||
|
// server (PoP against the enrolled key), so it must not be attempted.
|
||||||
|
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try await store.renew(using: client)
|
||||||
|
}
|
||||||
|
#expect(transport.callCount == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("renew re-signs with the SAME device key and posts a csr-only body to /device/:id/renew", .keychainSerialized)
|
||||||
|
func renewReusesTheEnrolledDeviceKey() async throws {
|
||||||
|
// Arrange — an enrolled device: record + the permanent key under the store's
|
||||||
|
// derived tag (the shape `enroll` leaves behind).
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let enrolledKey = try SecureEnclaveKeyFactory.generateSoftware(
|
||||||
|
tag: keys.deviceKeyTag, permanent: true
|
||||||
|
)
|
||||||
|
let originalRecord = storedEnrollmentJSON(
|
||||||
|
deviceId: "dev-77", deviceName: "Yaojia iPad",
|
||||||
|
notAfter: Date(timeIntervalSinceReferenceDate: 800_000_000),
|
||||||
|
renewAfter: Date(timeIntervalSinceReferenceDate: 790_000_000)
|
||||||
|
)
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount, data: originalRecord
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let transport = RecordingEnrollmentTransport(status: 201, body: enrollmentResponseJSON())
|
||||||
|
// Nil bearer: the renew endpoint authenticates by the CURRENT client cert.
|
||||||
|
let client = DeviceEnrollmentClient(baseURL: controlPlaneURL, transport: transport)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await #expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try await store.renew(using: client)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — the endpoint carries the stored deviceId…
|
||||||
|
#expect(transport.callCount == 1)
|
||||||
|
let request = try #require(transport.requests.first)
|
||||||
|
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/dev-77/renew")
|
||||||
|
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||||
|
|
||||||
|
// …the body is `{ csr }` ONLY (a stray field 400s every silent renewal)…
|
||||||
|
let body = try #require(transport.jsonBody(at: 0))
|
||||||
|
#expect(Set(body.keys) == ["csr"])
|
||||||
|
|
||||||
|
// …and the CSR re-uses the ENROLLED key (a new key would fail the server's
|
||||||
|
// PoP-against-the-enrolled-key check) with the stored device name as CN.
|
||||||
|
let csrDER = try #require(transport.csrDER(at: 0))
|
||||||
|
let csr = try #require(parseCSR(csrDER))
|
||||||
|
#expect(csr.publicPointX963 == (try enrolledKey.publicKeyX963()))
|
||||||
|
#expect(csr.subjectCommonName == "Yaojia iPad")
|
||||||
|
|
||||||
|
// …and a failed rotation leaves the EXISTING enrollment intact.
|
||||||
|
let state = try #require(try store.renewalState())
|
||||||
|
#expect(state.deviceId == "dev-77")
|
||||||
|
#expect(state.renewAfter == Date(timeIntervalSinceReferenceDate: 790_000_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("the flow's production install step is wired to the keychain store's SE enroll", .keychainSerialized)
|
||||||
|
func enrollmentFlowWiresTheKeychainStore() async throws {
|
||||||
|
// Arrange — the `store:` convenience initializer is the composition the app
|
||||||
|
// uses: login → bearer → enroll → install. Its install step deliberately
|
||||||
|
// takes NO keyProvider, i.e. it always asks for a real Secure-Enclave key.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
let loginBody = try JSONSerialization.data(withJSONObject: [
|
||||||
|
"enrollToken": "enroll-bearer", "accountId": "acct-1", "expiresIn": 300,
|
||||||
|
])
|
||||||
|
let transport = RecordingEnrollmentTransport(replies: [
|
||||||
|
.init(status: 201, body: loginBody),
|
||||||
|
.init(status: 201, body: enrollmentResponseJSON()),
|
||||||
|
])
|
||||||
|
let flow = DeviceEnrollmentFlow(
|
||||||
|
baseURL: controlPlaneURL, transport: transport, store: store
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var thrown: Error?
|
||||||
|
do {
|
||||||
|
_ = try await flow.run(
|
||||||
|
password: "account-password", subdomain: "yaojia", deviceName: "iPhone"
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
thrown = error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — login happened first, with the password and nothing else…
|
||||||
|
let loginRequest = try #require(transport.requests.first)
|
||||||
|
#expect(loginRequest.url?.path == "/auth/login")
|
||||||
|
#expect(Set(try #require(transport.jsonBody(at: 0)).keys) == ["password"])
|
||||||
|
|
||||||
|
// …and the failure came from the INSTALL step, not the login step: the
|
||||||
|
// convenience initializer really does route into the keychain store. Off
|
||||||
|
// device (macOS `swift test`, Simulator) `generateSecureEnclave` cannot mint
|
||||||
|
// a key, so `.secureEnclaveUnavailable` is the expected stop; an entitled
|
||||||
|
// host gets as far as the undecodable leaf (`.corruptStoredBlob`).
|
||||||
|
let error = try #require(thrown)
|
||||||
|
#expect(error is SecureEnclaveKeyError || error is ClientIdentityStoreError)
|
||||||
|
#expect(error is ControlPlaneLoginError == false)
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · The persistence half of enrollment: what `storeEnrolledLeaf` actually
|
||||||
|
// leaves in the keychain. Three cases, all reachable off-device because the
|
||||||
|
// server's answer is a REAL certificate here (`EnrolledLeafFixtures`):
|
||||||
|
//
|
||||||
|
// 1. first enroll → leaf installed + enrollment record ADDED
|
||||||
|
// 2. rotation (new leaf) → distinct leaf added, record UPDATED in place
|
||||||
|
// 3. retry (same leaf) → errSecDuplicateItem tolerated, record still correct
|
||||||
|
//
|
||||||
|
// Case 2/3 matter because the record write and the leaf write are separate
|
||||||
|
// keychain operations: a device whose record says "dev-B" while the installed
|
||||||
|
// leaf is "dev-A" cannot renew (the server checks the PoP against the enrolled
|
||||||
|
// key for THAT deviceId).
|
||||||
|
//
|
||||||
|
// These tests install certificates into the real keychain, so each one sweeps the
|
||||||
|
// fixture leaves before AND after itself — see `EnrolledLeafFixtures`.
|
||||||
|
|
||||||
|
private let controlPlaneURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||||
|
|
||||||
|
/// Run `body` with the fixture leaves swept on both sides. Actor-isolated like
|
||||||
|
/// its callers so nothing crosses a concurrency boundary.
|
||||||
|
private func withCleanLeafKeychain(
|
||||||
|
_ body: (StoreKeychainKeys, KeychainClientIdentityStore) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||||
|
defer {
|
||||||
|
EnrolledLeafFixtures.purgeFixtureLeaves()
|
||||||
|
KeychainProbe.purge(keys)
|
||||||
|
}
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
try await body(keys, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enrollmentClient(
|
||||||
|
_ transport: RecordingEnrollmentTransport
|
||||||
|
) -> DeviceEnrollmentClient {
|
||||||
|
DeviceEnrollmentClient(
|
||||||
|
baseURL: controlPlaneURL, bearerToken: "enroll-bearer", transport: transport
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a first enroll installs the leaf and records the server's rotation timing", .keychainSerialized)
|
||||||
|
func enrollInstallsLeafAndRecord() async throws {
|
||||||
|
try await withCleanLeafKeychain { keys, store in
|
||||||
|
// Arrange
|
||||||
|
#expect(try store.renewalState() == nil)
|
||||||
|
let transport = RecordingEnrollmentTransport(
|
||||||
|
status: 201,
|
||||||
|
body: enrollmentResponseJSON(
|
||||||
|
deviceId: "dev-first",
|
||||||
|
certDER: EnrolledLeafFixtures.leaf,
|
||||||
|
caChain: [EnrolledLeafFixtures.issuer],
|
||||||
|
notAfter: "2026-10-28T00:00:00.000Z",
|
||||||
|
renewAfter: "2026-09-27T00:00:00.000Z"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act — the returned summary is NOT asserted: reading it goes through
|
||||||
|
// `SecItemCopyMatching(kSecClassIdentity)`, which on macOS ignores the
|
||||||
|
// key tag and can hand back an unrelated login-keychain identity (see
|
||||||
|
// `defaultKeychainYieldsForeignIdentity`). What is persisted IS asserted.
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: enrollmentClient(transport), subdomain: "yaojia", deviceName: "Yaojia iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — exactly one leaf installed…
|
||||||
|
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||||
|
// …and the record carries what the server said, so a later renew can
|
||||||
|
// address the right device.
|
||||||
|
let state = try #require(try store.renewalState())
|
||||||
|
#expect(state.deviceId == "dev-first")
|
||||||
|
#expect(state.notAfter != nil)
|
||||||
|
#expect(state.renewAfter != nil)
|
||||||
|
#expect(state.isRenewalDue(asOf: try #require(state.renewAfter)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rotation installs the new leaf and updates the record in place", .keychainSerialized)
|
||||||
|
func rotationUpdatesRecordInPlace() async throws {
|
||||||
|
try await withCleanLeafKeychain { keys, store in
|
||||||
|
// Arrange — already enrolled.
|
||||||
|
let first = RecordingEnrollmentTransport(
|
||||||
|
status: 201,
|
||||||
|
body: enrollmentResponseJSON(
|
||||||
|
deviceId: "dev-old", certDER: EnrolledLeafFixtures.leaf
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
#expect(try store.renewalState()?.deviceId == "dev-old")
|
||||||
|
|
||||||
|
// Act — a rotation returns a DISTINCT leaf.
|
||||||
|
let second = RecordingEnrollmentTransport(
|
||||||
|
status: 201,
|
||||||
|
body: enrollmentResponseJSON(
|
||||||
|
deviceId: "dev-new", certDER: EnrolledLeafFixtures.rotatedLeaf
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: enrollmentClient(second), subdomain: "yaojia", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — the record is UPDATED (one item, new value), never duplicated:
|
||||||
|
// two records at the same (service, account) would make renew pick one at
|
||||||
|
// random.
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 1)
|
||||||
|
#expect(try store.renewalState()?.deviceId == "dev-new")
|
||||||
|
// …and a leaf is installed at every step — the anti-lockout invariant
|
||||||
|
// (ordering itself is pinned by KeychainItemReplaceTests).
|
||||||
|
#expect(EnrolledLeafFixtures.installedCount() >= 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("re-storing a byte-identical leaf is tolerated and keeps the record correct", .keychainSerialized)
|
||||||
|
func reEnrollingSameLeafIsIdempotent() async throws {
|
||||||
|
try await withCleanLeafKeychain { keys, store in
|
||||||
|
// Arrange — an interrupted enroll that is retried: the server re-issues
|
||||||
|
// the SAME certificate.
|
||||||
|
let body = enrollmentResponseJSON(
|
||||||
|
deviceId: "dev-retry", certDER: EnrolledLeafFixtures.leaf
|
||||||
|
)
|
||||||
|
let first = RecordingEnrollmentTransport(status: 201, body: body)
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: enrollmentClient(first), subdomain: "yaojia", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Act — `SecItemAdd` now answers errSecDuplicateItem, which must NOT be
|
||||||
|
// treated as a failure (the leaf we wanted installed IS installed).
|
||||||
|
let retry = RecordingEnrollmentTransport(status: 201, body: body)
|
||||||
|
await #expect(throws: Never.self) {
|
||||||
|
_ = try await store.enroll(
|
||||||
|
using: enrollmentClient(retry), subdomain: "yaojia", deviceName: "iPhone",
|
||||||
|
keyProvider: { try SecureEnclaveKeyFactory.generateSoftware() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — still one leaf, and the record is intact.
|
||||||
|
#expect(EnrolledLeafFixtures.installedCount() == 1)
|
||||||
|
#expect(try store.renewalState()?.deviceId == "dev-retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · `KeychainClientIdentityStore` against the REAL keychain — the legacy
|
||||||
|
// `.p12` half (the dual-trust migration window: devices enrolled before the
|
||||||
|
// Secure-Enclave path still carry an imported `.p12`).
|
||||||
|
//
|
||||||
|
// This package has no `SecItemShim` seam (unlike HostRegistry), so these tests
|
||||||
|
// drive the actual `SecItem*` calls. Each test gets a UUID-scoped service and
|
||||||
|
// purges it afterwards, so runs never collide and nothing is left behind.
|
||||||
|
//
|
||||||
|
// WHAT THIS LAYER CANNOT CHECK: the `kSecAttrAccessible` protection class. On
|
||||||
|
// macOS `swift test` reaches the FILE-BASED keychain, which has no
|
||||||
|
// data-protection class at all — a stored item's attributes come back as only
|
||||||
|
// `acct/cdat/class/labl/mdat/svce`, with no `pdmn` (verified 2026-07-30). The
|
||||||
|
// "AfterFirstUnlockThisDeviceOnly, never synchronized" assertion therefore
|
||||||
|
// belongs to a SIGNED simulator host, exactly as plan §9 already records for
|
||||||
|
// `KeychainHostStore`. It is NOT covered by this package's gate.
|
||||||
|
|
||||||
|
@Test("save persists exactly one item holding the .p12 and its passphrase", .keychainSerialized)
|
||||||
|
func keychainStoreSavePersistsSingleItem() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — one item, and the blob carries BOTH halves (the passphrase is
|
||||||
|
// required to re-import at every launch, so storing the .p12 alone would
|
||||||
|
// brick the identity after a relaunch).
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||||
|
let stored = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||||
|
#expect(stored.p12 == ClientTLSFixtures.deviceP12Data.base64EncodedString())
|
||||||
|
#expect(stored.passphrase == ClientTLSFixtures.passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("saving again REPLACES the stored blob instead of duplicating the item", .keychainSerialized)
|
||||||
|
func keychainStoreSaveReplaces() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
let first = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||||
|
|
||||||
|
// Act — same coordinates, re-saved (the rotation / re-install path).
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — still exactly one item (a duplicate would make loads
|
||||||
|
// order-dependent, i.e. an old cert could resurface after rotation) and the
|
||||||
|
// content is intact.
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||||
|
let second = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||||
|
#expect(second == first)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a wrong passphrase throws and writes NOTHING to the keychain", .keychainSerialized)
|
||||||
|
func keychainStoreSaveRejectsWrongPassphrase() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act / Assert — validation happens BEFORE persistence.
|
||||||
|
#expect(throws: PKCS12ImportError.wrongPassphrase) {
|
||||||
|
try store.save(p12Data: ClientTLSFixtures.deviceP12Data, passphrase: "wrong")
|
||||||
|
}
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a corrupt .p12 throws .corruptFile and leaves the PRIOR identity installed", .keychainSerialized)
|
||||||
|
func keychainStoreSaveKeepsPriorIdentityOnCorruptFile() async throws {
|
||||||
|
// Arrange — a good identity is already installed.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
let good = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||||
|
|
||||||
|
// Act — a truncated file arrives (bad download / wrong file picked).
|
||||||
|
#expect(throws: PKCS12ImportError.corruptFile) {
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data.prefix(64),
|
||||||
|
passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert — the working identity is untouched, not wiped by the failed install.
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||||
|
let survivor = try #require(storedP12Fields(service: keys.service, account: keys.account))
|
||||||
|
#expect(survivor == good)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("remove deletes the stored blob and a second remove is a no-op", .keychainSerialized)
|
||||||
|
func keychainStoreRemoveIsIdempotent() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 1)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.remove()
|
||||||
|
|
||||||
|
// Assert — gone, and removing again must NOT throw errSecItemNotFound
|
||||||
|
// (removal is reachable from "delete host" with no identity installed).
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||||
|
#expect(throws: Never.self) { try store.remove() }
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("remove also clears the enrollment record and the device key", .keychainSerialized)
|
||||||
|
func keychainStoreRemoveClearsEnrolledState() async throws {
|
||||||
|
// Arrange — a device that enrolled (record + device key) AND carries a
|
||||||
|
// legacy .p12, i.e. mid-migration.
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.enrollmentAccount,
|
||||||
|
data: storedEnrollmentJSON(deviceId: "dev-remove", deviceName: "iPhone")
|
||||||
|
)
|
||||||
|
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: keys.deviceKeyTag, permanent: true)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.remove()
|
||||||
|
|
||||||
|
// Assert — removal is unconditional across BOTH paths; a leftover device key
|
||||||
|
// would make a later enroll bind a new leaf to a stale key.
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.account) == 0)
|
||||||
|
#expect(KeychainProbe.count(service: keys.service, account: keys.enrollmentAccount) == 0)
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: keys.deviceKeyTag) == nil)
|
||||||
|
#expect(try store.renewalState() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The `.p12` fallback inside `loadIdentity()` is only reachable when the default
|
||||||
|
// keychain holds no identities: on macOS the `kSecClassIdentity` lookup ignores
|
||||||
|
// `kSecAttrApplicationTag` and returns an arbitrary foreign identity, which
|
||||||
|
// short-circuits the fallback (see `defaultKeychainYieldsForeignIdentity`). The
|
||||||
|
// test is gated rather than fudged — it runs on a clean runner and is reported as
|
||||||
|
// skipped on a developer machine with identities in the login keychain.
|
||||||
|
@Test(
|
||||||
|
"save → loadIdentity → loadSummary roundtrips through the real keychain",
|
||||||
|
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
||||||
|
, .keychainSerialized)
|
||||||
|
func keychainStoreLoadsBackTheStoredIdentity() async throws {
|
||||||
|
// Arrange
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(try store.loadIdentity() == nil)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
try store.save(
|
||||||
|
p12Data: ClientTLSFixtures.deviceP12Data, passphrase: ClientTLSFixtures.passphrase
|
||||||
|
)
|
||||||
|
|
||||||
|
// Assert — re-imported from the stored bytes + passphrase.
|
||||||
|
#expect(store.hasInstalledIdentity() == true)
|
||||||
|
let identity = try #require(try store.loadIdentity())
|
||||||
|
#expect(identity.summary()?.subjectCommonName == ClientTLSFixtures.leafCommonName)
|
||||||
|
let summary = try #require(try store.loadSummary())
|
||||||
|
#expect(summary.issuerCommonName == ClientTLSFixtures.issuerCommonName)
|
||||||
|
|
||||||
|
// Act / Assert — and after removal the gate closes again.
|
||||||
|
try store.remove()
|
||||||
|
#expect(store.hasInstalledIdentity() == false)
|
||||||
|
#expect(store.loadedIdentityOrNil() == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(
|
||||||
|
"a corrupt stored blob surfaces .corruptStoredBlob, and loadedIdentityOrNil swallows it",
|
||||||
|
.enabled(if: !defaultKeychainYieldsForeignIdentity())
|
||||||
|
, .keychainSerialized)
|
||||||
|
func keychainStoreCorruptBlobIsTyped() async throws {
|
||||||
|
// Arrange — bytes that are not the stored JSON envelope (a partial write, or
|
||||||
|
// an item written by an older schema).
|
||||||
|
let keys = StoreKeychainKeys.unique()
|
||||||
|
defer { KeychainProbe.purge(keys) }
|
||||||
|
KeychainProbe.write(
|
||||||
|
service: keys.service, account: keys.account, data: Data("not-json".utf8)
|
||||||
|
)
|
||||||
|
let store = KeychainClientIdentityStore(service: keys.service, account: keys.account)
|
||||||
|
|
||||||
|
// Act / Assert — typed error for the install UI…
|
||||||
|
#expect(throws: ClientIdentityStoreError.corruptStoredBlob) {
|
||||||
|
_ = try store.loadIdentity()
|
||||||
|
}
|
||||||
|
// …and launch does not crash: the convenience wrapper logs and degrades.
|
||||||
|
#expect(store.loadedIdentityOrNil() == nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · Direct `SecItem*` access for the store tests. Two jobs:
|
||||||
|
// 1. INSPECT what `KeychainClientIdentityStore` actually wrote — the protection
|
||||||
|
// class and the no-iCloud-sync flag are security requirements (plan §5.3 /
|
||||||
|
// contract §1.1) that only a raw read can confirm.
|
||||||
|
// 2. SEED / CORRUPT the persisted records so the read-side error paths and the
|
||||||
|
// renew path can be driven without first performing a real enrollment
|
||||||
|
// (which would have to install a certificate — see `undecodableCertificateDER`).
|
||||||
|
|
||||||
|
/// The keychain coordinates a `KeychainClientIdentityStore(service:account:)`
|
||||||
|
/// derives internally. Mirrored here ON PURPOSE: the persisted layout is a
|
||||||
|
/// migration contract, so the tests pin it rather than infer it.
|
||||||
|
struct StoreKeychainKeys {
|
||||||
|
let service: String
|
||||||
|
let account: String
|
||||||
|
|
||||||
|
/// `KeychainClientIdentityStore.enrollmentAccount`.
|
||||||
|
var enrollmentAccount: String { "\(account).enrollment" }
|
||||||
|
/// `KeychainClientIdentityStore.deviceKeyTag`.
|
||||||
|
var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
|
||||||
|
/// `KeychainClientIdentityStore.leafLabel`.
|
||||||
|
var leafLabel: String { "\(service).device-leaf" }
|
||||||
|
|
||||||
|
/// A fresh, collision-free coordinate set for one test.
|
||||||
|
static func unique() -> StoreKeychainKeys {
|
||||||
|
StoreKeychainKeys(
|
||||||
|
service: "com.yaojia.webterm.clienttls.test-\(UUID().uuidString)",
|
||||||
|
account: "device-identity"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum KeychainProbe {
|
||||||
|
private static func query(service: String, account: String) -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many generic-password items sit at `(service, account)`. `0` = nothing
|
||||||
|
/// stored; `> 1` = a write duplicated instead of replacing.
|
||||||
|
///
|
||||||
|
/// Attributes-only, `kSecMatchLimitAll`: asking for `kSecReturnData`
|
||||||
|
/// together with `kSecMatchLimitAll` is `errSecParam (-50)` on the macOS
|
||||||
|
/// file-based keychain (verified 2026-07-30), so counting and reading are
|
||||||
|
/// separate calls.
|
||||||
|
static func count(service: String, account: String) -> Int {
|
||||||
|
var request = query(service: service, account: account)
|
||||||
|
request[kSecReturnAttributes as String] = true
|
||||||
|
request[kSecMatchLimit as String] = kSecMatchLimitAll
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if let array = result as? [Any] { return array.count }
|
||||||
|
return result == nil ? 0 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored bytes at `(service, account)`; `nil` when no item exists.
|
||||||
|
static func data(service: String, account: String) -> Data? {
|
||||||
|
var request = query(service: service, account: account)
|
||||||
|
request[kSecReturnData as String] = true
|
||||||
|
request[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(request as CFDictionary, &result) == errSecSuccess else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write raw bytes at `(service, account)` with the store's own protection
|
||||||
|
/// class — used to seed or corrupt a record.
|
||||||
|
@discardableResult
|
||||||
|
static func write(service: String, account: String, data: Data) -> OSStatus {
|
||||||
|
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||||
|
var attributes = query(service: service, account: account)
|
||||||
|
attributes[kSecValueData as String] = data
|
||||||
|
attributes[kSecAttrAccessible as String] =
|
||||||
|
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||||
|
return SecItemAdd(attributes as CFDictionary, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
static func delete(service: String, account: String) -> OSStatus {
|
||||||
|
SecItemDelete(query(service: service, account: account) as CFDictionary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove everything a test could have created under `keys`.
|
||||||
|
static func purge(_ keys: StoreKeychainKeys) {
|
||||||
|
delete(service: keys.service, account: keys.account)
|
||||||
|
delete(service: keys.service, account: keys.enrollmentAccount)
|
||||||
|
try? SecureEnclaveKeyFactory.delete(tag: keys.deviceKeyTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The two fields of the stored `.p12` envelope, decoded from the keychain item.
|
||||||
|
///
|
||||||
|
/// Compared field-by-field rather than byte-by-byte because Foundation's
|
||||||
|
/// `JSONEncoder` serializes through an unordered dictionary on Darwin: two
|
||||||
|
/// encodings of the SAME value differ in key order (verified 2026-07-30 — both
|
||||||
|
/// blobs 2109 bytes, not equal), so byte equality is not a valid invariant.
|
||||||
|
func storedP12Fields(service: String, account: String) -> (p12: String, passphrase: String)? {
|
||||||
|
guard let data = KeychainProbe.data(service: service, account: account),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let p12 = json["p12"] as? String,
|
||||||
|
let passphrase = json["passphrase"] as? String
|
||||||
|
else { return nil }
|
||||||
|
return (p12, passphrase)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The JSON `StoredEnrollment` shape (`JSONEncoder` defaults: `Data` → base64
|
||||||
|
/// string, `Date` → seconds since the reference date). Written directly so the
|
||||||
|
/// read path can be exercised without installing a leaf certificate.
|
||||||
|
func storedEnrollmentJSON(
|
||||||
|
deviceId: String,
|
||||||
|
deviceName: String,
|
||||||
|
caChain: [Data] = [],
|
||||||
|
notAfter: Date? = nil,
|
||||||
|
renewAfter: Date? = nil
|
||||||
|
) -> Data {
|
||||||
|
var json: [String: Any] = [
|
||||||
|
"deviceId": deviceId,
|
||||||
|
"deviceName": deviceName,
|
||||||
|
"caChain": caChain.map { $0.base64EncodedString() },
|
||||||
|
]
|
||||||
|
if let notAfter { json["notAfter"] = notAfter.timeIntervalSinceReferenceDate }
|
||||||
|
if let renewAfter { json["renewAfter"] = renewAfter.timeIntervalSinceReferenceDate }
|
||||||
|
return try! JSONSerialization.data(withJSONObject: json)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
// B4 · Why every real-keychain test in this package runs one at a time.
|
||||||
|
//
|
||||||
|
// `KeychainClientIdentityStore` / `SecureEnclaveKeyFactory` call `SecItem*` and
|
||||||
|
// `SecKeyCreateRandomKey` WITHOUT `kSecUseDataProtectionKeychain`, so on macOS
|
||||||
|
// `swift test` reaches the FILE-BASED login keychain. Two problems follow from
|
||||||
|
// Swift Testing's default parallel execution:
|
||||||
|
//
|
||||||
|
// 1. That backend is not safe for concurrent writes from one process: two
|
||||||
|
// simultaneous permanent-key generations intermittently fail with
|
||||||
|
// keyGenerationFailed("… Code=-25300 \"failed to generate CDSA key\" …")
|
||||||
|
// (observed 2026-07-30 — green under `--no-parallel`, red in parallel).
|
||||||
|
// 2. Certificate items are keyed by the cert's own subject on macOS, so the
|
||||||
|
// fixture-leaf sweep is GLOBAL: one test's cleanup would delete a
|
||||||
|
// concurrently-running test's freshly installed leaf.
|
||||||
|
//
|
||||||
|
// A global actor is NOT enough: an actor releases its executor at every `await`,
|
||||||
|
// and these tests await network-stub calls in the middle of their critical
|
||||||
|
// section (proved by exactly failure mode 2 appearing when a `@globalActor` was
|
||||||
|
// used). What is needed is a mutex held ACROSS suspension points — applied as a
|
||||||
|
// custom trait so the whole test body, setup and cleanup included, is inside it.
|
||||||
|
|
||||||
|
/// A FIFO async mutex.
|
||||||
|
actor KeychainTestMutex {
|
||||||
|
static let shared = KeychainTestMutex()
|
||||||
|
|
||||||
|
private var isHeld = false
|
||||||
|
private var waiters: [CheckedContinuation<Void, Never>] = []
|
||||||
|
|
||||||
|
func acquire() async {
|
||||||
|
guard isHeld else {
|
||||||
|
isHeld = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await withCheckedContinuation { waiters.append($0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func release() {
|
||||||
|
guard waiters.isEmpty else {
|
||||||
|
waiters.removeFirst().resume() // hand the lock straight to the next waiter
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isHeld = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes a test against every other keychain-touching test.
|
||||||
|
struct KeychainSerializedTrait: TestTrait, SuiteTrait, TestScoping {
|
||||||
|
func provideScope(
|
||||||
|
for test: Test,
|
||||||
|
testCase: Test.Case?,
|
||||||
|
performing function: @Sendable () async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
await KeychainTestMutex.shared.acquire()
|
||||||
|
do {
|
||||||
|
try await function()
|
||||||
|
} catch {
|
||||||
|
await KeychainTestMutex.shared.release()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
await KeychainTestMutex.shared.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Trait where Self == KeychainSerializedTrait {
|
||||||
|
/// Apply to every test that touches the real keychain.
|
||||||
|
static var keychainSerialized: Self { Self() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does the default keychain hand back an identity for a tag that was never
|
||||||
|
/// installed?
|
||||||
|
///
|
||||||
|
/// On macOS's file-based keychain, `SecItemCopyMatching(kSecClass:
|
||||||
|
/// kSecClassIdentity, kSecAttrApplicationTag: …)` IGNORES the tag and returns an
|
||||||
|
/// arbitrary identity from the login keychain (verified 2026-07-30 with a random
|
||||||
|
/// tag: `errSecSuccess` + an unrelated `localhost` identity). On iOS's
|
||||||
|
/// data-protection keychain the tag IS honored, which is why the production code
|
||||||
|
/// is correct on the shipping platform — but it means the `.p12` fallback branch
|
||||||
|
/// of `loadIdentity()` is only reachable in a `swift test` run whose default
|
||||||
|
/// keychain holds no identities (a clean CI runner). Tests that depend on that
|
||||||
|
/// branch are gated on this probe instead of being silently wrong.
|
||||||
|
func defaultKeychainYieldsForeignIdentity() -> Bool {
|
||||||
|
let query: [String: Any] = [
|
||||||
|
kSecClass as String: kSecClassIdentity,
|
||||||
|
kSecAttrApplicationTag as String: Data("clienttls-probe-\(UUID().uuidString)".utf8),
|
||||||
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||||
|
]
|
||||||
|
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
/// B4 · A KNOWN-GOOD PKCS#10 vector produced by OpenSSL, used to pin the
|
||||||
|
/// hand-written DER encoder in `CertificateSigningRequest` byte-for-byte.
|
||||||
|
///
|
||||||
|
/// The ECDSA signature is randomized, so the stable part of a CSR is its
|
||||||
|
/// `CertificationRequestInfo` — for a fixed key + fixed subject it is fully
|
||||||
|
/// deterministic. That is what is pinned here: if our encoder ever drifts (a
|
||||||
|
/// non-minimal length, a PrintableString instead of UTF8String, a wrong curve
|
||||||
|
/// OID, an attributes SET that is not `A0 00`), the bytes stop matching
|
||||||
|
/// OpenSSL's and this test fails.
|
||||||
|
///
|
||||||
|
/// Regenerate (OpenSSL 3.0.18, 2026-07-30):
|
||||||
|
/// openssl ecparam -name prime256v1 -genkey -noout -out fixed.key.pem
|
||||||
|
/// openssl req -new -key fixed.key.pem -subj "/CN=web-terminal-device" \
|
||||||
|
/// -outform DER -out csr.der
|
||||||
|
/// # CertificationRequestInfo = the first child of the outer SEQUENCE:
|
||||||
|
/// openssl asn1parse -inform DER -in csr.der -i # → offset 3, hl 3, l 128
|
||||||
|
/// dd if=csr.der bs=1 skip=3 count=131 | base64
|
||||||
|
/// # private key as X9.63 (0x04||X||Y||K) for SecKeyCreateWithData:
|
||||||
|
/// openssl ec -in fixed.key.pem -text -noout # → pub(65) || priv(32)
|
||||||
|
///
|
||||||
|
/// SECURITY: `privateKeyX963Base64` is a THROWAWAY key generated solely for this
|
||||||
|
/// vector. It protects nothing, is never enrolled, and is never written to a
|
||||||
|
/// keychain (`SecKeyCreateWithData` keeps it in-process). Same convention as the
|
||||||
|
/// embedded `device.p12` + passphrase in `ClientTLSFixtures`.
|
||||||
|
enum KnownGoodCSR {
|
||||||
|
static let subjectCommonName = "web-terminal-device"
|
||||||
|
|
||||||
|
/// OpenSSL's `CertificationRequestInfo` DER for `privateKeyX963Base64` +
|
||||||
|
/// `CN=web-terminal-device` (131 bytes).
|
||||||
|
static let certificationRequestInfoBase64 = """
|
||||||
|
MIGAAgEAMB4xHDAaBgNVBAMME3dlYi10ZXJtaW5hbC1kZXZpY2UwWTATBgcqhkjOPQIBBggqhkjO\
|
||||||
|
PQMBBwNCAARiJ8iQGXzCgho1TRYPNNcnqtgK/mrsNf+CAvoPbSH+12v1Q2OfVRgVPVYeS2AO3y87\
|
||||||
|
Oel9Uxe2QsHluJnGoifSoAA=
|
||||||
|
"""
|
||||||
|
|
||||||
|
/// `0x04 || X(32) || Y(32) || K(32)` — the format `SecKeyCreateWithData`
|
||||||
|
/// expects for an EC private key.
|
||||||
|
static let privateKeyX963Base64 = """
|
||||||
|
BGInyJAZfMKCGjVNFg801yeq2Ar+auw1/4IC+g9tIf7Xa/VDY59VGBU9Vh5LYA7fLzs56X1TF7ZC\
|
||||||
|
weW4mcaiJ9Ko07ifqUA6io//Czd0XRMztqg+nY0OJcA1Y1LwoBMBbA==
|
||||||
|
"""
|
||||||
|
|
||||||
|
static var certificationRequestInfoDER: Data {
|
||||||
|
Data(base64Encoded: certificationRequestInfoBase64, options: .ignoreUnknownCharacters)!
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixed key as a `P256HardwareKey`, in-process only (no keychain item).
|
||||||
|
static func fixedKey() throws -> SecureEnclaveKey {
|
||||||
|
let blob = Data(
|
||||||
|
base64Encoded: privateKeyX963Base64, options: .ignoreUnknownCharacters
|
||||||
|
)!
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPrivate,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
]
|
||||||
|
var error: Unmanaged<CFError>?
|
||||||
|
guard let key = SecKeyCreateWithData(blob as CFData, attributes as CFDictionary, &error)
|
||||||
|
else {
|
||||||
|
throw SecureEnclaveKeyError.keyGenerationFailed(
|
||||||
|
String(describing: error?.takeRetainedValue())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return SecureEnclaveKey(privateKey: key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
import Security
|
||||||
|
import Testing
|
||||||
|
@testable import ClientTLS
|
||||||
|
|
||||||
|
// B4 · The device key factory. Two things must hold for the enrollment path to
|
||||||
|
// work at all: (1) a PERMANENT tagged key survives and is found again by tag —
|
||||||
|
// that is what lets the enrolled leaf bind to it and `kSecClassIdentity`
|
||||||
|
// assemble; (2) when the Secure Enclave is not usable (Simulator, missing
|
||||||
|
// entitlement) the failure is CLASSIFIED as `.secureEnclaveUnavailable`, because
|
||||||
|
// that is the signal callers use to fall back to a software key. A
|
||||||
|
// `.keyGenerationFailed` there would look like a bug instead of a platform
|
||||||
|
// limit and the fallback would never happen.
|
||||||
|
//
|
||||||
|
// Runs against the REAL keychain (no shim exists in this package): each test
|
||||||
|
// uses a UUID-scoped tag and deletes it again, so nothing leaks between runs.
|
||||||
|
|
||||||
|
/// A per-test keychain tag — never collides with another test or another run.
|
||||||
|
private func uniqueTag() -> Data {
|
||||||
|
Data("com.yaojia.webterm.clienttls.test.key-\(UUID().uuidString)".utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a software key exposes a 65-byte uncompressed X9.63 point and signs verifiably")
|
||||||
|
func softwareKeySignsVerifiably() throws {
|
||||||
|
// Arrange
|
||||||
|
let key = try SecureEnclaveKeyFactory.generateSoftware()
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let point = try key.publicKeyX963()
|
||||||
|
let message = Data("certificationRequestInfo".utf8)
|
||||||
|
let signature = try key.sign(message)
|
||||||
|
|
||||||
|
// Assert — X9.63 uncompressed form, and an X9.62 DER ECDSA signature that
|
||||||
|
// verifies under the same algorithm the server's PoP check uses.
|
||||||
|
#expect(point.count == 65)
|
||||||
|
#expect(point.first == 0x04)
|
||||||
|
let publicKey = try #require(
|
||||||
|
SecKeyCreateWithData(
|
||||||
|
point as CFData,
|
||||||
|
[
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
] as CFDictionary,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
SecKeyVerifySignature(
|
||||||
|
publicKey, .ecdsaSignatureMessageX962SHA256,
|
||||||
|
message as CFData, signature as CFData, nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
#expect(signature.first == 0x30) // SEQUENCE { r, s }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a non-permanent key is NOT stored in the keychain", .keychainSerialized)
|
||||||
|
func nonPermanentKeyIsNotPersisted() async throws {
|
||||||
|
// Arrange
|
||||||
|
let tag = uniqueTag()
|
||||||
|
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||||
|
|
||||||
|
// Act — tagged but permanent: false (the unit-test / Simulator shape).
|
||||||
|
_ = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: false)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a permanent tagged key is found again by tag and deleted idempotently", .keychainSerialized)
|
||||||
|
func permanentKeyRoundtripsByTag() async throws {
|
||||||
|
// Arrange
|
||||||
|
let tag = uniqueTag()
|
||||||
|
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil) // pre-enroll state
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let generated = try SecureEnclaveKeyFactory.generateSoftware(tag: tag, permanent: true)
|
||||||
|
let loaded = try SecureEnclaveKeyFactory.load(tag: tag)
|
||||||
|
|
||||||
|
// Assert — the SAME key comes back (rotation must re-sign with it, never
|
||||||
|
// mint a new one).
|
||||||
|
let reloaded = try #require(loaded)
|
||||||
|
#expect(try reloaded.publicKeyX963() == (try generated.publicKeyX963()))
|
||||||
|
|
||||||
|
// Act — delete, then delete again.
|
||||||
|
try SecureEnclaveKeyFactory.delete(tag: tag)
|
||||||
|
|
||||||
|
// Assert — gone, and a second delete is a no-op (not a throw).
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tag) == nil)
|
||||||
|
#expect(throws: Never.self) { try SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("two keys under different tags stay independent", .keychainSerialized)
|
||||||
|
func tagsScopeKeysIndependently() async throws {
|
||||||
|
// Arrange
|
||||||
|
let tagA = uniqueTag()
|
||||||
|
let tagB = uniqueTag()
|
||||||
|
defer {
|
||||||
|
try? SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||||
|
try? SecureEnclaveKeyFactory.delete(tag: tagB)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let keyA = try SecureEnclaveKeyFactory.generateSoftware(tag: tagA, permanent: true)
|
||||||
|
let keyB = try SecureEnclaveKeyFactory.generateSoftware(tag: tagB, permanent: true)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
#expect(try keyA.publicKeyX963() != (try keyB.publicKeyX963()))
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tagA)?.publicKeyX963()
|
||||||
|
== (try keyA.publicKeyX963()))
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tagB)?.publicKeyX963()
|
||||||
|
== (try keyB.publicKeyX963()))
|
||||||
|
|
||||||
|
// Deleting one leaves the other installed.
|
||||||
|
try SecureEnclaveKeyFactory.delete(tag: tagA)
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tagA) == nil)
|
||||||
|
#expect(try SecureEnclaveKeyFactory.load(tag: tagB) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Secure-Enclave keygen without the entitlement fails as .secureEnclaveUnavailable", .keychainSerialized)
|
||||||
|
func secureEnclaveKeygenClassifiesUnavailability() async throws {
|
||||||
|
// Arrange
|
||||||
|
let tag = uniqueTag()
|
||||||
|
defer { try? SecureEnclaveKeyFactory.delete(tag: tag) }
|
||||||
|
|
||||||
|
// Act / Assert — an UNSIGNED test binary has no
|
||||||
|
// `com.apple.developer.kernel...`/keychain-access-group entitlement, so
|
||||||
|
// SecKeyCreateRandomKey over the SE token fails with -34018. What is under
|
||||||
|
// test is the CLASSIFICATION: it must be `.secureEnclaveUnavailable`
|
||||||
|
// (callers' fallback signal), never `.keyGenerationFailed`.
|
||||||
|
//
|
||||||
|
// On a host that CAN mint an SE key (entitled build on real hardware) the
|
||||||
|
// call legitimately succeeds; then the key must be a usable signer. Both
|
||||||
|
// outcomes are asserted so this test is honest on every machine.
|
||||||
|
do {
|
||||||
|
let key = try SecureEnclaveKeyFactory.generateSecureEnclave(tag: tag)
|
||||||
|
let signature = try key.sign(Data("probe".utf8))
|
||||||
|
#expect(signature.first == 0x30)
|
||||||
|
} catch let error as SecureEnclaveKeyError {
|
||||||
|
guard case let .secureEnclaveUnavailable(description) = error else {
|
||||||
|
Issue.record("SE keygen failure must classify as .secureEnclaveUnavailable: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(description.isEmpty == false) // the underlying CFError is kept for logs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("signing with a public-only key surfaces .signatureFailed instead of crashing")
|
||||||
|
func signingWithPublicKeyFails() throws {
|
||||||
|
// Arrange — wrap a PUBLIC key in the signer (the shape a mis-wired
|
||||||
|
// composition root could produce).
|
||||||
|
let point = try SecureEnclaveKeyFactory.generateSoftware().publicKeyX963()
|
||||||
|
let publicKey = try #require(
|
||||||
|
SecKeyCreateWithData(
|
||||||
|
point as CFData,
|
||||||
|
[
|
||||||
|
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||||
|
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||||
|
kSecAttrKeySizeInBits as String: 256,
|
||||||
|
] as CFDictionary,
|
||||||
|
nil
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let key = SecureEnclaveKey(privateKey: publicKey)
|
||||||
|
|
||||||
|
// Act / Assert
|
||||||
|
do {
|
||||||
|
_ = try key.sign(Data("m".utf8))
|
||||||
|
Issue.record("signing with a public key must throw")
|
||||||
|
} catch let error as SecureEnclaveKeyError {
|
||||||
|
guard case let .signatureFailed(description) = error else {
|
||||||
|
Issue.record("expected .signatureFailed, got \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#expect(description.isEmpty == false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("a nil CFError is described as 'unknown' rather than crashing the error path")
|
||||||
|
func describeNilError() {
|
||||||
|
#expect(SecureEnclaveKey.describe(nil) == "unknown")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user