feat: punktfunk/1 — mid-stream mode renegotiation + PIN pairing ceremony

Renegotiation (no reconnect on resize): the handshake bi-stream stays open; the client
sends Reconfigure{mode} (typed post-handshake message), the host validates + acks
Reconfigured and rebuilds capture/encoder/virtual output at the new mode while the data
plane (keys, ports, FEC) runs untouched — the first new-mode AU is an IDR with in-band
parameter sets. NativeClient::request_mode / punktfunk_connection_request_mode; mode()
reflects the active mode. Validated live on KWin: one continuous stream, 225 frames
@1280x720 then 395 @1920x1080, ~90 ms pipeline rebuild (ffprobe shows both resolutions).

PIN pairing (mutual trust, kills TOFU MITM): clients get persistent self-signed
identities presented via QUIC client auth (generate_identity / client auth offered but
optional server-side — legacy clients still connect). Ceremony on the control stream:
PairRequest{name} → host shows a 4-digit PIN (log) + PairChallenge{salt} → client proves
with HMAC-SHA256(PIN‖salt, client_fp‖host_fp) — binding both certs means a MITM can't
forward a proof, single attempt per PIN, constant-time compare → PairResult; host
persists the fingerprint (~/.config/punktfunk/punktfunk1-paired.json), client pins the
host's. m3-host --require-pairing gates sessions on the paired set.
NativeClient::pair + punktfunk_pair/punktfunk_generate_identity in the ABI; reference
client: --pair PIN --name LABEL + auto-generated persistent identity, --remode for live
renegotiation testing. Swift wrapper: ClientIdentity/generateIdentity()/pair(),
requestMode()/currentMode(); README handoff updated.

Tested: reconfigure/pairing wire roundtrips, C-ABI mode switch ack, full in-process
ceremony (wrong PIN → Crypto, anonymous-vs-gate rejection, success → pinned session);
live wrong-PIN ceremony against the serving host (PIN logged, proof rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:42:29 +00:00
parent 7381ba8218
commit 4d26ac5c85
12 changed files with 1386 additions and 91 deletions
+13 -9
View File
@@ -117,15 +117,19 @@ signing, bundle id `io.unom.punktfunk`. Notes:
contract documented on the constructors; the host accumulates them into a virtual
Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback.
Client-side capture isn't in `InputCapture` yet.
7. **Trust**: connect once with `pinSHA256: nil` (TOFU), persist `hostFingerprint` keyed
by host, pass it on every later connect — a mismatch throws `.connectFailed`. The host
logs its fingerprint at startup ("clients pin this fingerprint") for out-of-band
verification UX; a PIN-style pairing ceremony is a later punktfunk-core task.
`PunktfunkClient` implements exactly this: explicit fingerprint confirmation on first
connect (input/cursor capture held back until confirmed), pin stored per host
(`HostStore`), "Forget Identity" in the card's context menu for legitimate host
reinstalls. Note the OTHER direction is still open: the host authorizes no one — any
client that reaches the port gets a session (fine on a LAN, not on the internet).
7. **Trust — the full ceremony exists now.** `generateIdentity()` once (persist both
PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN the
host displays (its log; UI later) — returns the host's VERIFIED fingerprint; persist
it and pass `pinSHA256:` + `identity:` to every connect. A wrong-size pin throws
`.invalidPin`, a wrong PIN `.wrongPIN`. 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).
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
`currentMode()` reflects the switch. Wire it to window-resize events.
8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus —
on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so
nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden
@@ -49,10 +49,69 @@ public enum PunktfunkClientError: Error {
/// `pinSHA256` was non-nil but not exactly 32 bytes. Failing closed: connecting
/// unpinned when the caller asked for verification would be a silent trust downgrade.
case invalidPin
/// Pairing rejected wrong PIN.
case wrongPIN
case closed
case status(Int32)
}
/// This client's persistent self-signed identity. Generate ONCE with `generateIdentity()`,
/// store both PEMs (Keychain), present on every connect the certificate's fingerprint is
/// how hosts recognize this client after pairing.
public struct ClientIdentity: Sendable {
public let certPEM: String
public let keyPEM: String
public init(certPEM: String, keyPEM: String) {
self.certPEM = certPEM
self.keyPEM = keyPEM
}
}
/// Generate a fresh client identity (self-signed cert + key, PEM).
public func generateIdentity() throws -> ClientIdentity {
var cert = [CChar](repeating: 0, count: 4096)
var key = [CChar](repeating: 0, count: 4096)
let rc = punktfunk_generate_identity(&cert, cert.count, &key, key.count)
guard rc.rawValue == PUNKTFUNK_STATUS_OK.rawValue else {
throw PunktfunkClientError.status(rc.rawValue)
}
return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key))
}
/// Run the PIN pairing ceremony: the host displays a 4-digit PIN (its log/UI), the user
/// types it here. On success the host stores this client's identity and the returned
/// fingerprint is the host's now-VERIFIED identity persist it and pass it as `pinSHA256`
/// to every later connect. Throws `.wrongPIN` when the proof is rejected.
public func pair(
host: String, port: UInt16 = 9777,
identity: ClientIdentity, pin: String, name: String,
timeoutMs: UInt32 = 90_000
) throws -> Data {
var observed = [UInt8](repeating: 0, count: 32)
let rc = host.withCString { cs in
identity.certPEM.withCString { cert in
identity.keyPEM.withCString { key in
pin.withCString { p in
name.withCString { n in
punktfunk_pair(cs, port, cert, key, p, n, &observed, timeoutMs)
}
}
}
}
}
switch rc.rawValue {
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
default: throw PunktfunkClientError.status(rc.rawValue)
}
}
/// `withCString` over an optional nil maps to a NULL C pointer.
func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R) -> R {
guard let s else { return body(nil) }
return s.withCString { body($0) }
}
public final class PunktfunkConnection {
private var handle: OpaquePointer?
/// Set by close() before it contends for the plane locks: the pullers see it at their
@@ -82,23 +141,34 @@ public final class PunktfunkConnection {
/// `pinSHA256`: the host's expected certificate fingerprint (exactly 32 bytes, else
/// `invalidPin` is thrown never silently downgraded); nil = trust on first use
/// (check `hostFingerprint` afterwards). A pinned mismatch throws.
///
/// `identity`: this client's persistent identity (from `generateIdentity()`, stored in
/// the Keychain) presented so a host recognizes a paired client. nil = anonymous;
/// hosts running `--require-pairing` reject anonymous sessions.
public init(
host: String, port: UInt16 = 9777,
width: UInt32, height: UInt32, refreshHz: UInt32,
pinSHA256: Data? = nil,
identity: ClientIdentity? = nil,
timeoutMs: UInt32 = 10_000
) throws {
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
var observed = [UInt8](repeating: 0, count: 32)
handle = host.withCString { cs in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect(
cs, port, width, height, refreshHz,
p.bindMemory(to: UInt8.self).baseAddress, &observed, timeoutMs)
withOptionalCString(identity?.certPEM) { cert in
withOptionalCString(identity?.keyPEM) { key in
if let pin = pinSHA256 {
return pin.withUnsafeBytes { p in
punktfunk_connect(
cs, port, width, height, refreshHz,
p.bindMemory(to: UInt8.self).baseAddress, &observed,
cert, key, timeoutMs)
}
}
return punktfunk_connect(
cs, port, width, height, refreshHz, nil, &observed, cert, key, timeoutMs)
}
}
return punktfunk_connect(cs, port, width, height, refreshHz, nil, &observed, timeoutMs)
}
guard handle != nil else { throw PunktfunkClientError.connectFailed }
hostFingerprint = Data(observed)
@@ -109,6 +179,28 @@ public final class PunktfunkConnection {
self.refreshHz = hz
}
/// Ask the host to switch the live session to a new mode (window resized) no
/// reconnect. Non-blocking; on acceptance the stream continues at the new mode (the
/// first new-mode AU is an IDR with fresh parameter sets `AnnexB.formatDescription`
/// refresh-on-IDR already handles it) and `currentMode()` reflects the switch.
public func requestMode(width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
}
/// The currently active session mode (updated by accepted `requestMode` switches).
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
defer { abiLock.unlock() }
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
if let hd = handle, !closeRequested {
_ = punktfunk_connection_mode(hd, &w, &h, &hz)
}
return (w, h, hz)
}
/// Pull the next access unit; nil on timeout, throws `.closed` once the session ended.
/// Call from a single pump thread.
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {