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>
This commit is contained in:
200
ios/Packages/ClientTLS/Sources/ClientTLS/SecureEnclaveKey.swift
Normal file
200
ios/Packages/ClientTLS/Sources/ClientTLS/SecureEnclaveKey.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
import Foundation
|
||||
import os
|
||||
import Security
|
||||
|
||||
/// C-iOS · A P-256 signing key that lives ENTIRELY inside SecKey /
|
||||
/// Security.framework — never CryptoKit.
|
||||
///
|
||||
/// **`[FIX C-native, ClientTLS trap]`** — the enrollment path deliberately avoids
|
||||
/// `CryptoKit.SecureEnclave.P256.Signing.PrivateKey` and `SecKeyCreateWithData`
|
||||
/// over a Secure-Enclave token: either route mints a key the keychain cannot
|
||||
/// later match to a stored certificate, so `SecItemCopyMatching(kSecClassIdentity)`
|
||||
/// fails with `errSecItemNotFound (-25300)` and the whole mTLS identity silently
|
||||
/// never assembles. Staying on `SecKeyCreateRandomKey` + `SecKeyCreateSignature`
|
||||
/// keeps the private key a first-class, permanent keychain resident that the leaf
|
||||
/// certificate binds to automatically.
|
||||
public protocol P256HardwareKey: Sendable {
|
||||
/// The public key in ANSI X9.63 uncompressed form: `0x04 || X || Y`
|
||||
/// (65 bytes for P-256). This is exactly what wraps into a SubjectPublicKeyInfo.
|
||||
func publicKeyX963() throws -> Data
|
||||
|
||||
/// ECDSA sign `message` over SHA-256, returning the X9.62 DER signature
|
||||
/// (`SEQUENCE { r INTEGER, s INTEGER }`) — the exact shape a PKCS#10
|
||||
/// `signature` BIT STRING and `verifyCsrPoPEc` expect. The digest is computed
|
||||
/// by the algorithm (`.ecdsaSignatureMessageX962SHA256`), so callers pass the
|
||||
/// raw message (the DER of `CertificationRequestInfo`), NOT a pre-hash.
|
||||
func sign(_ message: Data) throws -> Data
|
||||
}
|
||||
|
||||
public enum SecureEnclaveKeyError: Error, Equatable, Sendable {
|
||||
/// The Secure Enclave is absent (Simulator) or the app lacks the entitlement.
|
||||
/// Carries the underlying `SecKeyCreateRandomKey` failure for diagnostics.
|
||||
case secureEnclaveUnavailable(String)
|
||||
/// Key generation failed for a reason other than SE-unavailability.
|
||||
case keyGenerationFailed(String)
|
||||
/// The public key could not be derived from the private key.
|
||||
case publicKeyUnavailable
|
||||
/// Exporting the public key to X9.63 bytes failed.
|
||||
case exportFailed(String)
|
||||
/// Signing failed (algorithm unsupported, user-presence denied, …).
|
||||
case signatureFailed(String)
|
||||
/// A keychain `SecItem*` lookup/delete failed with this status.
|
||||
case keychain(OSStatus)
|
||||
}
|
||||
|
||||
/// A `SecKey`-backed P-256 key. The wrapped `SecKey` is a Secure-Enclave key in
|
||||
/// production (via `SecureEnclaveKeyFactory.generate`) and an in-process software
|
||||
/// key on Simulator/tests (via `.generateSoftware`) — both drive the SAME
|
||||
/// `SecKeyCreateSignature` path, so the CSR encoder is exercised identically.
|
||||
///
|
||||
/// `@unchecked Sendable`: `SecKey` is an immutable, thread-safe CoreFoundation
|
||||
/// handle once created; this wrapper only ever reads it.
|
||||
public final class SecureEnclaveKey: P256HardwareKey, @unchecked Sendable {
|
||||
/// The (non-exportable, in production) private key. Never leaves the device.
|
||||
private let privateKey: SecKey
|
||||
|
||||
/// Wrap an existing `SecKey`. Public so tests can inject a software P-256 key
|
||||
/// created via `SecKeyCreateRandomKey` without the SE token.
|
||||
public init(privateKey: SecKey) {
|
||||
self.privateKey = privateKey
|
||||
}
|
||||
|
||||
public func publicKeyX963() throws -> Data {
|
||||
guard let publicKey = SecKeyCopyPublicKey(privateKey) else {
|
||||
throw SecureEnclaveKeyError.publicKeyUnavailable
|
||||
}
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let data = SecKeyCopyExternalRepresentation(publicKey, &error) as Data? else {
|
||||
throw SecureEnclaveKeyError.exportFailed(Self.describe(error))
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
public func sign(_ message: Data) throws -> Data {
|
||||
var error: Unmanaged<CFError>?
|
||||
guard
|
||||
let signature = SecKeyCreateSignature(
|
||||
privateKey,
|
||||
.ecdsaSignatureMessageX962SHA256,
|
||||
message as CFData,
|
||||
&error
|
||||
) as Data?
|
||||
else {
|
||||
throw SecureEnclaveKeyError.signatureFailed(Self.describe(error))
|
||||
}
|
||||
return signature
|
||||
}
|
||||
|
||||
static func describe(_ error: Unmanaged<CFError>?) -> String {
|
||||
guard let error else { return "unknown" }
|
||||
return String(describing: error.takeRetainedValue())
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates / loads / deletes the device's P-256 key.
|
||||
///
|
||||
/// Production generation is **Secure-Enclave, non-exportable, permanent** so the
|
||||
/// private key never leaves hardware and survives relaunch as a keychain
|
||||
/// resident. `generateSoftware` is the documented **non-device fallback** (the SE
|
||||
/// is unavailable on the Simulator and — per plan §3.3 — desktop is explicitly
|
||||
/// downgraded to a best-effort OS-keychain key); it is the SAME `SecKey` API
|
||||
/// minus the SE token, so the CSR/signature code path is unchanged.
|
||||
public enum SecureEnclaveKeyFactory {
|
||||
private static let log = Logger(subsystem: "com.yaojia.webterm", category: "se-key")
|
||||
|
||||
/// Generate a NON-EXPORTABLE P-256 key inside the Secure Enclave, marked
|
||||
/// permanent + tagged so the keychain can later bind the enrolled leaf to it.
|
||||
/// Throws `.secureEnclaveUnavailable` on Simulator / missing entitlement so
|
||||
/// callers can fall back to `generateSoftware` for non-device builds.
|
||||
public static func generateSecureEnclave(tag: Data) throws -> SecureEnclaveKey {
|
||||
try generate(tag: tag, inSecureEnclave: true, permanent: true)
|
||||
}
|
||||
|
||||
/// Software P-256 key (NO Secure Enclave token). Simulator / desktop
|
||||
/// best-effort / unit tests. `permanent == false` keeps it in-process only.
|
||||
public static func generateSoftware(tag: Data? = nil, permanent: Bool = false) throws
|
||||
-> SecureEnclaveKey {
|
||||
try generate(tag: tag, inSecureEnclave: false, permanent: permanent)
|
||||
}
|
||||
|
||||
private static func generate(
|
||||
tag: Data?, inSecureEnclave: Bool, permanent: Bool
|
||||
) throws -> SecureEnclaveKey {
|
||||
var privateKeyAttrs: [String: Any] = [kSecAttrIsPermanent as String: permanent]
|
||||
if let tag {
|
||||
privateKeyAttrs[kSecAttrApplicationTag as String] = tag
|
||||
}
|
||||
|
||||
if inSecureEnclave {
|
||||
var accessError: Unmanaged<CFError>?
|
||||
guard
|
||||
let access = SecAccessControlCreateWithFlags(
|
||||
kCFAllocatorDefault,
|
||||
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
.privateKeyUsage,
|
||||
&accessError
|
||||
)
|
||||
else {
|
||||
throw SecureEnclaveKeyError.keyGenerationFailed(
|
||||
"access control: \(SecureEnclaveKey.describe(accessError))"
|
||||
)
|
||||
}
|
||||
privateKeyAttrs[kSecAttrAccessControl as String] = access
|
||||
}
|
||||
|
||||
var attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
kSecPrivateKeyAttrs as String: privateKeyAttrs,
|
||||
]
|
||||
if inSecureEnclave {
|
||||
attributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave
|
||||
}
|
||||
|
||||
var error: Unmanaged<CFError>?
|
||||
guard let key = SecKeyCreateRandomKey(attributes as CFDictionary, &error) else {
|
||||
let description = SecureEnclaveKey.describe(error)
|
||||
if inSecureEnclave {
|
||||
log.error(
|
||||
"Secure Enclave keygen failed (simulator / no entitlement?): \(description, privacy: .public)"
|
||||
)
|
||||
throw SecureEnclaveKeyError.secureEnclaveUnavailable(description)
|
||||
}
|
||||
throw SecureEnclaveKeyError.keyGenerationFailed(description)
|
||||
}
|
||||
return SecureEnclaveKey(privateKey: key)
|
||||
}
|
||||
|
||||
/// Load a previously-generated key (SE or software) by its keychain tag.
|
||||
/// `nil` if none exists (the normal pre-enroll state).
|
||||
public static func load(tag: Data) throws -> SecureEnclaveKey? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassKey,
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrApplicationTag as String: tag,
|
||||
kSecReturnRef as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let value = result else {
|
||||
throw SecureEnclaveKeyError.keychain(status)
|
||||
}
|
||||
// Safe: the query pins kSecClassKey, so a match is always a SecKey.
|
||||
let key = value as! SecKey
|
||||
return SecureEnclaveKey(privateKey: key)
|
||||
}
|
||||
|
||||
/// Delete the device key by tag. Idempotent.
|
||||
public static func delete(tag: Data) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassKey,
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrApplicationTag as String: tag,
|
||||
]
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw SecureEnclaveKeyError.keychain(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user