Files
web-terminal/ios/Packages/ClientTLS/Sources/ClientTLS/KeychainClientIdentityStore.swift
Yaojia Wang e7f3bd05f0 feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:11:13 +02:00

404 lines
17 KiB
Swift

import Foundation
import os
import Security
/// Persists the device identity so it survives relaunch. The raw `.p12` bytes
/// **and** its passphrase are stored together (the passphrase is required to
/// re-import via `SecPKCS12Import` at every launch), then re-imported on load.
public protocol ClientIdentityStore: Sendable {
/// Validate (`SecPKCS12Import`) then persist the `.p12` + passphrase.
/// Throws `PKCS12ImportError` on a bad passphrase / corrupt file (nothing is
/// persisted in that case) and `ClientIdentityStoreError` on a storage fault.
func save(p12Data: Data, passphrase: String) throws
/// Re-import and return the stored identity; `nil` if none is installed.
func loadIdentity() throws -> ClientIdentity?
/// Display summary of the stored certificate; `nil` if none is installed.
func loadSummary() throws -> ClientCertificateSummary?
/// Delete the stored identity (rotation / removal). Idempotent.
func remove() throws
/// Cheap existence check for gating (does NOT re-import).
func hasInstalledIdentity() -> Bool
}
public enum ClientIdentityStoreError: Error, Equatable, Sendable {
/// A Keychain `SecItem*` call failed with this `OSStatus`.
case keychain(OSStatus)
/// The stored blob was present but could not be decoded.
case corruptStoredBlob
}
public extension ClientIdentityStore {
/// Convenience for composition roots: load the identity, logging and
/// swallowing errors into `nil`. A missing cert is the normal pre-install
/// state; a genuine fault must not crash launch, but is logged (never
/// silently dropped).
func loadedIdentityOrNil() -> ClientIdentity? {
do {
return try loadIdentity()
} catch {
ClientTLSLog.identity.error(
"loadIdentity failed: \(String(describing: error), privacy: .public)"
)
return nil
}
}
}
/// The stored payload `.p12` bytes plus the passphrase needed to re-import.
private struct StoredP12Blob: Codable {
let p12: Data
let passphrase: String
}
/// C-iOS · The enrolled-identity record stored beside the Secure-Enclave leaf:
/// the issuer chain (presented on the handshake), the `deviceId` (drives renew),
/// and the rotation timing. The private key itself never lives here it stays
/// non-exportable in the Secure Enclave.
private struct StoredEnrollment: Codable {
let deviceId: String
let deviceName: String
let caChain: [Data]
let notAfter: Date?
let renewAfter: Date?
}
/// Keychain-backed store: one `kSecClassGenericPassword` item holding the
/// JSON-encoded `StoredP12Blob` in `kSecValueData`, protected with
/// `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (available after the first
/// unlock post-boot, never migrates off this device).
public struct KeychainClientIdentityStore: ClientIdentityStore {
public static let defaultService = "com.yaojia.webterm.clienttls"
public static let defaultAccount = "device-identity"
private let service: String
private let account: String
public init(
service: String = defaultService, account: String = defaultAccount
) {
self.service = service
self.account = account
}
public func save(p12Data: Data, passphrase: String) throws {
// Validate BEFORE persisting a wrong passphrase / corrupt file must
// surface to the install UI and leave any prior identity untouched.
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
let blob = try encode(StoredP12Blob(p12: p12Data, passphrase: passphrase))
try writeItem(blob)
}
public func loadIdentity() throws -> ClientIdentity? {
// C-iOS · Prefer the Secure-Enclave-backed enrolled identity (the .p12-free
// path). During the dual-trust migration window a device may still carry a
// legacy imported `.p12`; fall back to it so existing installs keep working.
if let enrolled = try loadDeviceIdentity() { return enrolled }
guard let blob = try readBlob() else { return nil }
return try PKCS12Importer.importIdentity(
data: blob.p12, passphrase: blob.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
// Clear BOTH paths so removal is unconditional: the SE key + enrolled leaf
// and the legacy `.p12` blob.
try removeEnrolled()
let status = SecItemDelete(baseQuery() as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(status)
}
}
public func hasInstalledIdentity() -> Bool {
if hasEnrolledLeaf() { return true }
var query = baseQuery()
query[kSecReturnData as String] = false
query[kSecMatchLimit as String] = kSecMatchLimitOne
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
// MARK: - C-iOS · Secure-Enclave enrollment (the .p12-free path)
/// Keychain tag of the device's Secure-Enclave private key. The enrolled leaf
/// binds to this key so `SecItemCopyMatching(kSecClassIdentity)` can assemble
/// the `SecIdentity` presented on the existing mTLS path (unchanged).
private var deviceKeyTag: Data { Data("\(service).device-key".utf8) }
/// Label under which the enrolled leaf certificate is stored.
private var leafLabel: String { "\(service).device-leaf" }
/// Account of the generic-password item holding the enrollment record
/// (deviceId + issuer chain + rotation timing) alongside the leaf.
private var enrollmentAccount: String { "\(account).enrollment" }
/// One-time enrollment: generate a NON-EXPORTABLE Secure-Enclave P-256 key,
/// self-sign a CSR with it, POST it, and store the returned leaf against that
/// key. Returns the installed cert's summary. `keyProvider` is injectable so
/// non-device builds can supply a software key; production defaults to the SE.
public func enroll(
using client: DeviceEnrollmentClient,
subdomain: String,
deviceName: String,
keyProvider: (@Sendable () throws -> any P256HardwareKey)? = nil
) async throws -> ClientCertificateSummary? {
let key = try keyProvider?()
?? SecureEnclaveKeyFactory.generateSecureEnclave(tag: deviceKeyTag)
let csr = try CertificateSigningRequest.der(subjectCommonName: deviceName, signer: key)
let result = try await client.enroll(
csrDER: csr, subdomain: subdomain, deviceName: deviceName
)
try storeEnrolledLeaf(result, deviceName: deviceName)
return try loadSummary()
}
/// Rotation: re-CSR from the SAME Secure-Enclave key and replace the leaf.
/// The caller (rotation scheduler) tears down live connections afterward so
/// the new cert is presented on the next handshake (plan §3.3).
public func renew(
using client: DeviceEnrollmentClient
) async throws -> ClientCertificateSummary? {
guard let record = try readEnrollment(),
let key = try SecureEnclaveKeyFactory.load(tag: deviceKeyTag)
else {
throw ClientIdentityStoreError.corruptStoredBlob // nothing to renew
}
let csr = try CertificateSigningRequest.der(
subjectCommonName: record.deviceName, signer: key
)
let result = try await client.renew(deviceId: record.deviceId, csrDER: csr)
try storeEnrolledLeaf(result, deviceName: record.deviceName)
return try loadSummary()
}
/// The Secure-Enclave-backed identity: the assembled `SecIdentity` (leaf bound
/// to the SE key) plus the stored issuer chain. `nil` when not enrolled.
func loadDeviceIdentity() throws -> ClientIdentity? {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrApplicationTag as String: deviceKeyTag,
kSecReturnRef as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let value = result else {
throw ClientIdentityStoreError.keychain(status)
}
// Safe: the query pins kSecClassIdentity, so a match is a SecIdentity.
let identity = value as! SecIdentity
let issuers = ((try? readEnrollment())?.caChain ?? []).compactMap {
SecCertificateCreateWithData(nil, $0 as CFData)
}
return ClientIdentity(secIdentity: identity, issuerCertificates: issuers)
}
/// Cheap check: is a Secure-Enclave identity installed? (No cert re-parse.)
private func hasEnrolledLeaf() -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassIdentity,
kSecAttrApplicationTag as String: deviceKeyTag,
kSecMatchLimit as String: kSecMatchLimitOne,
]
return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess
}
/// Persist the enrolled leaf (delete-then-add so rotation replaces the prior
/// one) plus the enrollment record. The leaf binds to the permanent SE key,
/// letting the keychain form the identity on load.
private func storeEnrolledLeaf(_ result: EnrollmentResult, deviceName: String) throws {
guard let certificate = SecCertificateCreateWithData(nil, result.certificate as CFData)
else {
throw ClientIdentityStoreError.corruptStoredBlob // not a valid DER cert
}
let deleteLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
]
let deleteStatus = SecItemDelete(deleteLeaf as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
let addLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecValueRef as String: certificate,
kSecAttrLabel as String: leafLabel,
]
let addStatus = SecItemAdd(addLeaf as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
try writeEnrollment(
StoredEnrollment(
deviceId: result.deviceId,
deviceName: deviceName,
caChain: result.caChain,
notAfter: result.notAfter,
renewAfter: result.renewAfter
)
)
}
/// Delete the SE key, the enrolled leaf, and the enrollment record. Idempotent.
private func removeEnrolled() throws {
let deleteLeaf: [String: Any] = [
kSecClass as String: kSecClassCertificate,
kSecAttrLabel as String: leafLabel,
]
let leafStatus = SecItemDelete(deleteLeaf as CFDictionary)
guard leafStatus == errSecSuccess || leafStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(leafStatus)
}
try SecureEnclaveKeyFactory.delete(tag: deviceKeyTag)
let recordStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
guard recordStatus == errSecSuccess || recordStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(recordStatus)
}
}
private func enrollmentQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: enrollmentAccount,
]
}
private func writeEnrollment(_ record: StoredEnrollment) throws {
let data: Data
do {
data = try JSONEncoder().encode(record)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
let deleteStatus = SecItemDelete(enrollmentQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = enrollmentQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readEnrollment() throws -> StoredEnrollment? {
var query = enrollmentQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredEnrollment.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
// MARK: - Keychain plumbing
private func baseQuery() -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: account,
]
}
private func writeItem(_ data: Data) throws {
// Delete-then-add keeps the item's protection class deterministic
// (SecItemUpdate can't change kSecAttrAccessible in place).
let deleteStatus = SecItemDelete(baseQuery() as CFDictionary)
guard deleteStatus == errSecSuccess || deleteStatus == errSecItemNotFound else {
throw ClientIdentityStoreError.keychain(deleteStatus)
}
var attributes = baseQuery()
attributes[kSecValueData as String] = data
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(attributes as CFDictionary, nil)
guard addStatus == errSecSuccess else {
throw ClientIdentityStoreError.keychain(addStatus)
}
}
private func readBlob() throws -> StoredP12Blob? {
var query = baseQuery()
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess, let data = result as? Data else {
throw ClientIdentityStoreError.keychain(status)
}
do {
return try JSONDecoder().decode(StoredP12Blob.self, from: data)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
private func encode(_ blob: StoredP12Blob) throws -> Data {
do {
return try JSONEncoder().encode(blob)
} catch {
throw ClientIdentityStoreError.corruptStoredBlob
}
}
}
/// In-memory store for previews and unit tests: same import/summary code path as
/// the keychain store (so the roundtrip is exercised) without any keychain
/// entitlement. `@unchecked Sendable` mutable blob guarded by a lock.
public final class InMemoryClientIdentityStore: ClientIdentityStore, @unchecked Sendable {
private let lock = NSLock()
private var blob: StoredP12BlobBox?
/// Boxed so the private `StoredP12Blob` type stays file-private above; this
/// mirror keeps the two bytes+passphrase without exposing the Codable type.
private struct StoredP12BlobBox {
let p12: Data
let passphrase: String
}
public init() {}
public func save(p12Data: Data, passphrase: String) throws {
_ = try PKCS12Importer.importIdentity(data: p12Data, passphrase: passphrase)
lock.withLock { blob = StoredP12BlobBox(p12: p12Data, passphrase: passphrase) }
}
public func loadIdentity() throws -> ClientIdentity? {
guard let stored = lock.withLock({ blob }) else { return nil }
return try PKCS12Importer.importIdentity(
data: stored.p12, passphrase: stored.passphrase
)
}
public func loadSummary() throws -> ClientCertificateSummary? {
try loadIdentity()?.summary()
}
public func remove() throws {
lock.withLock { blob = nil }
}
public func hasInstalledIdentity() -> Bool {
lock.withLock { blob != nil }
}
}
enum ClientTLSLog {
static let identity = Logger(subsystem: "com.yaojia.webterm", category: "client-tls")
}