feat(apple): adapt the macOS client to ABI v2 — client identity + SPAKE2 PIN pairing
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
The pairing/renegotiation batch bumped the punktfunk/1 ABI to v2 and the host now hard-rejects v1 Hellos (m3.rs), so streaming from the Mac was dead until the bundled PunktfunkCore.xcframework is rebuilt — it is gitignored, so that is a per-checkout step: bash scripts/build-xcframework.sh. The Swift wrapper itself was already adapted upstream; this lands the app on top of it. - ClientIdentityStore: persistent client identity in the login Keychain, presented on every connect so paired hosts recognize this Mac. Keychain access failure throws instead of regenerating (a fresh identity would silently un-pair this Mac from every --require-pairing host); a lost first-run race resolves toward the stored identity; pairing uses the strict loadForPairing() so a memory-only identity can't strand a ceremony. - PairSheet: the SPAKE2 PIN ceremony, reachable from a host card's context menu and from the trust prompt's "Pair with PIN instead…" (which drops the live session first — the host's accept loop is sequential). Success pins the verified fingerprint and connects; an in-flight ceremony self-discards when the sheet is dismissed, so a late success can't pin + auto-connect behind the user's back. Wrong PIN and Keychain failures get distinct, actionable error text. - Tests: identity unit tests; the full pairing ceremony + --require-pairing gate on loopback (test-loopback.sh arms a second host, parses its PIN from the log, and gives both hosts throwaway config homes — no more writes to the real ~/.config/punktfunk); remote pairing + pinned stream over the LAN (PUNKTFUNK_REMOTE_PIN, _PORT). Validated live against the box: SPAKE2 ceremony with the host's arming PIN → verified fingerprint → pinned + identified 720p60 session (host persisted the client identity); first light 60/60 AUs decoded to pixels; vkcube on glass through the app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+17
-9
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user