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:
@@ -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