feat(apple): gamepad ui
apple / swift (push) Successful in 1m5s
audit / cargo-audit (push) Successful in 1m19s
android / android (push) Successful in 4m21s
ci / web (push) Successful in 58s
ci / docs-site (push) Successful in 58s
ci / rust (push) Successful in 8m40s
release / apple (push) Successful in 9m10s
ci / bench (push) Successful in 4m39s
decky / build-publish (push) Successful in 14s
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 29s
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 5s
deb / build-publish (push) Successful in 3m50s
apple / screenshots (push) Successful in 5m38s
flatpak / build-publish (push) Successful in 4m12s
windows-host / package (push) Successful in 19m17s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m15s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m5s
docker / deploy-docs (push) Successful in 18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m1s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m53s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m24s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m30s

This commit is contained in:
2026-07-01 15:14:19 +02:00
parent c8be614d9a
commit ecbbff5544
22 changed files with 1782 additions and 74 deletions
@@ -4,10 +4,16 @@
//
// 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.
// CryptoKit parses the key its X9.63 form a SecKey, the cert PEM a SecCertificate. From
// there the two platform families diverge because `SecIdentityCreateWithCertificate` the
// straight-line "pair these two" API is macOS-only:
// - macOS: SecIdentityCreateWithCertificate does the pairing directly once the key is in the
// Keychain (a plain `SecItemAdd`).
// - iOS/tvOS: that API is unavailable. Instead, add BOTH the key and the certificate to the
// Keychain (under the same application tag) and query `kSecClassIdentity` the system
// correlates a stored cert against a stored key with a matching public key and vends the pair
// as one `SecIdentity`, no PKCS#12 needed. This is the standard non-macOS technique for
// "I already have a raw cert + key, not a .p12".
import CryptoKit
import Foundation
@@ -18,15 +24,12 @@ private let tlsLog = Logger(subsystem: "io.unom.punktfunk", category: "library-t
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)"
@@ -45,9 +48,8 @@ enum ClientTLS {
}
/// 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).
/// Keychain (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 {
@@ -71,9 +73,11 @@ enum ClientTLS {
let cert = SecCertificateCreateWithData(nil, certDER as CFData)
else { throw TLSError.badCert }
let tag = Data("io.unom.punktfunk.library-client-key".utf8)
#if os(macOS)
// 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,
@@ -81,7 +85,7 @@ enum ClientTLS {
]
let status = SecItemAdd(add as CFDictionary, nil)
guard status == errSecSuccess || status == errSecDuplicateItem else {
throw TLSError.identity("keychain add failed (OSStatus \(status))")
throw TLSError.identity("keychain add key failed (OSStatus \(status))")
}
var identity: SecIdentity?
@@ -91,20 +95,64 @@ enum ClientTLS {
}
return identity
#else
throw TLSError.unsupportedPlatform
// Add the key (tagged) and the certificate (matched to it by public key) separately
// a duplicate of either just means a previous fetch already added it.
let addKey: [CFString: Any] = [
kSecClass: kSecClassKey,
kSecAttrApplicationTag: tag,
kSecValueRef: secKey,
]
let keyStatus = SecItemAdd(addKey as CFDictionary, nil)
guard keyStatus == errSecSuccess || keyStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add key failed (OSStatus \(keyStatus))")
}
let addCert: [CFString: Any] = [
kSecClass: kSecClassCertificate,
kSecValueRef: cert,
]
let certStatus = SecItemAdd(addCert as CFDictionary, nil)
guard certStatus == errSecSuccess || certStatus == errSecDuplicateItem else {
throw TLSError.identity("keychain add certificate failed (OSStatus \(certStatus))")
}
// The system correlates the just-added cert against the tagged key (matching public key)
// and vends the pair as a kSecClassIdentity the tag filter here matches the KEY half.
var identityRef: CFTypeRef?
let query: [CFString: Any] = [
kSecClass: kSecClassIdentity,
kSecAttrApplicationTag: tag,
kSecReturnRef: true,
]
let idStatus = SecItemCopyMatching(query as CFDictionary, &identityRef)
guard idStatus == errSecSuccess, let identityRef else {
throw TLSError.identity("SecItemCopyMatching(kSecClassIdentity) (OSStatus \(idStatus))")
}
// Safe: a kSecClassIdentity query with kSecReturnRef always vends a SecIdentity.
return (identityRef as! SecIdentity) // swiftlint:disable:this force_cast
#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.
/// already trusts) and presents the client identity for the mTLS client-cert challenge but ONLY
/// for challenges from `host`:`port` (the punktfunk host itself). A session built with this
/// delegate is safe to reuse for OTHER origins too (e.g. a GOG/Heroic/Xbox cover-art CDN): a
/// non-matching origin falls through to `.performDefaultHandling`, i.e. normal system trust
/// evaluation and no client cert exactly what `URLSession.shared` would have done. Without the
/// host scoping, pinning would reject every external origin's cert (its fingerprint never matches
/// the host's) and the client identity would leak to servers that didn't ask for it.
final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
private let identity: SecIdentity
private let pinnedHostFingerprint: Data? // SHA-256 of the host cert DER; nil = accept any (TOFU)
private let host: String
private let port: Int
init(identity: SecIdentity, pinnedHostFingerprint: Data?) {
init(identity: SecIdentity, pinnedHostFingerprint: Data?, host: String, port: UInt16) {
self.identity = identity
self.pinnedHostFingerprint = pinnedHostFingerprint
self.host = host
self.port = Int(port)
}
func urlSession(
@@ -112,11 +160,16 @@ final class LibraryTLSDelegate: NSObject, URLSessionDelegate {
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
switch challenge.protectionSpace.authenticationMethod {
let space = challenge.protectionSpace
guard space.host == host, space.port == port else {
completionHandler(.performDefaultHandling, nil)
return
}
switch space.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,
guard let trust = space.serverTrust,
let leaf = (SecTrustCopyCertificateChain(trust) as? [SecCertificate])?.first
else {
completionHandler(.cancelAuthenticationChallenge, nil)