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:
@@ -0,0 +1,181 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS · Manual DER encoder for a P-256 PKCS#10 `CertificationRequest`.
|
||||
///
|
||||
/// Built by hand (no CryptoKit / SecCertificate helpers) so the exact bytes are
|
||||
/// under our control and the request is signed with a Secure-Enclave `SecKey`
|
||||
/// via `SecKeyCreateSignature(.ecdsaSignatureMessageX962SHA256)` — see the
|
||||
/// `[FIX C-native, ClientTLS trap]` note on `SecureEnclaveKey`.
|
||||
///
|
||||
/// The output must satisfy the control-plane `verifyCsrPoPEc` (A1): an EC P-256
|
||||
/// `SubjectPublicKeyInfo` (algorithm `id-ecPublicKey` + namedCurve `prime256v1`),
|
||||
/// a self-signature under `ecdsa-with-SHA256`, and a valid PoP. Encoding is
|
||||
/// strictly canonical DER (minimal lengths) so the server's re-serialization of
|
||||
/// `CertificationRequestInfo` matches the bytes we signed.
|
||||
///
|
||||
/// ```
|
||||
/// CertificationRequest ::= SEQUENCE {
|
||||
/// certificationRequestInfo CertificationRequestInfo,
|
||||
/// signatureAlgorithm AlgorithmIdentifier, -- ecdsa-with-SHA256
|
||||
/// signature BIT STRING } -- X9.62 DER ECDSA-Sig
|
||||
///
|
||||
/// CertificationRequestInfo ::= SEQUENCE {
|
||||
/// version INTEGER { v1(0) },
|
||||
/// subject Name,
|
||||
/// subjectPKInfo SubjectPublicKeyInfo,
|
||||
/// attributes [0] IMPLICIT SET OF Attribute } -- empty
|
||||
/// ```
|
||||
public enum CertificateSigningRequest {
|
||||
public enum CSRError: Error, Equatable, Sendable {
|
||||
/// The public key was not the expected 65-byte X9.63 uncompressed point.
|
||||
case invalidPublicKey
|
||||
/// The empty subject CN is not encodable.
|
||||
case invalidSubject
|
||||
}
|
||||
|
||||
/// P-256 uncompressed public point is `0x04 || X(32) || Y(32)` = 65 bytes.
|
||||
private static let uncompressedP256PointLength = 65
|
||||
|
||||
/// Build and self-sign a P-256 PKCS#10 CSR DER for `signer`'s key.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - subjectCommonName: the CSR subject CN. The device leaf's identity is
|
||||
/// driven server-side by the ownership-verified subdomain SAN, so this is
|
||||
/// descriptive only; it must be non-empty.
|
||||
/// - signer: the P-256 hardware key that provides the public key and signs
|
||||
/// the `CertificationRequestInfo`.
|
||||
public static func der(
|
||||
subjectCommonName: String, signer: any P256HardwareKey
|
||||
) throws -> Data {
|
||||
guard !subjectCommonName.isEmpty else { throw CSRError.invalidSubject }
|
||||
|
||||
let publicPoint = [UInt8](try signer.publicKeyX963())
|
||||
guard publicPoint.count == uncompressedP256PointLength, publicPoint[0] == 0x04 else {
|
||||
throw CSRError.invalidPublicKey
|
||||
}
|
||||
|
||||
let requestInfo = certificationRequestInfo(
|
||||
subjectCommonName: subjectCommonName, publicPoint: publicPoint
|
||||
)
|
||||
let signature = [UInt8](try signer.sign(Data(requestInfo)))
|
||||
|
||||
let request = DERWriter.sequence([
|
||||
requestInfo,
|
||||
ecdsaWithSHA256AlgorithmIdentifier,
|
||||
DERWriter.bitString(signature),
|
||||
])
|
||||
return Data(request)
|
||||
}
|
||||
|
||||
// MARK: - CertificationRequestInfo
|
||||
|
||||
private static func certificationRequestInfo(
|
||||
subjectCommonName: String, publicPoint: [UInt8]
|
||||
) -> [UInt8] {
|
||||
DERWriter.sequence([
|
||||
DERWriter.integer0, // version v1(0)
|
||||
name(commonName: subjectCommonName),
|
||||
subjectPublicKeyInfo(publicPoint: publicPoint),
|
||||
DERWriter.emptyAttributesContext0, // [0] IMPLICIT SET OF Attribute (empty)
|
||||
])
|
||||
}
|
||||
|
||||
/// `Name ::= SEQUENCE OF RelativeDistinguishedName` with a single CN RDN.
|
||||
private static func name(commonName: String) -> [UInt8] {
|
||||
let attribute = DERWriter.sequence([
|
||||
DERWriter.oid(OID.commonName),
|
||||
DERWriter.utf8String(commonName),
|
||||
])
|
||||
let rdn = DERWriter.set([attribute])
|
||||
return DERWriter.sequence([rdn])
|
||||
}
|
||||
|
||||
/// `SubjectPublicKeyInfo` for an EC P-256 key: `id-ecPublicKey` +
|
||||
/// `prime256v1` named curve, then the uncompressed point as a BIT STRING.
|
||||
private static func subjectPublicKeyInfo(publicPoint: [UInt8]) -> [UInt8] {
|
||||
let algorithm = DERWriter.sequence([
|
||||
DERWriter.oid(OID.ecPublicKey),
|
||||
DERWriter.oid(OID.prime256v1),
|
||||
])
|
||||
return DERWriter.sequence([
|
||||
algorithm,
|
||||
DERWriter.bitString(publicPoint),
|
||||
])
|
||||
}
|
||||
|
||||
/// `AlgorithmIdentifier` for `ecdsa-with-SHA256` — no parameters (absent, per
|
||||
/// RFC 5758), which is exactly what the server's verifier expects.
|
||||
private static let ecdsaWithSHA256AlgorithmIdentifier: [UInt8] =
|
||||
DERWriter.sequence([DERWriter.oid(OID.ecdsaWithSHA256)])
|
||||
}
|
||||
|
||||
// MARK: - Object identifiers (DER content bytes, tag/length added by DERWriter.oid)
|
||||
|
||||
private enum OID {
|
||||
/// 1.2.840.10045.2.1 — id-ecPublicKey.
|
||||
static let ecPublicKey: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01]
|
||||
/// 1.2.840.10045.3.1.7 — prime256v1 / secp256r1.
|
||||
static let prime256v1: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07]
|
||||
/// 1.2.840.10045.4.3.2 — ecdsa-with-SHA256.
|
||||
static let ecdsaWithSHA256: [UInt8] = [0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02]
|
||||
/// 2.5.4.3 — id-at-commonName.
|
||||
static let commonName: [UInt8] = [0x55, 0x04, 0x03]
|
||||
}
|
||||
|
||||
// MARK: - Minimal canonical DER writer
|
||||
|
||||
/// A tiny DER encoder. Every helper returns a fully-formed TLV so callers just
|
||||
/// concatenate children — canonical minimal-length encoding throughout.
|
||||
enum DERWriter {
|
||||
private static let tagInteger: UInt8 = 0x02
|
||||
private static let tagBitString: UInt8 = 0x03
|
||||
private static let tagOID: UInt8 = 0x06
|
||||
private static let tagUTF8String: UInt8 = 0x0C
|
||||
private static let tagSequence: UInt8 = 0x30
|
||||
private static let tagSet: UInt8 = 0x31
|
||||
private static let tagContext0Constructed: UInt8 = 0xA0
|
||||
|
||||
/// `INTEGER 0` — the fixed PKCS#10 version v1(0).
|
||||
static let integer0: [UInt8] = [tagInteger, 0x01, 0x00]
|
||||
|
||||
/// `[0] IMPLICIT SET OF Attribute`, empty — `A0 00`.
|
||||
static let emptyAttributesContext0: [UInt8] = [tagContext0Constructed, 0x00]
|
||||
|
||||
static func sequence(_ children: [[UInt8]]) -> [UInt8] {
|
||||
tlv(tagSequence, children.flatMap { $0 })
|
||||
}
|
||||
|
||||
static func set(_ children: [[UInt8]]) -> [UInt8] {
|
||||
tlv(tagSet, children.flatMap { $0 })
|
||||
}
|
||||
|
||||
static func oid(_ content: [UInt8]) -> [UInt8] {
|
||||
tlv(tagOID, content)
|
||||
}
|
||||
|
||||
static func utf8String(_ value: String) -> [UInt8] {
|
||||
tlv(tagUTF8String, [UInt8](Data(value.utf8)))
|
||||
}
|
||||
|
||||
/// BIT STRING with zero unused bits (all our bit strings are byte-aligned).
|
||||
static func bitString(_ content: [UInt8]) -> [UInt8] {
|
||||
tlv(tagBitString, [0x00] + content)
|
||||
}
|
||||
|
||||
/// Tag-Length-Value with canonical DER length encoding.
|
||||
private static func tlv(_ tag: UInt8, _ value: [UInt8]) -> [UInt8] {
|
||||
[tag] + length(value.count) + value
|
||||
}
|
||||
|
||||
/// DER length: short form (<128) or long form (0x80 | byteCount, big-endian).
|
||||
private static func length(_ count: Int) -> [UInt8] {
|
||||
if count < 0x80 { return [UInt8(count)] }
|
||||
var value = count
|
||||
var bytes: [UInt8] = []
|
||||
while value > 0 {
|
||||
bytes.insert(UInt8(value & 0xFF), at: 0)
|
||||
value >>= 8
|
||||
}
|
||||
return [0x80 | UInt8(bytes.count)] + bytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import Foundation
|
||||
|
||||
/// C-iOS · Talks to the control-plane device-enrollment API (A4):
|
||||
///
|
||||
/// `POST /device/enroll` [Bearer device:enroll]
|
||||
/// body { csr, keyAlg:'ec-p256', subdomain, deviceName, attestation? }
|
||||
/// → 201 { deviceId, cert, caChain, notBefore, notAfter, renewAfter }
|
||||
///
|
||||
/// `POST /device/attest/challenge` [Bearer device:enroll] → { challenge, expires_in }
|
||||
/// `POST /device/:id/renew` [Bearer device:enroll] (A6 seam)
|
||||
///
|
||||
/// Deliberately logic-free about TLS: it only builds requests and maps responses.
|
||||
/// The `EnrollmentTransport` seam (same `send(_:)` shape as the app's
|
||||
/// `HTTPTransport`) lets the App inject `URLSessionHTTPTransport` in production
|
||||
/// and a stub in tests. The `csr` is sent as standard base64(DER), which the
|
||||
/// server's `decodeCsrWire` accepts directly.
|
||||
public struct DeviceEnrollmentClient: Sendable {
|
||||
private let baseURL: URL
|
||||
/// The one-time account `device:enroll` bearer obtained at login.
|
||||
private let bearerToken: String
|
||||
private let transport: any EnrollmentTransport
|
||||
|
||||
public init(baseURL: URL, bearerToken: String, transport: any EnrollmentTransport) {
|
||||
self.baseURL = baseURL
|
||||
self.bearerToken = bearerToken
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
/// Enroll a freshly-generated hardware key: POST the CSR, receive the leaf.
|
||||
public func enroll(
|
||||
csrDER: Data, subdomain: String, deviceName: String, attestation: String? = nil
|
||||
) async throws -> EnrollmentResult {
|
||||
var body: [String: String] = [
|
||||
"csr": csrDER.base64EncodedString(),
|
||||
"keyAlg": "ec-p256",
|
||||
"subdomain": subdomain,
|
||||
"deviceName": deviceName,
|
||||
]
|
||||
if let attestation { body["attestation"] = attestation }
|
||||
let request = try makeJSONRequest(path: "/device/enroll", jsonObject: body)
|
||||
return try await sendExpectingLeaf(request)
|
||||
}
|
||||
|
||||
/// Renew against the SAME hardware key (A6 seam): a fresh CSR to
|
||||
/// `/device/:id/renew`. Server support lands in A6; the client shape is here
|
||||
/// so the rotation scheduler has an endpoint to drive.
|
||||
public func renew(deviceId: String, csrDER: Data) async throws -> EnrollmentResult {
|
||||
let body = ["csr": csrDER.base64EncodedString(), "keyAlg": "ec-p256"]
|
||||
let path = "/device/\(deviceId)/renew"
|
||||
let request = try makeJSONRequest(path: path, jsonObject: body)
|
||||
return try await sendExpectingLeaf(request)
|
||||
}
|
||||
|
||||
/// Fetch a short-TTL attestation challenge (stub server-side; shapes the
|
||||
/// keygen→attest→CSR flow so App Attest can layer in later — build order C·4).
|
||||
public func attestChallenge() async throws -> AttestChallenge {
|
||||
let request = try makeJSONRequest(path: "/device/attest/challenge", jsonObject: [:])
|
||||
let (data, response) = try await transport.send(request)
|
||||
guard response.statusCode == 200 else {
|
||||
throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
|
||||
}
|
||||
guard let dto = try? JSONDecoder().decode(AttestChallengeDTO.self, from: data) else {
|
||||
throw DeviceEnrollmentError.malformedResponse
|
||||
}
|
||||
return AttestChallenge(challenge: dto.challenge, expiresIn: dto.expires_in)
|
||||
}
|
||||
|
||||
// MARK: - Request/response plumbing
|
||||
|
||||
private func makeJSONRequest(path: String, jsonObject: [String: String]) throws -> URLRequest {
|
||||
guard let url = URL(string: path, relativeTo: baseURL) else {
|
||||
throw DeviceEnrollmentError.malformedResponse
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: jsonObject)
|
||||
return request
|
||||
}
|
||||
|
||||
private func sendExpectingLeaf(_ request: URLRequest) async throws -> EnrollmentResult {
|
||||
let (data, response) = try await transport.send(request)
|
||||
guard response.statusCode == 201 else {
|
||||
throw DeviceEnrollmentError.http(status: response.statusCode, code: errorCode(in: data))
|
||||
}
|
||||
guard let dto = try? JSONDecoder().decode(EnrollResponseDTO.self, from: data) else {
|
||||
throw DeviceEnrollmentError.malformedResponse
|
||||
}
|
||||
return try dto.toResult()
|
||||
}
|
||||
|
||||
private func errorCode(in data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(ErrorDTO.self, from: data))?.error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Transport seam
|
||||
|
||||
/// The one exchange the enrollment client needs — identical in shape to the
|
||||
/// app's `HTTPTransport.send`, so the production `URLSessionHTTPTransport` slots
|
||||
/// in via a one-line adapter and tests inject a stub. Kept local so `ClientTLS`
|
||||
/// stays a leaf package (no dependency on the WireProtocol transport contract).
|
||||
public protocol EnrollmentTransport: Sendable {
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse)
|
||||
}
|
||||
|
||||
// MARK: - Results & errors
|
||||
|
||||
public struct EnrollmentResult: Equatable, Sendable {
|
||||
public let deviceId: String
|
||||
/// Leaf certificate DER (decoded from the response's base64).
|
||||
public let certificate: Data
|
||||
/// Issuer chain DERs (device-CA etc.), leaf excluded.
|
||||
public let caChain: [Data]
|
||||
public let notBefore: Date?
|
||||
public let notAfter: Date?
|
||||
/// When to renew from the same hardware key (~2/3 of the lifetime).
|
||||
public let renewAfter: Date?
|
||||
|
||||
public init(
|
||||
deviceId: String, certificate: Data, caChain: [Data],
|
||||
notBefore: Date?, notAfter: Date?, renewAfter: Date?
|
||||
) {
|
||||
self.deviceId = deviceId
|
||||
self.certificate = certificate
|
||||
self.caChain = caChain
|
||||
self.notBefore = notBefore
|
||||
self.notAfter = notAfter
|
||||
self.renewAfter = renewAfter
|
||||
}
|
||||
|
||||
/// The rotation seam: is the leaf due for renewal as of `now`?
|
||||
/// A missing `renewAfter` never triggers (fail-safe — the TLS stack is the
|
||||
/// real gate; the scheduler only pre-empts expiry).
|
||||
public func isRenewalDue(asOf now: Date = Date()) -> Bool {
|
||||
guard let renewAfter else { return false }
|
||||
return now >= renewAfter
|
||||
}
|
||||
}
|
||||
|
||||
public struct AttestChallenge: Equatable, Sendable {
|
||||
public let challenge: String
|
||||
public let expiresIn: Int
|
||||
}
|
||||
|
||||
public enum DeviceEnrollmentError: Error, Equatable, Sendable {
|
||||
/// Non-success HTTP status with the server's uniform `{ error }` code, if any
|
||||
/// (401 missing/rejected token, 403 subdomain-not-owned, 429 rate_limited,
|
||||
/// 400 rejected CSR/subdomain).
|
||||
case http(status: Int, code: String?)
|
||||
/// 2xx body that did not decode to the expected shape.
|
||||
case malformedResponse
|
||||
}
|
||||
|
||||
// MARK: - Wire DTOs (base64 + ISO-8601 strings, mapped to typed values)
|
||||
|
||||
private struct EnrollResponseDTO: Decodable {
|
||||
let deviceId: String
|
||||
let cert: String
|
||||
let caChain: [String]
|
||||
let notBefore: String?
|
||||
let notAfter: String?
|
||||
let renewAfter: String?
|
||||
|
||||
func toResult() throws -> EnrollmentResult {
|
||||
guard let certificate = Data(base64Encoded: cert) else {
|
||||
throw DeviceEnrollmentError.malformedResponse
|
||||
}
|
||||
let chain = try caChain.map { entry -> Data in
|
||||
guard let der = Data(base64Encoded: entry) else {
|
||||
throw DeviceEnrollmentError.malformedResponse
|
||||
}
|
||||
return der
|
||||
}
|
||||
return EnrollmentResult(
|
||||
deviceId: deviceId,
|
||||
certificate: certificate,
|
||||
caChain: chain,
|
||||
notBefore: ISO8601.date(notBefore),
|
||||
notAfter: ISO8601.date(notAfter),
|
||||
renewAfter: ISO8601.date(renewAfter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AttestChallengeDTO: Decodable {
|
||||
let challenge: String
|
||||
let expires_in: Int // swiftlint:disable:this identifier_name — wire field name
|
||||
}
|
||||
|
||||
private struct ErrorDTO: Decodable {
|
||||
let error: String
|
||||
}
|
||||
|
||||
/// The server emits `Date.toISOString()` (fractional-second UTC). Parse with and
|
||||
/// without fractional seconds so both `...T00:00:00.000Z` and `...T00:00:00Z`
|
||||
/// decode; an unparseable/absent value degrades to `nil` (dates are advisory).
|
||||
private enum ISO8601 {
|
||||
static func date(_ text: String?) -> Date? {
|
||||
guard let text else { return nil }
|
||||
// Formatters are created per call: ISO8601DateFormatter is not Sendable,
|
||||
// so it cannot be a shared static under Swift 6 strict concurrency. Date
|
||||
// parsing here is rare (once per enroll/renew), so the cost is immaterial.
|
||||
let withFractional = ISO8601DateFormatter()
|
||||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let date = withFractional.date(from: text) { return date }
|
||||
return ISO8601DateFormatter().date(from: text)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,18 @@ private struct StoredP12Blob: Codable {
|
||||
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
|
||||
@@ -77,6 +89,10 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -88,6 +104,9 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -95,12 +114,196 @@ public struct KeychainClientIdentityStore: ClientIdentityStore {
|
||||
}
|
||||
|
||||
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] {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Foundation
|
||||
import Security
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS · Proves the manual PKCS#10 encoder produces a well-formed, self-signed
|
||||
// P-256 CSR that the control-plane `verifyCsrPoPEc` (id-ecPublicKey + prime256v1
|
||||
// SPKI, ecdsa-with-SHA256 self-signature) would accept. Runs headless with a
|
||||
// SOFTWARE P-256 SecKey (SecKeyCreateRandomKey WITHOUT the Secure-Enclave token)
|
||||
// so no hardware/entitlement is needed — the signing path is byte-identical to
|
||||
// the on-device SE key. Real SE keygen + SecIdentity roundtrip are device-only.
|
||||
|
||||
/// Software P-256 key via the SAME SecKey API used on-device (no SE token).
|
||||
private func makeSoftwareKey() throws -> SecureEnclaveKey {
|
||||
try SecureEnclaveKeyFactory.generateSoftware(tag: nil, permanent: false)
|
||||
}
|
||||
|
||||
@Test("CSR is a canonical PKCS#10 SEQUENCE of exactly three elements")
|
||||
func csrOuterStructure() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
|
||||
// Assert — outer CertificationRequest ::= SEQUENCE { info, algId, sig }.
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
#expect(outer.tag == 0x30)
|
||||
#expect(outer.end == bytes.count) // no trailing garbage
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
#expect(parts.count == 3)
|
||||
#expect(parts[0].tag == 0x30) // certificationRequestInfo
|
||||
#expect(parts[1].tag == 0x30) // signatureAlgorithm
|
||||
#expect(parts[2].tag == 0x03) // signature BIT STRING
|
||||
}
|
||||
|
||||
@Test("CSR self-signature verifies against the embedded P-256 public key")
|
||||
func csrSelfSignatureVerifies() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
let expectedPoint = try signer.publicKeyX963()
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
|
||||
// Extract the exact CertificationRequestInfo bytes that were signed and the
|
||||
// ECDSA signature (the same crypto check `verifyCsrPoPEc`'s req.verify() runs).
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let parts = TestDER.children(bytes, outer)
|
||||
let infoBytes = Data(bytes[parts[0].start..<parts[0].end])
|
||||
let sigContent = parts[2] // BIT STRING: first content byte is unused-bits (0x00)
|
||||
let signature = Data(bytes[(sigContent.valueStart + 1)..<sigContent.valueEnd])
|
||||
|
||||
// Rebuild the public SecKey from the X9.63 point and verify.
|
||||
let publicKey = try #require(makePublicKey(fromX963: expectedPoint))
|
||||
var error: Unmanaged<CFError>?
|
||||
let ok = SecKeyVerifySignature(
|
||||
publicKey,
|
||||
.ecdsaSignatureMessageX962SHA256,
|
||||
infoBytes as CFData,
|
||||
signature as CFData,
|
||||
&error
|
||||
)
|
||||
#expect(ok, "self-signature must verify: \(String(describing: error?.takeRetainedValue()))")
|
||||
}
|
||||
|
||||
@Test("CSR embeds a P-256 SubjectPublicKeyInfo the server verifier accepts")
|
||||
func csrEmbedsP256Spki() throws {
|
||||
// Arrange
|
||||
let signer = try makeSoftwareKey()
|
||||
let point = [UInt8](try signer.publicKeyX963())
|
||||
|
||||
// Act
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
|
||||
// certificationRequestInfo → { version, subject, subjectPKInfo, [0] attrs }
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let info = TestDER.children(bytes, outer)[0]
|
||||
let infoChildren = TestDER.children(bytes, info)
|
||||
#expect(infoChildren.count == 4)
|
||||
#expect(Array(bytes[infoChildren[0].start..<infoChildren[0].end]) == [0x02, 0x01, 0x00]) // v1(0)
|
||||
#expect(infoChildren[3].tag == 0xA0) // [0] IMPLICIT attributes
|
||||
#expect(infoChildren[3].valueEnd - infoChildren[3].valueStart == 0) // empty SET
|
||||
|
||||
// subjectPublicKeyInfo ::= SEQUENCE { AlgorithmIdentifier, BIT STRING point }
|
||||
let spki = infoChildren[2]
|
||||
let spkiChildren = TestDER.children(bytes, spki)
|
||||
#expect(spkiChildren.count == 2)
|
||||
let algIdChildren = TestDER.children(bytes, spkiChildren[0])
|
||||
// AlgorithmIdentifier { id-ecPublicKey, prime256v1 } — the exact OIDs
|
||||
// verifyCsrPoPEc pins.
|
||||
#expect(Array(bytes[algIdChildren[0].start..<algIdChildren[0].end])
|
||||
== [0x06, 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01])
|
||||
#expect(Array(bytes[algIdChildren[1].start..<algIdChildren[1].end])
|
||||
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07])
|
||||
// BIT STRING content = 0x00 unused-bits + the exact 65-byte point.
|
||||
let bitString = spkiChildren[1]
|
||||
#expect(bitString.tag == 0x03)
|
||||
#expect(bytes[bitString.valueStart] == 0x00)
|
||||
#expect(Array(bytes[(bitString.valueStart + 1)..<bitString.valueEnd]) == point)
|
||||
}
|
||||
|
||||
@Test("signatureAlgorithm is ecdsa-with-SHA256")
|
||||
func csrSignatureAlgorithm() throws {
|
||||
let signer = try makeSoftwareKey()
|
||||
let der = try CertificateSigningRequest.der(
|
||||
subjectCommonName: "web-terminal-device", signer: signer
|
||||
)
|
||||
let bytes = [UInt8](der)
|
||||
let outer = try #require(TestDER.read(bytes, at: 0))
|
||||
let algId = TestDER.children(bytes, outer)[1]
|
||||
let oid = TestDER.children(bytes, algId)[0]
|
||||
#expect(Array(bytes[oid.start..<oid.end])
|
||||
== [0x06, 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02])
|
||||
}
|
||||
|
||||
@Test("empty subject CN is rejected")
|
||||
func csrRejectsEmptySubject() throws {
|
||||
let signer = try makeSoftwareKey()
|
||||
#expect(throws: CertificateSigningRequest.CSRError.invalidSubject) {
|
||||
_ = try CertificateSigningRequest.der(subjectCommonName: "", signer: signer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - helpers
|
||||
|
||||
/// Reconstruct a public SecKey from an X9.63 uncompressed point for verification.
|
||||
private func makePublicKey(fromX963 point: Data) -> SecKey? {
|
||||
let attributes: [String: Any] = [
|
||||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||||
kSecAttrKeyClass as String: kSecAttrKeyClassPublic,
|
||||
kSecAttrKeySizeInBits as String: 256,
|
||||
]
|
||||
return SecKeyCreateWithData(point as CFData, attributes as CFDictionary, nil)
|
||||
}
|
||||
|
||||
/// A throwaway DER reader for assertions (production parsing lives in
|
||||
/// CertificateSummary's X509 walk; this mirrors it for tests).
|
||||
enum TestDER {
|
||||
struct Element {
|
||||
let tag: UInt8
|
||||
let start: Int // index of the tag byte
|
||||
let valueStart: Int
|
||||
let valueEnd: Int
|
||||
var end: Int { valueEnd }
|
||||
}
|
||||
|
||||
static func read(_ bytes: [UInt8], at start: Int) -> Element? {
|
||||
guard start >= 0, start + 1 < bytes.count else { return nil }
|
||||
let tag = bytes[start]
|
||||
var index = start + 1
|
||||
let first = bytes[index]
|
||||
index += 1
|
||||
var length = 0
|
||||
if first & 0x80 == 0 {
|
||||
length = Int(first)
|
||||
} else {
|
||||
let count = Int(first & 0x7F)
|
||||
guard count > 0, count <= 4, index + count <= bytes.count else { return nil }
|
||||
for _ in 0..<count {
|
||||
length = (length << 8) | Int(bytes[index])
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
let valueEnd = index + length
|
||||
guard valueEnd <= bytes.count else { return nil }
|
||||
return Element(tag: tag, start: start, valueStart: index, valueEnd: valueEnd)
|
||||
}
|
||||
|
||||
static func children(_ bytes: [UInt8], _ parent: Element) -> [Element] {
|
||||
var elements: [Element] = []
|
||||
var index = parent.valueStart
|
||||
while index < parent.valueEnd, let element = read(bytes, at: index) {
|
||||
elements.append(element)
|
||||
index = element.valueEnd
|
||||
}
|
||||
return elements
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ClientTLS
|
||||
|
||||
// C-iOS · DeviceEnrollmentClient request-building + response-mapping, driven by a
|
||||
// stub transport (no network). Mirrors the A4 contract exactly.
|
||||
|
||||
private let baseURL = URL(string: "https://cp.terminal.yaojia.wang")!
|
||||
private let bearer = "device-enroll-token-abc"
|
||||
|
||||
/// Records the last request and replays a canned response. `@unchecked Sendable`:
|
||||
/// the mutable capture is guarded by a lock.
|
||||
private final class StubTransport: EnrollmentTransport, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var _lastRequest: URLRequest?
|
||||
private let status: Int
|
||||
private let body: Data
|
||||
|
||||
init(status: Int, body: Data) {
|
||||
self.status = status
|
||||
self.body = body
|
||||
}
|
||||
|
||||
var lastRequest: URLRequest? { lock.withLock { _lastRequest } }
|
||||
|
||||
func send(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
||||
lock.withLock { _lastRequest = request }
|
||||
let response = HTTPURLResponse(
|
||||
url: request.url!, statusCode: status, httpVersion: "HTTP/1.1", headerFields: nil
|
||||
)!
|
||||
return (body, response)
|
||||
}
|
||||
}
|
||||
|
||||
private func enrollBody(
|
||||
deviceId: String = "dev-1",
|
||||
cert: Data = Data([0x30, 0x01, 0x02]),
|
||||
caChain: [Data] = [Data([0x30, 0xAA])],
|
||||
notBefore: String = "2026-07-08T00:00:00.000Z",
|
||||
notAfter: String = "2026-10-06T00:00:00.000Z",
|
||||
renewAfter: String = "2026-09-05T00:00:00.000Z"
|
||||
) -> Data {
|
||||
let json: [String: Any] = [
|
||||
"deviceId": deviceId,
|
||||
"cert": cert.base64EncodedString(),
|
||||
"caChain": caChain.map { $0.base64EncodedString() },
|
||||
"notBefore": notBefore,
|
||||
"notAfter": notAfter,
|
||||
"renewAfter": renewAfter,
|
||||
]
|
||||
return try! JSONSerialization.data(withJSONObject: json)
|
||||
}
|
||||
|
||||
@Test("enroll builds a bearer-authenticated POST /device/enroll with the A4 body")
|
||||
func enrollBuildsRequest() async throws {
|
||||
// Arrange
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
let csr = Data([0xDE, 0xAD, 0xBE, 0xEF])
|
||||
|
||||
// Act
|
||||
_ = try await client.enroll(csrDER: csr, subdomain: "alice", deviceName: "Alice iPhone")
|
||||
|
||||
// Assert
|
||||
let request = try #require(stub.lastRequest)
|
||||
#expect(request.httpMethod == "POST")
|
||||
#expect(request.url?.absoluteString == "https://cp.terminal.yaojia.wang/device/enroll")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer \(bearer)")
|
||||
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
|
||||
|
||||
let sent = try #require(request.httpBody)
|
||||
let object = try #require(
|
||||
try JSONSerialization.jsonObject(with: sent) as? [String: Any]
|
||||
)
|
||||
#expect(object["csr"] as? String == csr.base64EncodedString())
|
||||
#expect(object["keyAlg"] as? String == "ec-p256")
|
||||
#expect(object["subdomain"] as? String == "alice")
|
||||
#expect(object["deviceName"] as? String == "Alice iPhone")
|
||||
#expect(object["attestation"] == nil) // omitted when not provided
|
||||
}
|
||||
|
||||
@Test("enroll maps a 201 response into a typed EnrollmentResult")
|
||||
func enrollMapsResponse() async throws {
|
||||
// Arrange
|
||||
let cert = Data([0x30, 0x82, 0x01, 0x23])
|
||||
let ca = Data([0x30, 0x82, 0x02, 0x00])
|
||||
let stub = StubTransport(status: 201, body: enrollBody(deviceId: "dev-xyz", cert: cert, caChain: [ca]))
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
|
||||
// Act
|
||||
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "alice", deviceName: "iPhone")
|
||||
|
||||
// Assert
|
||||
#expect(result.deviceId == "dev-xyz")
|
||||
#expect(result.certificate == cert)
|
||||
#expect(result.caChain == [ca])
|
||||
#expect(result.notAfter != nil)
|
||||
#expect(result.renewAfter != nil)
|
||||
// renewAfter (2026-09-05) is before notAfter (2026-10-06).
|
||||
#expect(result.renewAfter! < result.notAfter!)
|
||||
}
|
||||
|
||||
@Test("isRenewalDue flips at renewAfter")
|
||||
func renewalDueSeam() async throws {
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
let result = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
|
||||
|
||||
let before = ISO8601DateFormatter().date(from: "2026-09-04T00:00:00Z")!
|
||||
let after = ISO8601DateFormatter().date(from: "2026-09-06T00:00:00Z")!
|
||||
#expect(result.isRenewalDue(asOf: before) == false)
|
||||
#expect(result.isRenewalDue(asOf: after) == true)
|
||||
}
|
||||
|
||||
@Test("attestation passphrase is forwarded when provided")
|
||||
func enrollForwardsAttestation() async throws {
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
_ = try await client.enroll(
|
||||
csrDER: Data([0x01]), subdomain: "a", deviceName: "d", attestation: "attest-blob"
|
||||
)
|
||||
let object = try JSONSerialization.jsonObject(
|
||||
with: stub.lastRequest!.httpBody!
|
||||
) as! [String: Any]
|
||||
#expect(object["attestation"] as? String == "attest-blob")
|
||||
}
|
||||
|
||||
@Test("a non-201 response throws http with the server error code")
|
||||
func enrollRejectSurfacesStatus() async throws {
|
||||
// 403 subdomain-not-owned → { error: "rejected" }.
|
||||
let body = try! JSONSerialization.data(withJSONObject: ["error": "rejected"])
|
||||
let stub = StubTransport(status: 403, body: body)
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
|
||||
await #expect(throws: DeviceEnrollmentError.http(status: 403, code: "rejected")) {
|
||||
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "bob", deviceName: "d")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("a malformed 201 body throws malformedResponse")
|
||||
func enrollMalformedBody() async throws {
|
||||
let stub = StubTransport(status: 201, body: Data("not json".utf8))
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
await #expect(throws: DeviceEnrollmentError.malformedResponse) {
|
||||
_ = try await client.enroll(csrDER: Data([0x01]), subdomain: "a", deviceName: "d")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("attestChallenge maps a 200 challenge response")
|
||||
func attestChallengeMaps() async throws {
|
||||
let body = try! JSONSerialization.data(
|
||||
withJSONObject: ["challenge": "Y2hhbGxlbmdl", "expires_in": 120]
|
||||
)
|
||||
let stub = StubTransport(status: 200, body: body)
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
|
||||
let challenge = try await client.attestChallenge()
|
||||
#expect(challenge.challenge == "Y2hhbGxlbmdl")
|
||||
#expect(challenge.expiresIn == 120)
|
||||
#expect(stub.lastRequest?.url?.absoluteString
|
||||
== "https://cp.terminal.yaojia.wang/device/attest/challenge")
|
||||
}
|
||||
|
||||
@Test("renew targets POST /device/:id/renew (A6 seam)")
|
||||
func renewTargetsRenewEndpoint() async throws {
|
||||
let stub = StubTransport(status: 201, body: enrollBody())
|
||||
let client = DeviceEnrollmentClient(baseURL: baseURL, bearerToken: bearer, transport: stub)
|
||||
_ = try await client.renew(deviceId: "dev-9", csrDER: Data([0x02]))
|
||||
#expect(stub.lastRequest?.url?.absoluteString
|
||||
== "https://cp.terminal.yaojia.wang/device/dev-9/renew")
|
||||
}
|
||||
Reference in New Issue
Block a user