feat(apple/library): mTLS — authenticate by the paired identity, drop the token
apple / swift (push) Successful in 1m16s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 29s
ci / bench (push) Successful in 1m40s
ci / rust (push) Successful in 6m42s
deb / build-publish (push) Successful in 3m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
apple / swift (push) Successful in 1m16s
ci / web (push) Successful in 28s
ci / docs-site (push) Successful in 29s
ci / bench (push) Successful in 1m40s
ci / rust (push) Successful in 6m42s
deb / build-publish (push) Successful in 3m50s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m16s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 5m22s
docker / deploy-docs (push) Successful in 17s
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 <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
|
||||
}
|
||||
|
||||
@@ -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..<pem.endIndex)
|
||||
else { return nil }
|
||||
let b64 = pem[start.upperBound..<end.lowerBound]
|
||||
.components(separatedBy: .whitespacesAndNewlines).joined()
|
||||
return Data(base64Encoded: b64)
|
||||
}
|
||||
|
||||
/// Build a `SecIdentity` from the client's PEM cert + PKCS#8 P-256 key. Pairs them via the
|
||||
/// Keychain (the key is stored once under a stable tag, so repeat calls reuse it).
|
||||
static func makeIdentity(certPEM: String, keyPEM: String) throws -> 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<CFError>?
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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://<address>:<port>/api/v1/library` with an optional bearer token.
|
||||
/// `GET https://<address>:<port>/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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user