feat(apple): adapt the macOS client to ABI v2 — client identity + SPAKE2 PIN pairing
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:
2026-06-10 21:49:36 +02:00
parent 49d31b9cad
commit 0494e0200a
11 changed files with 485 additions and 32 deletions
@@ -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. (Hostclient 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."
}
}
}