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

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:
2026-06-14 17:47:19 +00:00
parent b4a85a8610
commit 36107018a8
4 changed files with 190 additions and 107 deletions
@@ -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
}