diff --git a/CLAUDE.md b/CLAUDE.md index 0aa103f..e25a675 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,8 +57,12 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc (2026-06-10).** PunktfunkKit compiles and is tested on macOS (AnnexB → VideoToolbox → `AVSampleBufferDisplayLayer`, GCMouse/GCKeyboard capture, `PunktfunkClient` app shell); validated live Mac ↔ this box at 720p60 — vkcube on glass, input injected via gamescope - EIS. Tests: `swift test` in `clients/apple` (unit + real-codec round trip), - `test-loopback.sh` (Swift client vs synthetic m3-host on loopback — runs on macOS), + EIS. The app speaks the full ABI v2 trust surface: Keychain-persisted client identity + presented on every connect, SPAKE2 PIN pairing UI (host-card context menu + the trust + prompt's "Pair with PIN instead…"), TOFU fingerprint prompt. Tests: `swift test` in + `clients/apple` (unit + real-codec round trip), + `test-loopback.sh` (Swift client vs synthetic m3-hosts on loopback — runs on macOS; + includes the pairing ceremony + `--require-pairing` gate), `RemoteFirstLightTests` (full pipeline over the LAN). See [`clients/apple/README.md`](clients/apple/README.md). Next: stage 2 presenter (`VTDecompressionSession` + `CAMetalLayer` frame pacing), glass-to-glass numbers via diff --git a/clients/apple/README.md b/clients/apple/README.md index e6a46a6..b1a5e2d 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -40,14 +40,19 @@ What's here, all compiled and tested on macOS (Xcode 26.5 / Swift 6.3): motion isn't truncated away. Buttons use GameStream ids (1=left … 5=X2); scroll is WHEEL_DELTA(120)-scaled. - **`PunktfunkClient`** (the app): hosts grid (saved in UserDefaults), "+" toolbar - sheet to add hosts, stream mode in Settings (⌘,), trust-on-first-use fingerprint prompt - over the live-but-blurred stream → pinned reconnects, fps/Mb-s HUD. (Audio playback and + sheet to add hosts, stream mode in Settings (⌘,), two trust flows — the + trust-on-first-use fingerprint prompt over the live-but-blurred stream, and SPAKE2 PIN + pairing (`PairSheet`, from a host card's context menu or the trust prompt; + `ClientIdentityStore` keeps the client identity in the Keychain and presents it on + every connect) — then pinned reconnects, fps/Mb-s HUD. (Audio playback and gamepad capture are not wired into the app yet — the connector surface is there; see notes 5–6.) - **Tests** (`swift test`): byte-level Annex-B units; a real-codec round trip (VTCompressionSession-encoded HEVC rebuilt as the host's wire shape → `AnnexB` → - VTDecompressionSession → pixels); loopback integration against a real local host - (`test-loopback.sh`); the remote first-light test above. + VTDecompressionSession → pixels); loopback integration against real local hosts + (`test-loopback.sh` — stream round trip, plus the PIN pairing ceremony and the + `--require-pairing` gate against a second, armed host); the remote first-light test + above. ## Build / run / test (on a Mac) @@ -123,11 +128,14 @@ signing, bundle id `io.unom.punktfunk`. Notes: per arming window, shown at startup — the user reads it before pairing). Returns the host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline - dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. The TOFU flow `PunktfunkClient` already - implements (fingerprint confirmation sheet, per-host `HostStore`, "Forget Identity") - keeps working against hosts not running `--require-pairing`; upgrading the sheet to a - PIN-entry field closes the remaining gap — with `--require-pairing` the host now - authorizes clients too (the "other direction" is no longer open, opt-in per host). + dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. `PunktfunkClient` implements both flows: + the TOFU fingerprint sheet keeps working against hosts not running + `--require-pairing`, and the PIN ceremony is wired in — `ClientIdentityStore` + (Keychain) on every connect, `PairSheet` from a host card's context menu or the trust + prompt's "Pair with PIN instead…" (the host's accept loop is sequential, so that path + drops the live session before pairing). With `--require-pairing` the host now + authorizes clients too (the "other direction" is no longer open, opt-in per host); + the whole gate is regression-tested in `testPairingCeremonyAndRequirePairingGate`. 7b. **Resize without reconnect**: `requestMode(width:height:refreshHz:)` mid-stream — the host rebuilds at the new mode in ~90 ms; the first new-mode AU is an IDR with fresh parameter sets (the refresh-on-IDR decode flow handles it untouched) and diff --git a/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift b/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift new file mode 100644 index 0000000..60a5f23 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/ClientIdentityStore.swift @@ -0,0 +1,123 @@ +// This client's persistent punktfunk/1 identity: a self-signed certificate + key (PEM), +// generated once and stored in the login Keychain. The certificate's fingerprint is how +// hosts recognize this client after PIN pairing — losing the key un-pairs this Mac from +// every host, so the pair is presented on every connect but never regenerated once +// stored. That invariant drives the error handling below: a Keychain that *refuses +// access* (locked, ACL denied) is an error, not a first run — minting a replacement +// would silently shadow the durable identity and break every existing pairing. + +import Foundation +import PunktfunkKit +import Security + +final class ClientIdentityStore: @unchecked Sendable { + static let shared = ClientIdentityStore() + + enum IdentityError: Error { + /// The Keychain refused access (locked, ACL denied, …) — an identity may exist. + case keychain(OSStatus) + /// The identity lives only in memory (Keychain write failed); good enough to + /// present on a connect, not good enough to pair against. + case notPersisted + } + + private let lock = NSLock() + private var cached: (identity: ClientIdentity, persisted: Bool)? + + /// The identity to present when connecting, generating + persisting it on first run. + /// `persisted == false` means the Keychain write failed and it lives only in memory — + /// fine for a session, see `loadForPairing()` for the strict variant. Blocking + /// (Keychain + key generation) — call off the main actor. + func load() throws -> (identity: ClientIdentity, persisted: Bool) { + lock.lock() + defer { lock.unlock() } + if let cached { return cached } + + switch copyStored() { + case .found(let identity): + let hit = (identity, true) + cached = hit + return hit + case .absent: + break // genuine first run — mint below + case .corrupt: + // Our own item, undecodable: the pairings it backed are unusable either + // way, so deliberately self-heal by replacing it. + SecItemDelete(Self.query as CFDictionary) + case .denied(let status): + throw IdentityError.keychain(status) + } + + let fresh = try generateIdentity() + let entry: (ClientIdentity, Bool) + switch add(fresh) { + case errSecSuccess: + entry = (fresh, true) + case errSecDuplicateItem: + // Lost a first-run race with another instance — the stored identity is the + // durable one, never overwrite it. + if case .found(let identity) = copyStored() { + entry = (identity, true) + } else { + entry = (fresh, false) + } + default: + entry = (fresh, false) + } + cached = entry + return entry + } + + /// Pairing variant: the host is about to durably trust this identity, so it must be + /// durable on our side too — a memory-only identity would evaporate on relaunch and + /// strand the pairing. + func loadForPairing() throws -> ClientIdentity { + let (identity, persisted) = try load() + guard persisted else { throw IdentityError.notPersisted } + return identity + } + + private struct Stored: Codable { + var certPEM: String + var keyPEM: String + } + + private enum ReadResult { + case found(ClientIdentity) + case absent + case corrupt + case denied(OSStatus) + } + + private static let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "io.unom.punktfunk", + kSecAttrAccount as String: "client-identity", + ] + + private func copyStored() -> ReadResult { + var query = Self.query + query[kSecReturnData as String] = true + var out: CFTypeRef? + switch SecItemCopyMatching(query as CFDictionary, &out) { + case errSecSuccess: + guard let data = out as? Data, + let stored = try? JSONDecoder().decode(Stored.self, from: data) + else { return .corrupt } + return .found(ClientIdentity(certPEM: stored.certPEM, keyPEM: stored.keyPEM)) + case errSecItemNotFound: + return .absent + case let status: + return .denied(status) + } + } + + private func add(_ identity: ClientIdentity) -> OSStatus { + guard let data = try? JSONEncoder().encode( + Stored(certPEM: identity.certPEM, keyPEM: identity.keyPEM)) + else { return errSecParam } + var add = Self.query + add[kSecValueData as String] = data + return SecItemAdd(add as CFDictionary, nil) + } +} diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index f276140..ac350a2 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -1,9 +1,12 @@ // Hosts grid ⇄ trust prompt ⇄ live stream. // // Home is a grid of saved hosts (click to connect); "+" in the toolbar adds one; the -// stream mode lives in Settings (⌘,). First connect to a host shows its certificate -// fingerprint over the live-but-blurred stream for explicit trust-on-first-use; once -// pinned, reconnects are silent and a changed host identity refuses to connect. +// stream mode lives in Settings (⌘,). Two ways to establish trust on first contact: +// the TOFU prompt (host fingerprint over the live-but-blurred stream, user compares it +// with the host's log) or the PIN pairing ceremony (right-click a card → "Pair with +// PIN…", or from the trust prompt itself) — pairing verifies both sides at once and is +// the only way into hosts running --require-pairing. Once pinned, reconnects are silent +// and a changed host identity refuses to connect. import AppKit import PunktfunkKit @@ -16,6 +19,7 @@ struct ContentView: View { @AppStorage("punktfunk.height") private var height = 1080 @AppStorage("punktfunk.hz") private var hz = 60 @State private var showAddHost = false + @State private var pairingTarget: StoredHost? var body: some View { Group { @@ -32,6 +36,20 @@ struct ContentView: View { } .onAppear { autoConnectIfAsked() } .onDisappear { model.disconnect() } // window closed mid-session (Cmd+N spawns more) + // On the outer Group so the sheet survives the trust-prompt → home transition + // (the "Pair with PIN instead" path disconnects first — the host's accept loop + // is sequential, a pairing connection would queue behind the live session). + .sheet(item: $pairingTarget) { host in + PairSheet(host: host) { fingerprint in + // Backstop against a stale ceremony surfacing after dismissal (PairSheet + // also self-discards those): only act while this host's sheet is up. + guard pairingTarget?.id == host.id else { return } + store.pin(host.id, fingerprint: fingerprint) + var pinned = host + pinned.pinnedSHA256 = fingerprint + connect(pinned) + } + } } private var sessionView: some View { @@ -163,6 +181,10 @@ struct ContentView: View { .buttonStyle(.plain) .disabled(model.isBusy) .contextMenu { + Button("Pair with PIN…") { + guard !model.isBusy else { return } + pairingTarget = host + } if host.pinnedSHA256 != nil { Button("Forget Identity") { store.forgetIdentity(host) } } @@ -207,6 +229,15 @@ struct ContentView: View { .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) } + // The verified alternative to eyeballing hex: drop this session (the host + // serves one connection at a time) and run the SPAKE2 PIN ceremony instead. + Button("Pair with PIN instead…") { + let host = model.activeHost + model.rejectTrust() + pairingTarget = host + } + .buttonStyle(.link) + .font(.callout) } .padding(28) .frame(maxWidth: 440) diff --git a/clients/apple/Sources/PunktfunkClient/HostStore.swift b/clients/apple/Sources/PunktfunkClient/HostStore.swift index 2542bd7..8d52707 100644 --- a/clients/apple/Sources/PunktfunkClient/HostStore.swift +++ b/clients/apple/Sources/PunktfunkClient/HostStore.swift @@ -1,11 +1,12 @@ // Saved hosts + their pinned identities, persisted as JSON in UserDefaults. // // Trust model (client side of punktfunk/1): the host serves a persistent certificate and -// logs its SHA-256 fingerprint at startup. First connect is trust-on-first-use — the user -// explicitly confirms the observed fingerprint against the host's log, and we pin it here. -// Every later connect passes the pin into punktfunk-core, which refuses a host whose -// identity changed. (Host→client authorization — a pairing PIN — is a roadmap item; today -// the host accepts any client that can reach its port.) +// logs its SHA-256 fingerprint at startup. The pin lands here one of two ways — the +// trust-on-first-use prompt (user compares the observed fingerprint against the host's +// log) or the SPAKE2 PIN pairing ceremony (PairSheet; mutually verified, and the host +// stores our identity from ClientIdentityStore in return). Every later connect passes +// the pin into punktfunk-core, which refuses a host whose identity changed. Hosts running +// --require-pairing only admit paired clients, so for them pairing is the only way in. import Foundation import SwiftUI diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift new file mode 100644 index 0000000..d392007 --- /dev/null +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -0,0 +1,126 @@ +// PIN pairing sheet. The host, started with --allow-pairing (or --require-pairing), +// prints a short PIN at startup ("PAIRING ARMED — enter this PIN on the client to +// pair"); the user types it here. The ceremony is SPAKE2, so a wrong PIN buys an +// attacker exactly one online guess — for the user a typo just means "try again" (the +// host rate-limits ceremonies to one per 2 s). Success returns the host's now-VERIFIED +// fingerprint: the caller pins it, no manual comparison needed, and the host stores this +// client's identity in return. + +import Foundation +import PunktfunkKit +import SwiftUI + +/// Dismissing the sheet must abandon an in-flight ceremony: the blocking pair() call +/// can't be interrupted, so its completion checks this flag and self-discards — a late +/// success must NOT pin and auto-connect to a host the user cancelled out of. Only +/// touched on the main actor. +private final class CeremonyToken: @unchecked Sendable { + var cancelled = false +} + +struct PairSheet: View { + @Environment(\.dismiss) private var dismiss + let host: StoredHost + /// Called with the verified host fingerprint after a successful ceremony. + let onPaired: (Data) -> Void + + @State private var pin = "" + @State private var clientName = Host.current().localizedName ?? "Mac" + @State private var busy = false + @State private var errorText: String? + @State private var token = CeremonyToken() + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + TextField("PIN", text: $pin, prompt: Text("Shown in the host's log")) + .font(.system(.body, design: .monospaced)) + TextField( + "Client name", text: $clientName, + prompt: Text("How the host lists this Mac")) + } header: { + Text("Pair with \(host.displayName)") + } footer: { + Text("The host prints the PIN when pairing is armed " + + "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). " + + "Pairing verifies both sides at once — no fingerprint " + + "comparison needed.") + .font(.caption) + .foregroundStyle(.secondary) + } + if let errorText { + Section { + Text(errorText) + .font(.callout) + .foregroundStyle(.red) + } + } + } + .formStyle(.grouped) + HStack { + Button("Cancel", role: .cancel) { + token.cancelled = true + dismiss() + } + .keyboardShortcut(.cancelAction) + Spacer() + if busy { + ProgressView() + .controlSize(.small) + .padding(.trailing, 8) + } + Button("Pair & Connect") { runCeremony() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty) + } + .padding(16) + } + .frame(width: 400) + .fixedSize(horizontal: false, vertical: true) + .interactiveDismissDisabled(busy) + .onDisappear { token.cancelled = true } // any other dismissal path + } + + private func runCeremony() { + busy = true + errorText = nil + let pin = pin.trimmingCharacters(in: .whitespaces) + let name = clientName.trimmingCharacters(in: .whitespaces) + let address = host.address + let port = host.port + let token = token + Task.detached(priority: .userInitiated) { + // Identity load + the ceremony both block — keep them off the main actor. + // loadForPairing is the strict variant: the host durably trusts this + // identity, so it must have made it into the Keychain. + let result = Result { + let identity = try ClientIdentityStore.shared.loadForPairing() + return try PunktfunkKit.pair( + host: address, port: port, identity: identity, + pin: pin, name: name.isEmpty ? "Mac" : name) + } + await MainActor.run { + guard !token.cancelled else { return } // sheet dismissed mid-ceremony + busy = false + switch result { + case .success(let fingerprint): + onPaired(fingerprint) + dismiss() + case .failure(PunktfunkClientError.wrongPIN): + errorText = "Wrong PIN — check the host's \u{201C}PAIRING ARMED\u{201D} " + + "line and try again." + case .failure(is ClientIdentityStore.IdentityError): + errorText = "Can't store this Mac's identity in the Keychain, so the " + + "pairing would not survive a relaunch. Unlock the login " + + "keychain and try again." + case .failure: + errorText = "Pairing failed. Is the host reachable, armed with " + + "--allow-pairing, and not mid-session? Retries are rate-limited " + + "to one per 2 seconds." + } + } + } + } +} diff --git a/clients/apple/Sources/PunktfunkClient/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/SessionModel.swift index 54e9e69..7c5887c 100644 --- a/clients/apple/Sources/PunktfunkClient/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/SessionModel.swift @@ -68,11 +68,15 @@ final class SessionModel: ObservableObject { errorMessage = nil let pin = host.pinnedSHA256 Task.detached(priority: .userInitiated) { - // PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main actor. + // PunktfunkConnection.init blocks on the QUIC handshake — keep it off the main + // actor. The persistent identity is presented on every connect so a paired + // host recognizes this Mac (nil = anonymous, fine for hosts without + // --require-pairing; Keychain/generation failure must not block connecting). + let identity = (try? ClientIdentityStore.shared.load())?.identity let result = Result { try PunktfunkConnection( host: host.address, port: host.port, width: width, height: height, refreshHz: hz, - pinSHA256: pin) } + pinSHA256: pin, identity: identity) } await MainActor.run { [weak self] in guard let self else { return } switch result { @@ -89,10 +93,14 @@ final class SessionModel: ObservableObject { self.activeHost = nil self.errorMessage = pin != nil ? "Could not connect to \(host.displayName) — host unreachable, " - + "not running, or its identity no longer matches the pinned " - + "fingerprint." + + "not running, its identity no longer matches the pinned " + + "fingerprint, or it requires pairing and no longer " + + "recognizes this Mac (right-click the host card to pair " + + "again)." : "Could not connect to \(host.displayName) — is punktfunk-host " - + "running on \(host.address):\(host.port)?" + + "running on \(host.address):\(host.port)? If it requires " + + "pairing, right-click the host card and pair with its PIN " + + "first." } } } diff --git a/clients/apple/Tests/PunktfunkKitTests/IdentityTests.swift b/clients/apple/Tests/PunktfunkKitTests/IdentityTests.swift new file mode 100644 index 0000000..c92df53 --- /dev/null +++ b/clients/apple/Tests/PunktfunkKitTests/IdentityTests.swift @@ -0,0 +1,36 @@ +// Client identity generation through the ABI (punktfunk_generate_identity): the PEM pair +// hosts use to recognize a paired client. Pure local crypto — no host needed. + +import XCTest +@testable import PunktfunkKit + +final class IdentityTests: XCTestCase { + func testGenerateIdentityYieldsDistinctPEMPairs() throws { + let a = try generateIdentity() + let b = try generateIdentity() + + XCTAssertTrue(a.certPEM.contains("BEGIN CERTIFICATE"), "cert is PEM") + XCTAssertTrue(a.keyPEM.contains("PRIVATE KEY"), "key is PEM") + XCTAssertTrue(a.certPEM.hasSuffix("\n") || a.certPEM.contains("END CERTIFICATE")) + + // Each call mints a fresh keypair — identical output would mean a broken RNG. + XCTAssertNotEqual(a.certPEM, b.certPEM) + XCTAssertNotEqual(a.keyPEM, b.keyPEM) + } + + func testPairAgainstNothingFailsCleanly() { + // Nothing listens on this port; the ceremony must throw within its timeout, and + // must not report .wrongPIN (no SPAKE2 exchange ever happened). + do { + let identity = try generateIdentity() + _ = try pair( + host: "127.0.0.1", port: 9, identity: identity, + pin: "0000", name: "test", timeoutMs: 2000) + XCTFail("expected pair() against a dead port to throw") + } catch PunktfunkClientError.wrongPIN { + XCTFail("dead port must not look like a wrong PIN") + } catch { + // any other error is the correct outcome + } + } +} diff --git a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift index 21f4baa..badd0b1 100644 --- a/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/LoopbackIntegrationTests.swift @@ -62,4 +62,59 @@ final class LoopbackIntegrationTests: XCTestCase { host: "127.0.0.1", port: 9, width: 640, height: 480, refreshHz: 30, timeoutMs: 2000)) } + + /// The PIN pairing ceremony + the --require-pairing gate through the Swift wrapper: + /// anonymous rejection, the single wrong-PIN online guess, the real ceremony, and a + /// paired + pinned session. Driven by test-loopback.sh, which arms a second host with + /// --require-pairing and parses its random PIN out of the log. + func testPairingCeremonyAndRequirePairingGate() throws { + let env = ProcessInfo.processInfo.environment + guard let portStr = env["PUNKTFUNK_PAIRING_PORT"], let port = UInt16(portStr), + let pin = env["PUNKTFUNK_PAIRING_PIN"] + else { + throw XCTSkip("needs an armed m3-host — use clients/apple/test-loopback.sh") + } + + let identity = try generateIdentity() + + // 1. Unpaired clients don't get sessions from a --require-pairing host. + XCTAssertThrowsError( + try PunktfunkConnection( + host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60, + identity: identity, timeoutMs: 5000), + "unpaired client must be rejected") + + // 2. A wrong PIN is exactly one failed online guess — distinguishable from + // transport errors so the UI can say "try again". + XCTAssertThrowsError( + try pair( + host: "127.0.0.1", port: port, identity: identity, + pin: pin == "0000" ? "9999" : "0000", name: "wrong-pin", timeoutMs: 5000) + ) { error in + guard case PunktfunkClientError.wrongPIN = error else { + return XCTFail("expected .wrongPIN, got \(error)") + } + } + + // 3. The real ceremony (after the host's 2 s pairing cooldown). + Thread.sleep(forTimeInterval: 2.2) + let fingerprint = try pair( + host: "127.0.0.1", port: port, identity: identity, + pin: pin, name: "loopback-test", timeoutMs: 5000) + XCTAssertEqual(fingerprint.count, 32) + + // 4. Paired + pinned: the same identity now gets a session, and the ceremony's + // fingerprint matches the certificate the host actually serves. + let conn = try PunktfunkConnection( + host: "127.0.0.1", port: port, width: 1280, height: 720, refreshHz: 60, + pinSHA256: fingerprint, identity: identity, timeoutMs: 5000) + XCTAssertEqual(conn.hostFingerprint, fingerprint) + var got = 0 + let deadline = Date().addingTimeInterval(15) + while got < 5, Date() < deadline { + if try conn.nextAU(timeoutMs: 2000) != nil { got += 1 } + } + conn.close() + XCTAssertGreaterThanOrEqual(got, 5, "paired session must stream") + } } diff --git a/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift b/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift index aba9b2b..8a75dee 100644 --- a/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift +++ b/clients/apple/Tests/PunktfunkKitTests/RemoteFirstLightTests.swift @@ -15,15 +15,49 @@ import XCTest @testable import PunktfunkKit final class RemoteFirstLightTests: XCTestCase { + /// The pairing ceremony over the real LAN, exactly as the app runs it: fresh identity, + /// SPAKE2 with the host's arming PIN, then a pinned + identified session. Needs the + /// host armed (--allow-pairing) and its logged PIN in PUNKTFUNK_REMOTE_PIN. Heads-up: + /// every run durably adds one throwaway "remote-test" identity to the host's + /// ~/.config/punktfunk/punktfunk1-paired.json — prune those entries at will. + func testRemotePairingThenPinnedStream() throws { + let env = ProcessInfo.processInfo.environment + guard let host = env["PUNKTFUNK_REMOTE_HOST"], let pin = env["PUNKTFUNK_REMOTE_PIN"] + else { + throw XCTSkip("set PUNKTFUNK_REMOTE_HOST + PUNKTFUNK_REMOTE_PIN " + + "(host armed with --allow-pairing)") + } + let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777 + + let identity = try generateIdentity() + let fingerprint = try pair( + host: host, port: port, identity: identity, pin: pin, name: "remote-test") + XCTAssertEqual(fingerprint.count, 32) + + let conn = try PunktfunkConnection( + host: host, port: port, width: 1280, height: 720, refreshHz: 60, + pinSHA256: fingerprint, identity: identity) + defer { conn.close() } + XCTAssertEqual(conn.hostFingerprint, fingerprint) + var got = 0 + let deadline = Date().addingTimeInterval(20) + while got < 10, Date() < deadline { + if try conn.nextAU(timeoutMs: 2000) != nil { got += 1 } + } + XCTAssertGreaterThanOrEqual(got, 10, "paired + pinned session must stream") + } + func testRemoteStreamDecodesToPixels() throws { - guard let host = ProcessInfo.processInfo.environment["PUNKTFUNK_REMOTE_HOST"] else { + let env = ProcessInfo.processInfo.environment + guard let host = env["PUNKTFUNK_REMOTE_HOST"] else { throw XCTSkip("set PUNKTFUNK_REMOTE_HOST (and start m3-host --source virtual there)") } + let port = env["PUNKTFUNK_REMOTE_PORT"].flatMap(UInt16.init) ?? 9777 let width: UInt32 = 1280 let height: UInt32 = 720 let conn = try PunktfunkConnection( - host: host, width: width, height: height, refreshHz: 60) + host: host, port: port, width: width, height: height, refreshHz: 60) defer { conn.close() } XCTAssertEqual(conn.width, width) XCTAssertEqual(conn.height, height) diff --git a/clients/apple/test-loopback.sh b/clients/apple/test-loopback.sh index 16a0ec0..a015977 100755 --- a/clients/apple/test-loopback.sh +++ b/clients/apple/test-loopback.sh @@ -1,17 +1,44 @@ #!/usr/bin/env bash -# Loopback integration: a real punktfunk/1 host (synthetic source — pure protocol, runs fine on -# macOS) on 127.0.0.1, then the Swift integration tests against it through the xcframework. -# The m3 host serves exactly one session and exits; the trap is just for failure paths. +# Loopback integration: real punktfunk/1 hosts (synthetic source — pure protocol, runs fine on +# macOS) on 127.0.0.1, then the Swift integration tests against them through the xcframework. +# Two hosts: an open one (stream round trip) and one armed with --require-pairing (the PIN +# ceremony + pairing gate — its random PIN is parsed out of its log). set -euo pipefail cd "$(dirname "$0")/../.." PORT="${PUNKTFUNK_LOOPBACK_PORT:-19778}" +PAIR_PORT="${PUNKTFUNK_PAIRING_PORT:-19779}" cargo build --release -p punktfunk-host -target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 & + +# Each host gets a throwaway config home: the pairing host persists a trust store +# (punktfunk1-paired.json, resolved from $HOME) and both mint an identity cert on first +# run — none of that belongs in the user's real ~/.config/punktfunk, and separate homes +# also keep the two first runs from racing on the same cert.pem. +CFG="$(mktemp -d "${TMPDIR:-/tmp}/punktfunk-loopback.XXXXXX")" +PAIR_LOG="$CFG/pairing-host.log" +mkdir -p "$CFG/open" "$CFG/paired" +trap 'kill "${HOST_PID:-}" "${PAIR_PID:-}" 2>/dev/null || true' EXIT +HOME="$CFG/open" XDG_CONFIG_HOME="$CFG/open/.config" \ + target/release/punktfunk-host m3-host --port "$PORT" --source synthetic --frames 300 & HOST_PID=$! -trap 'kill "$HOST_PID" 2>/dev/null || true' EXIT +HOME="$CFG/paired" XDG_CONFIG_HOME="$CFG/paired/.config" \ + target/release/punktfunk-host m3-host --port "$PAIR_PORT" --source synthetic --frames 300 \ + --require-pairing >"$PAIR_LOG" 2>&1 & +PAIR_PID=$! sleep 1 +PIN="" +for _ in $(seq 50); do + PIN="$(grep -oE 'pair: [0-9]+' "$PAIR_LOG" | head -1 | cut -d' ' -f2 || true)" + [ -n "$PIN" ] && break + sleep 0.2 +done +if [ -z "$PIN" ]; then + echo "no arming PIN in the pairing host's log ($PAIR_LOG)" >&2 + exit 1 +fi + cd clients/apple -PUNKTFUNK_LOOPBACK_PORT="$PORT" swift test --filter LoopbackIntegrationTests +PUNKTFUNK_LOOPBACK_PORT="$PORT" PUNKTFUNK_PAIRING_PORT="$PAIR_PORT" PUNKTFUNK_PAIRING_PIN="$PIN" \ + swift test --filter LoopbackIntegrationTests