From 36107018a89c4cac5846eb8c1a8b992ad62424c5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 14 Jun 2026 17:47:19 +0000 Subject: [PATCH] =?UTF-8?q?feat(apple/library):=20mTLS=20=E2=80=94=20authe?= =?UTF-8?q?nticate=20by=20the=20paired=20identity,=20drop=20the=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: the Apple library now talks to the host's HTTPS mgmt API (b4a85a8) over mTLS using this client's persistent identity — the SAME cert the host paired over QUIC — so there is NO manual token anymore. - ClientTLS: builds a SecIdentity from the stored PEM (CryptoKit parses the rcgen P-256 PKCS#8 key → x963 → SecKey; the cert PEM → SecCertificate; SecIdentityCreateWithCertificate pairs them via the Keychain). macOS-only for now (that API is unavailable on iOS — a PKCS#12 path would be needed there; the client is macOS-first). - LibraryTLSDelegate: pins the host's self-signed cert by the fingerprint the client already trusts, and presents the identity for the client-cert challenge. - LibraryClient.fetch now does GET https://…/library with the identity + host fingerprint; the whole connection form (port + token) and StoredHost.mgmtToken/setMgmt are gone — the library "just works" for a paired host. 401 → "pair with the host first". Can't compile Swift on the Linux box; CI (apple.yml) compiles the macOS path incl. the Security/CryptoKit code. Runtime (SecIdentity build + the mTLS handshake) needs Mac validation. Pairs with the host mTLS already landed + live-tested. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/PunktfunkClient/HostStore.swift | 19 +-- .../Sources/PunktfunkClient/LibraryView.swift | 96 ++---------- .../Sources/PunktfunkKit/ClientTLS.swift | 142 ++++++++++++++++++ .../Sources/PunktfunkKit/LibraryClient.swift | 40 +++-- 4 files changed, 190 insertions(+), 107 deletions(-) create mode 100644 clients/apple/Sources/PunktfunkKit/ClientTLS.swift diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index 8f57f4a..cd2975b 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -21,14 +21,11 @@ struct StoredHost: Identifiable, Codable, Hashable { var pinnedSHA256: Data? /// Last time a streaming session actually started (nil until the first one). var lastConnected: Date? - /// Management-API port for the experimental library browser (distinct from the data-plane - /// `port`). Optional (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks - /// this key — still decode: synthesized Decodable ignores property defaults but treats a - /// missing Optional as nil. Resolve via `effectiveMgmtPort`. + /// Management-API port for the library browser (distinct from the data-plane `port`). Optional + /// (NOT a defaulted non-optional) so older saved hosts — whose JSON lacks this key — still + /// decode: synthesized Decodable ignores property defaults but treats a missing Optional as + /// nil. Resolve via `effectiveMgmtPort`. (Auth is mTLS by the pinned identity — no token.) var mgmtPort: UInt16? - /// Bearer token for the management API (the host's `--mgmt-token`). Required for any - /// non-loopback mgmt bind; nil until the user enters it. - var mgmtToken: String? var displayName: String { name.isEmpty ? address : name } var effectiveMgmtPort: UInt16 { mgmtPort ?? punktfunkDefaultMgmtPort } @@ -96,14 +93,6 @@ final class HostStore: ObservableObject { hosts[i].pinnedSHA256 = nil } - /// Persist the management-API endpoint for the (experimental) library browser. An empty - /// token is stored as nil (no credential). - func setMgmt(_ hostID: UUID, port: UInt16, token: String) { - guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } - hosts[i].mgmtPort = port - let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) - hosts[i].mgmtToken = trimmed.isEmpty ? nil : trimmed - } private func persist() { if let data = try? JSONEncoder().encode(hosts) { diff --git a/clients/apple/Sources/PunktfunkClient/LibraryView.swift b/clients/apple/Sources/PunktfunkClient/LibraryView.swift index eb6eb26..753c98f 100644 --- a/clients/apple/Sources/PunktfunkClient/LibraryView.swift +++ b/clients/apple/Sources/PunktfunkClient/LibraryView.swift @@ -16,10 +16,6 @@ struct LibraryView: View { @State private var games: [GameEntry] = [] @State private var loading = false @State private var errorText: String? - @State private var showConfig = false - // Connection form state, seeded from the saved host. - @State private var portText: String = "" - @State private var tokenText: String = "" var body: some View { content @@ -29,20 +25,12 @@ struct LibraryView: View { #endif .toolbar { #if os(macOS) - ToolbarItemGroup { - connectionButton - reloadButton - } + ToolbarItemGroup { reloadButton } #else ToolbarItem(placement: .primaryAction) { reloadButton } - ToolbarItem(placement: .cancellationAction) { connectionButton } #endif } - .sheet(isPresented: $showConfig) { connectionSheet } - .task { - seedForm() - await load() - } + .task { await load() } } @ViewBuilder private var content: some View { @@ -92,7 +80,7 @@ struct LibraryView: View { .multilineTextAlignment(.center) .foregroundStyle(.secondary) .frame(maxWidth: 420) - Button("Connection Settings…") { showConfig = true } + Button("Retry") { Task { await load() } } .buttonStyle(.borderedProminent) } .padding() @@ -117,81 +105,29 @@ struct LibraryView: View { .disabled(loading) } - private var connectionButton: some View { - Button { showConfig = true } label: { - Label("Connection", systemImage: "network") - } - } - - private var connectionSheet: some View { - NavigationStack { - Form { - Section { - LabeledContent("Host") { Text(host.address) } - TextField("Management port", text: $portText) - #if !os(macOS) - .keyboardType(.numberPad) - #endif - TextField("Management token", text: $tokenText) - .autocorrectionDisabled(true) - #if !os(macOS) - .textInputAutocapitalization(.never) - #endif - } header: { - Text("Management API") - } footer: { - Text("The host must expose its management API on the LAN: " - + "`serve --mgmt-bind 0.0.0.0 --mgmt-token `. The default port " - + "is \(punktfunkDefaultMgmtPort). Enter the same token here.") - } - } - .navigationTitle("Library Connection") - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Save") { - let port = UInt16(portText) ?? punktfunkDefaultMgmtPort - store.setMgmt(host.id, port: port, token: tokenText) - showConfig = false - Task { await load() } - } - } - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { showConfig = false } - } - } - } - } - - private func seedForm() { - // Always reflect the latest saved values (the host snapshot may predate a setMgmt). - let current = store.hosts.first { $0.id == host.id } ?? host - portText = String(current.effectiveMgmtPort) - tokenText = current.mgmtToken ?? "" - } - private func load() async { loading = true errorText = nil let current = store.hosts.first { $0.id == host.id } ?? host + // mTLS uses this client's persistent identity (the host paired it over QUIC). No identity + // yet → the user hasn't connected/paired, which is also when there's nothing to browse. + guard let identity = (try? ClientIdentityStore.shared.load())?.identity else { + games = [] + errorText = "Connect to this host once first — the library uses the identity created " + + "on pairing to authenticate." + loading = false + return + } do { games = try await LibraryClient.fetch( address: current.address, port: current.effectiveMgmtPort, - token: current.mgmtToken) + certPEM: identity.certPEM, + keyPEM: identity.keyPEM, + hostFingerprint: current.pinnedSHA256) } catch { games = [] - if let libError = error as? LibraryError { - errorText = libError.errorDescription - // Token rejected — drop the user straight into the connection form. - if case .unauthorized = libError { showConfig = true } - } else { - errorText = error.localizedDescription - } - // No credential entered yet → also straight to setup. - if current.mgmtToken == nil { showConfig = true } + errorText = (error as? LibraryError)?.errorDescription ?? error.localizedDescription } loading = false } diff --git a/clients/apple/Sources/PunktfunkKit/ClientTLS.swift b/clients/apple/Sources/PunktfunkKit/ClientTLS.swift new file mode 100644 index 0000000..f0e0b35 --- /dev/null +++ b/clients/apple/Sources/PunktfunkKit/ClientTLS.swift @@ -0,0 +1,142 @@ +// mTLS for the management REST API. The host now serves the API over HTTPS and authorizes a +// request whose client certificate is in its paired store (host commit b4a85a8) — the SAME +// identity + trust the QUIC data plane uses — so a paired client needs no bearer token. +// +// To present that identity, URLSession needs a SecIdentity (cert + private key pair). The client +// stores its identity as PEM (rcgen ECDSA P-256, PKCS#8 key). We rebuild a SecIdentity natively: +// CryptoKit parses the key → its X9.63 form → a SecKey, the cert PEM → a SecCertificate, and +// SecIdentityCreateWithCertificate pairs them via the Keychain. This is macOS-only +// (SecIdentityCreateWithCertificate is unavailable on iOS — that path will need a PKCS#12); the +// client library is macOS-first today. + +import CryptoKit +import Foundation +import Security +import os + +private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-tls") + +enum ClientTLS { + enum TLSError: LocalizedError { + case unsupportedPlatform + case badKey(String) + case badCert + case identity(String) + + var errorDescription: String? { + switch self { + case .unsupportedPlatform: + return "Library mTLS is supported on macOS only right now." + case .badKey(let why): return "Couldn't load the client key: \(why)" + case .badCert: return "Couldn't load the client certificate." + case .identity(let why): return "Couldn't build the client identity: \(why)" + } + } + } + + /// First PEM block of `type` ("CERTIFICATE" / "PRIVATE KEY") → its DER bytes. + private static func derFromPEM(_ pem: String, type: String) -> Data? { + guard let start = pem.range(of: "-----BEGIN \(type)-----"), + let end = pem.range(of: "-----END \(type)-----", range: start.upperBound.. SecIdentity { + #if os(macOS) + // Key: CryptoKit accepts the SEC1 or PKCS#8 PEM; its x963 form is what SecKey wants. + let priv: P256.Signing.PrivateKey + do { + priv = try P256.Signing.PrivateKey(pemRepresentation: keyPEM) + } catch { + throw TLSError.badKey(error.localizedDescription) + } + var keyError: Unmanaged? + let attrs: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeyClass: kSecAttrKeyClassPrivate, + kSecAttrKeySizeInBits: 256, + ] + guard let secKey = SecKeyCreateWithData( + priv.x963Representation as CFData, attrs as CFDictionary, &keyError) + else { + throw TLSError.badKey((keyError?.takeRetainedValue()).map { "\($0)" } ?? "SecKeyCreateWithData") + } + + guard let certDER = derFromPEM(certPEM, type: "CERTIFICATE"), + let cert = SecCertificateCreateWithData(nil, certDER as CFData) + else { throw TLSError.badCert } + + // The key must live in a Keychain for SecIdentityCreateWithCertificate to pair it with the + // cert. Add it under a stable tag; a duplicate just means a previous fetch already did. + let tag = Data("io.unom.punktfunk.library-client-key".utf8) + let add: [CFString: Any] = [ + kSecClass: kSecClassKey, + kSecAttrApplicationTag: tag, + kSecValueRef: secKey, + ] + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess || status == errSecDuplicateItem else { + throw TLSError.identity("keychain add failed (OSStatus \(status))") + } + + var identity: SecIdentity? + let idStatus = SecIdentityCreateWithCertificate(nil, cert, &identity) + guard idStatus == errSecSuccess, let identity else { + throw TLSError.identity("SecIdentityCreateWithCertificate (OSStatus \(idStatus))") + } + return identity + #else + throw TLSError.unsupportedPlatform + #endif + } +} + +/// URLSession delegate that pins the host's self-signed cert (by the fingerprint the client +/// already trusts) and presents the client identity for the mTLS client-cert challenge. +final class LibraryTLSDelegate: NSObject, URLSessionDelegate { + private let identity: SecIdentity + private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU) + + init(identity: SecIdentity, pinnedHostFingerprint: Data?) { + self.identity = identity + self.pinnedHostFingerprint = pinnedHostFingerprint + } + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + switch challenge.protectionSpace.authenticationMethod { + case NSURLAuthenticationMethodServerTrust: + // Pin the host cert by fingerprint — the host is self-signed (the client trusts it the + // same way the QUIC session does). No pin yet (TOFU) → accept the presented leaf. + guard let trust = challenge.protectionSpace.serverTrust, + let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first + else { + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + let der = SecCertificateCopyData(leaf) as Data + let fp = Data(SHA256.hash(data: der)) + if let pinned = pinnedHostFingerprint, pinned != fp { + tlsLog.warning("library: host cert fingerprint mismatch — refusing") + completionHandler(.cancelAuthenticationChallenge, nil) + return + } + completionHandler(.useCredential, URLCredential(trust: trust)) + + case NSURLAuthenticationMethodClientCertificate: + completionHandler(.useCredential, + URLCredential(identity: identity, certificates: nil, persistence: .forSession)) + + default: + completionHandler(.performDefaultHandling, nil) + } + } +} diff --git a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift index 63e5faf..cd01c0d 100644 --- a/clients/apple/Sources/PunktfunkKit/LibraryClient.swift +++ b/clients/apple/Sources/PunktfunkKit/LibraryClient.swift @@ -42,7 +42,7 @@ public struct GameEntry: Codable, Hashable, Identifiable, Sendable { public var isCustom: Bool { store == "custom" } } -/// Errors surfaced to the UI so it can guide setup (the common case is "needs a token"). +/// Errors surfaced to the UI so it can guide setup (the common case is "not paired yet"). public enum LibraryError: LocalizedError { case unauthorized case http(Int) @@ -51,13 +51,13 @@ public enum LibraryError: LocalizedError { public var errorDescription: String? { switch self { case .unauthorized: - return "The host's management API rejected the token. Start the host with " - + "--mgmt-token and enter the same token here." + return "The host didn't recognize this device. Pair with the host first — it " + + "authorizes paired clients by their certificate (no token needed)." case .http(let code): return "The management API returned HTTP \(code)." case .unreachable(let why): - return "Couldn't reach the management API: \(why). The host must expose it on the " - + "LAN (serve --mgmt-bind 0.0.0.0 --mgmt-token …)." + return "Couldn't reach the host's management API: \(why). The host must expose it on " + + "the LAN (serve --mgmt-bind 0.0.0.0)." } } } @@ -68,20 +68,36 @@ public let punktfunkDefaultMgmtPort: UInt16 = 47990 /// Stateless fetcher for a host's library. public enum LibraryClient { - /// `GET http://
:/api/v1/library` with an optional bearer token. + /// `GET https://
:/api/v1/library`, authenticated by **mTLS**: the client + /// presents `identity` (its persistent cert/key PEM — the same identity the host paired over + /// QUIC), and the host's self-signed cert is pinned by `hostFingerprint` (SHA-256 of its DER, + /// the value the client already trusts). No bearer token — a paired client is authorized by + /// its certificate. `hostFingerprint == nil` ⇒ TOFU (accept the presented host cert). public static func fetch( - address: String, port: UInt16 = punktfunkDefaultMgmtPort, token: String? = nil + address: String, + port: UInt16 = punktfunkDefaultMgmtPort, + certPEM: String, + keyPEM: String, + hostFingerprint: Data? ) async throws -> [GameEntry] { - guard let url = URL(string: "http://\(address):\(port)/api/v1/library") else { + guard let url = URL(string: "https://\(address):\(port)/api/v1/library") else { throw LibraryError.unreachable("invalid host address") } - var req = URLRequest(url: url, timeoutInterval: 10) - if let token, !token.isEmpty { - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + let identity: SecIdentity + do { + identity = try ClientTLS.makeIdentity(certPEM: certPEM, keyPEM: keyPEM) + } catch { + throw LibraryError.unreachable( + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription) } + let delegate = LibraryTLSDelegate(identity: identity, pinnedHostFingerprint: hostFingerprint) + let session = URLSession(configuration: .ephemeral, delegate: delegate, delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + + let req = URLRequest(url: url, timeoutInterval: 10) let (data, response): (Data, URLResponse) do { - (data, response) = try await URLSession.shared.data(for: req) + (data, response) = try await session.data(for: req) } catch { throw LibraryError.unreachable(error.localizedDescription) }