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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user