From 4d26ac5c85e7b34e16b0aee434c3f0be342d24ca Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 15:42:29 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20punktfunk/1=20=E2=80=94=20mid-stream=20?= =?UTF-8?q?mode=20renegotiation=20+=20PIN=20pairing=20ceremony?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 24 +- Cargo.lock | 11 + clients/apple/README.md | 22 +- .../PunktfunkKit/PunktfunkConnection.swift | 104 ++++- crates/punktfunk-client-rs/src/main.rs | 119 +++++- crates/punktfunk-core/Cargo.toml | 5 +- crates/punktfunk-core/src/abi.rs | 164 +++++++- crates/punktfunk-core/src/client.rs | 163 +++++++- crates/punktfunk-core/src/quic.rs | 379 ++++++++++++++++- crates/punktfunk-host/src/m3.rs | 386 ++++++++++++++++-- crates/punktfunk-host/src/main.rs | 5 + include/punktfunk_core.h | 95 ++++- 12 files changed, 1386 insertions(+), 91 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1c1fa67..f2697da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc **KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus - `RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy). + `RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy), + **Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a + managed chooser config; validated live on sway 1.11, zero-copy). Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf → EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic @@ -36,7 +38,13 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc audio** 0xC9 (48 kHz stereo, 5 ms, host→client), **rumble** 0xCA (host→client). **Trust:** host serves its persistent identity (`~/.config/punktfunk/cert.pem`, shared with GameStream pairing) and logs the SHA-256 fingerprint; clients pin it (TOFU on first connect — - `endpoint::client_pinned`). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms** + `endpoint::client_pinned`), and a **PIN pairing ceremony** (host displays a 4-digit PIN, + proof = HMAC over both cert fingerprints, single attempt) establishes mutual trust: + clients present persistent identities via QUIC client auth, the host stores paired + fingerprints (`punktfunk1-paired.json`) and can gate sessions with `--require-pairing`. + **Mid-stream mode renegotiation**: `Reconfigure` on the still-open control stream — the + host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on + (validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms** capture→…→reassembled; audio measured live (~200 pkts/s). `punktfunk-client-rs` is the working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad). The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect` @@ -58,11 +66,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc 2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms at high res). -3. **punktfunk/1 protocol growth**: a PIN-style pairing ceremony on top of fingerprint pinning, - mid-stream mode renegotiation (the Welcome is one-shot today), concurrent sessions - (today: one at a time, extras wait in the accept queue). -4. **M2 polish**: wlroots/Sway `VirtualDisplay` backend (deferred; swaymsg `create_output`), - HDR/10-bit/AV1 negotiation, surround audio, reconnect-at-new-mode robustness. +3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait + in the accept queue); mgmt REST endpoints for the punktfunk/1 paired-client list. +4. **M2 polish**: HDR negotiation, reconnect-at-new-mode robustness. 5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`. Box one-time setup is complete: udev rule + `input` group (gamepads validated live), @@ -73,7 +79,7 @@ backend validated live). All three compositor backends are live-validated. ```sh cargo build --workspace # green on Linux and macOS -cargo test --workspace # unit + loopback + proptest + C ABI harness (~97 tests) +cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests) cargo clippy --workspace --all-targets -- -D warnings cargo fmt --all --check @@ -91,7 +97,7 @@ Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated) crates/punktfunk-host/ gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps - vdisplay/{kwin,gamescope,mutter}.rs per-compositor client-sized virtual outputs + vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan) inject/{libei,wlr,gamepad}.rs input backends (+ uinput virtual gamepads) capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs diff --git a/Cargo.lock b/Cargo.lock index ca874b0..ae77d4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,6 +718,7 @@ dependencies = [ "block-buffer", "const-oid", "crypto-common", + "subtle", ] [[package]] @@ -1142,6 +1143,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -1917,6 +1927,7 @@ dependencies = [ "bytes", "cbindgen", "fec-rs", + "hmac", "proptest", "quinn", "rand 0.9.4", diff --git a/clients/apple/README.md b/clients/apple/README.md index e706338..304e852 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -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 diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index 820130c..0eceb03 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -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(_ s: String?, _ body: (UnsafePointer?) -> 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? { diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 856ad20..7349c48 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -20,7 +20,7 @@ use anyhow::{anyhow, Context, Result}; use punktfunk_core::config::Role; use punktfunk_core::input::{InputEvent, InputKind}; -use punktfunk_core::quic::{endpoint, io, Hello, Start, Welcome}; +use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome}; use punktfunk_core::transport::UdpTransport; use punktfunk_core::{Mode, PunktfunkError, Session}; use std::io::Write; @@ -31,6 +31,21 @@ struct Args { out: Option, input_test: bool, pin: Option<[u8; 32]>, + /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. + remode: Option<(Mode, u32)>, + /// `--pair PIN` — run the pairing ceremony instead of a session. + pair: Option, + /// `--name LABEL` — how the host labels this client when pairing. + name: String, +} + +fn parse_mode(m: &str) -> Option { + let mut it = m.split('x'); + Some(Mode { + width: it.next()?.parse().ok()?, + height: it.next()?.parse().ok()?, + refresh_hz: it.next()?.parse().ok()?, + }) } fn parse_hex32(s: &str) -> Option<[u8; 32]> { @@ -48,6 +63,24 @@ fn hex(fp: &[u8; 32]) -> String { fp.iter().map(|b| format!("{b:02x}")).collect() } +/// This client's persistent identity (`~/.config/punktfunk/client-{cert,key}.pem`), +/// generated on first use — presented on every connect so hosts can recognize it once +/// paired. +fn load_or_create_identity() -> Result<(String, String)> { + let home = std::env::var("HOME").context("HOME unset")?; + let dir = std::path::PathBuf::from(home).join(".config/punktfunk"); + let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem")); + if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) { + return Ok((c, k)); + } + let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?; + std::fs::create_dir_all(&dir)?; + std::fs::write(&cp, &c)?; + std::fs::write(&kp, &k)?; + tracing::info!(cert = %cp.display(), "generated client identity"); + Ok((c, k)) +} + fn parse_args() -> Args { let argv: Vec = std::env::args().collect(); let get = |flag: &str| { @@ -56,20 +89,15 @@ fn parse_args() -> Args { .nth(1) .map(String::as_str) }; - let mode = get("--mode") - .and_then(|m| { - let mut it = m.split('x'); - Some(Mode { - width: it.next()?.parse().ok()?, - height: it.next()?.parse().ok()?, - refresh_hz: it.next()?.parse().ok()?, - }) - }) - .unwrap_or(Mode { - width: 1280, - height: 720, - refresh_hz: 60, - }); + let mode = get("--mode").and_then(parse_mode).unwrap_or(Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }); + let remode = get("--remode").and_then(|s| { + let (m, secs) = s.split_once(':')?; + Some((parse_mode(m)?, secs.parse().ok()?)) + }); // A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use // (the user asked for verification; fail closed). let pin = match get("--pin") { @@ -90,6 +118,9 @@ fn parse_args() -> Args { out: get("--out").map(String::from), input_test: argv.iter().any(|a| a == "--input-test"), pin, + remode, + pair: get("--pair").map(String::from), + name: get("--name").unwrap_or("punktfunk-client-rs").to_string(), } } @@ -114,6 +145,29 @@ fn main() { } fn run(args: Args) -> Result<()> { + // Pairing mode: run the PIN ceremony and print the fingerprint to pin, then exit. + if let Some(pin) = &args.pair { + let (host, port) = args + .connect + .rsplit_once(':') + .context("--connect host:port")?; + let identity = load_or_create_identity()?; + let fp = punktfunk_core::client::NativeClient::pair( + host, + port.parse().context("port")?, + (&identity.0, &identity.1), + pin, + &args.name, + std::time::Duration::from_secs(90), + ) + .map_err(|e| anyhow!("pairing failed: {e:?} (wrong PIN?)"))?; + tracing::info!( + fingerprint = %hex(&fp), + "PAIRED — connect with --pin {} from now on", + hex(&fp) + ); + return Ok(()); + } let rt = tokio::runtime::Builder::new_multi_thread() .worker_threads(2) .enable_all() @@ -123,7 +177,11 @@ fn run(args: Args) -> Result<()> { async fn session(args: Args) -> Result<()> { let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?; - let (ep, observed) = endpoint::client_pinned(args.pin); + let identity = load_or_create_identity()?; + let (ep, observed) = endpoint::client_pinned_with_identity( + args.pin, + Some((identity.0.as_str(), identity.1.as_str())), + ); let ep = ep.map_err(|e| anyhow!("QUIC client endpoint: {e}"))?; let conn = ep .connect(remote, "punktfunk") @@ -173,6 +231,35 @@ async fn session(args: Args) -> Result<()> { ) .await?; + // Mid-stream renegotiation test: after a delay, ask the host to switch modes on the + // still-open control stream. The stream then carries new-mode AUs (IDR + in-band + // parameter sets) — ffprobe the --out file to see both resolutions. + if let Some((new_mode, after_secs)) = args.remode { + let mut rs = send; + let mut rr = recv; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(after_secs as u64)).await; + tracing::info!(?new_mode, "requesting mid-stream mode switch"); + if io::write_msg(&mut rs, &Reconfigure { mode: new_mode }.encode()) + .await + .is_err() + { + tracing::error!("Reconfigure write failed"); + return; + } + match io::read_msg(&mut rr) + .await + .map(|b| Reconfigured::decode(&b)) + { + Ok(Ok(ack)) if ack.accepted => { + tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED") + } + Ok(Ok(ack)) => tracing::warn!(active = ?ack.mode, "mode switch REJECTED"), + other => tracing::error!(?other, "bad Reconfigured"), + } + }); + } + // Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the // low-latency input path without a real input device. if args.input_test { diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index 023228b..7e90f6b 100644 --- a/crates/punktfunk-core/Cargo.toml +++ b/crates/punktfunk-core/Cargo.toml @@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"] default = [] # Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here, # never on the per-frame hot path. Off by default so the core stays runtime-free. -quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2"] +quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac"] [dependencies] reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2) @@ -38,9 +38,10 @@ zeroize = "1" quinn = { version = "0.11", optional = true } rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] } -rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs"] } +rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] } rustls-pki-types = { version = "1", optional = true } sha2 = { version = "0.10", optional = true } +hmac = { version = "0.12", optional = true } tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] } [dev-dependencies] diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index de38949..c61c944 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -465,6 +465,18 @@ pub struct PunktfunkConnection { last_audio: std::sync::Mutex>, } +/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8. +#[cfg(feature = "quic")] +unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result, ()> { + if p.is_null() { + return Ok(None); + } + unsafe { std::ffi::CStr::from_ptr(p) } + .to_str() + .map(Some) + .map_err(|_| ()) +} + /// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. /// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. /// @@ -473,9 +485,15 @@ pub struct PunktfunkConnection { /// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and /// pass it as the pin on every later connect. /// +/// Identity: `client_cert_pem`/`client_key_pem` (both NULL, or both NUL-terminated PEM +/// strings — see [`punktfunk_generate_identity`]) are presented via TLS client auth so a +/// host can recognize this client once paired ([`punktfunk_pair`]). NULL = anonymous; +/// hosts running `--require-pairing` reject anonymous sessions. +/// /// # Safety /// `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform); -/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes. +/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes; +/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8. #[cfg(feature = "quic")] #[no_mangle] pub unsafe extern "C" fn punktfunk_connect( @@ -486,6 +504,8 @@ pub unsafe extern "C" fn punktfunk_connect( refresh_hz: u32, pin_sha256: *const u8, observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, timeout_ms: u32, ) -> *mut PunktfunkConnection { let r = std::panic::catch_unwind(AssertUnwindSafe(|| { @@ -508,11 +528,19 @@ pub unsafe extern "C" fn punktfunk_connect( p.copy_from_slice(unsafe { std::slice::from_raw_parts(pin_sha256, 32) }); Some(p) }; + let identity = match (unsafe { opt_cstr(client_cert_pem) }, unsafe { + opt_cstr(client_key_pem) + }) { + (Ok(Some(c)), Ok(Some(k))) => Some((c.to_string(), k.to_string())), + (Ok(None), Ok(None)) => None, + _ => return std::ptr::null_mut(), // half an identity / bad UTF-8: fail closed + }; match crate::client::NativeClient::connect( host, port, mode, pin, + identity, std::time::Duration::from_millis(timeout_ms as u64), ) { Ok(c) => { @@ -534,6 +562,97 @@ pub unsafe extern "C" fn punktfunk_connect( r.unwrap_or(std::ptr::null_mut()) } +/// Generate a persistent client identity: a self-signed certificate + private key, both +/// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both +/// strings (Keychain etc.), pass them to [`punktfunk_pair`] and every +/// [`punktfunk_connect`] — the certificate's fingerprint is how hosts recognize this +/// client. 4096-byte buffers are ample. +/// +/// # Safety +/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_generate_identity( + cert_pem_out: *mut std::os::raw::c_char, + cert_cap: usize, + key_pem_out: *mut std::os::raw::c_char, + key_cap: usize, +) -> PunktfunkStatus { + guard(|| { + if cert_pem_out.is_null() || key_pem_out.is_null() { + return PunktfunkStatus::NullPointer; + } + let (cert, key) = match crate::quic::endpoint::generate_identity() { + Ok(t) => t, + Err(_) => return PunktfunkStatus::Io, + }; + if cert.len() + 1 > cert_cap || key.len() + 1 > key_cap { + return PunktfunkStatus::InvalidArg; + } + unsafe { + std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out as *mut u8, cert.len()); + *cert_pem_out.add(cert.len()) = 0; + std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out as *mut u8, key.len()); + *key_pem_out.add(key.len()) = 0; + } + PunktfunkStatus::Ok + }) +} + +/// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core): +/// the host displays a short PIN; the user types it into the client app, which passes it +/// here. On success the host has stored this client's identity, the now-verified host +/// fingerprint is written to `host_sha256_out` (32 bytes) — persist it and pass it as +/// `pin_sha256` to [`punktfunk_connect`] from then on. Returns +/// [`PunktfunkStatus::Crypto`] for a wrong PIN. +/// +/// # Safety +/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8; +/// `host_sha256_out` is writable for 32 bytes. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_pair( + host: *const std::os::raw::c_char, + port: u16, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + pin: *const std::os::raw::c_char, + name: *const std::os::raw::c_char, + host_sha256_out: *mut u8, + timeout_ms: u32, +) -> PunktfunkStatus { + guard(|| { + let (Ok(Some(host)), Ok(Some(cert)), Ok(Some(key)), Ok(Some(pin)), Ok(Some(name))) = ( + unsafe { opt_cstr(host) }, + unsafe { opt_cstr(client_cert_pem) }, + unsafe { opt_cstr(client_key_pem) }, + unsafe { opt_cstr(pin) }, + unsafe { opt_cstr(name) }, + ) else { + return PunktfunkStatus::NullPointer; + }; + if host_sha256_out.is_null() { + return PunktfunkStatus::NullPointer; + } + match crate::client::NativeClient::pair( + host, + port, + (cert, key), + pin, + name, + std::time::Duration::from_millis(timeout_ms as u64), + ) { + Ok(fp) => { + unsafe { + std::slice::from_raw_parts_mut(host_sha256_out, 32).copy_from_slice(&fp); + } + PunktfunkStatus::Ok + } + Err(e) => e.status(), + } + }) +} + /// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns /// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended. /// On `Ok`, `*out` borrows connection memory **until the next `next_au` call** on this @@ -710,7 +829,8 @@ pub unsafe extern "C" fn punktfunk_connection_send_input( }) } -/// The host-confirmed session mode (from the Welcome). Safe any time after connect. +/// The currently active session mode — the Welcome's, until an accepted +/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect. /// /// # Safety /// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). @@ -727,21 +847,55 @@ pub unsafe extern "C" fn punktfunk_connection_mode( Some(c) => c, None => return PunktfunkStatus::NullPointer, }; + let mode = c.inner.mode(); unsafe { if !width.is_null() { - *width = c.inner.mode.width; + *width = mode.width; } if !height.is_null() { - *height = c.inner.mode.height; + *height = mode.height; } if !refresh_hz.is_null() { - *refresh_hz = c.inner.mode.refresh_hz; + *refresh_hz = mode.refresh_hz; } } PunktfunkStatus::Ok }) } +/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without +/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the +/// stream continues at the new mode — the first new-mode access unit is an IDR with +/// in-band parameter sets (rebuild the decoder from it) — and +/// [`punktfunk_connection_mode`] reflects the switch. A rejected request leaves the +/// session unchanged. +/// +/// # Safety +/// `c` is a valid connection handle. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connection_request_mode( + c: *const PunktfunkConnection, + width: u32, + height: u32, + refresh_hz: u32, +) -> PunktfunkStatus { + guard(|| { + let c = match unsafe { c.as_ref() } { + Some(c) => c, + None => return PunktfunkStatus::NullPointer, + }; + match c.inner.request_mode(crate::config::Mode { + width, + height, + refresh_hz, + }) { + Ok(()) => PunktfunkStatus::Ok, + Err(e) => e.status(), + } + }) +} + /// Close the connection and free the handle (joins the internal threads). NULL is a no-op. /// /// # Safety diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 2c329e2..9e94172 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -14,7 +14,7 @@ use crate::config::{Mode, Role}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; -use crate::quic::{endpoint, io, Hello, Start, Welcome}; +use crate::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome}; use crate::session::{Frame, Session}; use crate::transport::UdpTransport; use std::sync::atomic::{AtomicBool, Ordering}; @@ -50,10 +50,12 @@ pub struct NativeClient { audio: Receiver, rumble: Receiver<(u16, u16, u16)>, input_tx: tokio::sync::mpsc::UnboundedSender, + reconfig_tx: tokio::sync::mpsc::UnboundedSender, shutdown: Arc, worker: Option>, - /// The host-confirmed session mode (from the Welcome). - pub mode: Mode, + /// The currently active session mode (the Welcome's, then updated by every accepted + /// [`NativeClient::request_mode`]). + mode: Arc>, /// SHA-256 fingerprint of the certificate the host actually presented. A TOFU caller /// (`pin = None`) persists this and passes it as the pin from then on. pub host_fingerprint: [u8; 32], @@ -66,22 +68,30 @@ impl NativeClient { /// `pin`: expected SHA-256 of the host's certificate. `Some` and the host presents /// anything else → the handshake is rejected ([`PunktfunkError::Crypto`]). `None` = trust on /// first use; check [`NativeClient::host_fingerprint`] after connecting. + /// + /// `identity`: this client's persistent self-signed identity (PEM cert + PKCS#8 key, + /// see [`endpoint::generate_identity`]), presented via TLS client auth so a host can + /// recognize a paired client. `None` = anonymous (rejected by hosts requiring pairing). pub fn connect( host: &str, port: u16, mode: Mode, pin: Option<[u8; 32]>, + identity: Option<(String, String)>, timeout: Duration, ) -> Result { let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::(FRAME_QUEUE); let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::(AUDIO_QUEUE); let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE); let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::>(); let shutdown = Arc::new(AtomicBool::new(false)); + let mode_slot = Arc::new(std::sync::Mutex::new(mode)); let host = host.to_string(); let shutdown_w = shutdown.clone(); + let mode_slot_w = mode_slot.clone(); let worker = std::thread::Builder::new() .name("punktfunk-client".into()) .spawn(move || { @@ -101,12 +111,15 @@ impl NativeClient { port, mode, pin, + identity, frame_tx, audio_tx, rumble_tx, input_rx, + reconfig_rx, ready_tx, shutdown: shutdown_w, + mode_slot: mode_slot_w, })); }) .map_err(PunktfunkError::Io)?; @@ -119,18 +132,100 @@ impl NativeClient { return Err(PunktfunkError::Timeout); } }; + *mode_slot.lock().unwrap() = negotiated; Ok(NativeClient { frames: frame_rx, audio: audio_rx, rumble: rumble_rx, input_tx, + reconfig_tx, shutdown, worker: Some(worker), - mode: negotiated, + mode: mode_slot, host_fingerprint: fingerprint, }) } + /// Run the PIN pairing ceremony against a host: connect (trust-on-first-use — the PIN + /// proof is what authenticates the certificates), prove knowledge of the PIN the host + /// is displaying, and return the host's now-verified fingerprint for pinning. The host + /// persists this client's fingerprint in its paired set. + /// + /// `identity` is this client's persistent PEM identity (cert, key) — the same one + /// later passed to [`NativeClient::connect`]; `pin` is what the user read off the host + /// (its log / UI); `name` is the label the host stores. + pub fn pair( + host: &str, + port: u16, + identity: (&str, &str), + pin: &str, + name: &str, + timeout: Duration, + ) -> Result<[u8; 32]> { + use crate::quic::{PairChallenge, PairRequest, PairResult}; + + let client_fp = endpoint::fingerprint_of_pem(identity.0) + .map_err(|_| PunktfunkError::InvalidArg("client cert pem"))?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(PunktfunkError::Io)?; + let pin = pin.to_string(); + let name = name.to_string(); + let remote: std::net::SocketAddr = format!("{host}:{port}") + .parse() + .map_err(|_| PunktfunkError::InvalidArg("host:port"))?; + + rt.block_on(async move { + // The quinn endpoint must be created inside the runtime (it spawns its driver). + let (ep, observed) = endpoint::client_pinned_with_identity(None, Some(identity)); + let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; + let ceremony = async { + let conn = ep + .connect(remote, "punktfunk") + .map_err(|_| PunktfunkError::InvalidArg("connect"))? + .await + .map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; + let host_fp = observed.lock().unwrap().ok_or(PunktfunkError::Crypto)?; + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; + + io::write_msg(&mut send, &PairRequest { name }.encode()).await?; + let challenge = PairChallenge::decode(&io::read_msg(&mut recv).await?)?; + let proof = crate::quic::pair_proof(&pin, &challenge.salt, &client_fp, &host_fp); + io::write_msg(&mut send, &crate::quic::PairProof { hmac: proof }.encode()).await?; + let result = PairResult::decode(&io::read_msg(&mut recv).await?)?; + conn.close(0u32.into(), b"pair done"); + if result.ok { + Ok(host_fp) + } else { + Err(PunktfunkError::Crypto) // wrong PIN (or refused) + } + }; + tokio::time::timeout(timeout, ceremony) + .await + .map_err(|_| PunktfunkError::Timeout)? + }) + } + + /// The currently active session mode — the Welcome's, until an accepted + /// [`NativeClient::request_mode`] switches it. + pub fn mode(&self) -> Mode { + *self.mode.lock().unwrap() + } + + /// Ask the host to switch the live session to `mode` (no reconnect). Non-blocking: + /// the request is queued; on acceptance the stream continues at the new mode (next + /// frames open with an IDR carrying new parameter sets) and [`NativeClient::mode`] + /// reflects it. A rejected request leaves the session unchanged. + pub fn request_mode(&self, mode: Mode) -> Result<()> { + self.reconfig_tx + .send(mode) + .map_err(|_| PunktfunkError::Closed) + } + /// Pull the next reassembled, FEC-recovered access unit; [`PunktfunkError::NoFrame`] on /// timeout, [`PunktfunkError::Closed`]-class errors once the session ended. /// @@ -187,33 +282,43 @@ struct WorkerArgs { port: u16, mode: Mode, pin: Option<[u8; 32]>, + identity: Option<(String, String)>, frame_tx: SyncSender, audio_tx: SyncSender, rumble_tx: SyncSender<(u16, u16, u16)>, input_rx: tokio::sync::mpsc::UnboundedReceiver, + reconfig_rx: tokio::sync::mpsc::UnboundedReceiver, ready_tx: std::sync::mpsc::Sender>, shutdown: Arc, + mode_slot: Arc>, } -/// The worker: QUIC handshake, then the input/datagram tasks + the blocking data-plane pump. +/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking +/// data-plane pump. async fn worker_main(args: WorkerArgs) { let WorkerArgs { host, port, mode, pin, + identity, frame_tx, audio_tx, rumble_tx, mut input_rx, + mut reconfig_rx, ready_tx, shutdown, + mode_slot, } = args; let setup = async { let remote: std::net::SocketAddr = format!("{host}:{port}") .parse() .map_err(|_| PunktfunkError::InvalidArg("host:port"))?; - let (ep, observed) = endpoint::client_pinned(pin); + let (ep, observed) = endpoint::client_pinned_with_identity( + pin, + identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())), + ); let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; let conn = ep .connect(remote, "punktfunk") @@ -264,16 +369,17 @@ async fn worker_main(args: WorkerArgs) { let transport = UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?; let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?; - Ok::<_, PunktfunkError>((conn, session, welcome.mode, fingerprint)) + Ok::<_, PunktfunkError>((conn, session, send, recv, welcome.mode, fingerprint)) }; - let (conn, mut session, negotiated, fingerprint) = match setup.await { - Ok(t) => t, - Err(e) => { - let _ = ready_tx.send(Err(e)); - return; - } - }; + let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, fingerprint) = + match setup.await { + Ok(t) => t, + Err(e) => { + let _ = ready_tx.send(Err(e)); + return; + } + }; let _ = ready_tx.send(Ok((negotiated, fingerprint))); // Input task: embedder events → QUIC datagrams. @@ -284,6 +390,35 @@ async fn worker_main(args: WorkerArgs) { } }); + // Control task: the handshake stream stays open for mid-stream renegotiation. One + // request at a time — write Reconfigure, await Reconfigured, publish the active mode. + { + let mode_slot = mode_slot.clone(); + tokio::spawn(async move { + while let Some(want) = reconfig_rx.recv().await { + if io::write_msg(&mut ctrl_send, &Reconfigure { mode: want }.encode()) + .await + .is_err() + { + break; + } + let ack = match io::read_msg(&mut ctrl_recv).await { + Ok(b) => match Reconfigured::decode(&b) { + Ok(a) => a, + Err(_) => break, // protocol error — stop renegotiating + }, + Err(_) => break, // stream closed + }; + if ack.accepted { + *mode_slot.lock().unwrap() = ack.mode; + tracing::info!(mode = ?ack.mode, "host accepted mode switch"); + } else { + tracing::warn!(requested = ?want, active = ?ack.mode, "host rejected mode switch"); + } + } + }); + } + // Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the // newest packet rather than backing up the QUIC receive path). let dgram_conn = conn.clone(); diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 0c90f05..08e3532 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -58,6 +58,179 @@ pub struct Start { pub client_udp_port: u16, } +/// `client → host`, any time after [`Start`]: switch the session to a new display mode +/// (window resized, refresh changed) without reconnecting. The host answers with +/// [`Reconfigured`]; on acceptance it rebuilds its virtual output + encoder at the new +/// mode and the stream continues over the unchanged data plane — the first new-mode frame +/// is an IDR with in-band parameter sets, which is all a decoder needs to follow. +/// +/// Post-handshake messages carry a type byte after the magic (the handshake itself is +/// positional and stays untyped for wire compatibility). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reconfigure { + pub mode: Mode, +} + +/// `host → client`: answer to [`Reconfigure`]. `accepted = false` means the requested +/// mode was rejected (e.g. exceeds encoder limits) and the session continues at `mode` +/// (the still-active one); `true` means `mode` is now being switched to live. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reconfigured { + pub accepted: bool, + pub mode: Mode, +} + +/// Type byte of [`Reconfigure`] (first byte after the magic). +pub const MSG_RECONFIGURE: u8 = 0x01; +/// Type byte of [`Reconfigured`]. +pub const MSG_RECONFIGURED: u8 = 0x02; + +// --------------------------------------------------------------------------------------------- +// Pairing ceremony (typed messages 0x10..): instead of a session Hello, a client may open +// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); +// the user types it into the client, which proves knowledge with an HMAC bound to BOTH +// certificate fingerprints — a MITM would need the PIN within its single attempt, and any +// substituted certificate changes the proof. On success the host persists the client's +// fingerprint (presented via QUIC client auth) and the client pins the host's. +// --------------------------------------------------------------------------------------------- + +/// Type byte of [`PairRequest`]. +pub const MSG_PAIR_REQUEST: u8 = 0x10; +/// Type byte of [`PairChallenge`]. +pub const MSG_PAIR_CHALLENGE: u8 = 0x11; +/// Type byte of [`PairProof`]. +pub const MSG_PAIR_PROOF: u8 = 0x12; +/// Type byte of [`PairResult`]. +pub const MSG_PAIR_RESULT: u8 = 0x13; + +/// `client → host`: begin pairing. `name` is the human label the host stores (e.g. +/// "Enrico's Mac"), at most 64 bytes of UTF-8. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PairRequest { + pub name: String, +} + +/// `host → client`: a fresh random salt for the proof; the host has generated (and is +/// displaying) the PIN. One proof attempt per challenge. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairChallenge { + pub salt: [u8; 16], +} + +/// `client → host`: the proof, see [`pair_proof`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairProof { + pub hmac: [u8; 32], +} + +/// `host → client`: ceremony outcome. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PairResult { + pub ok: bool, +} + +impl PairRequest { + pub fn encode(&self) -> Vec { + let name = self.name.as_bytes(); + let n = name.len().min(64); + let mut b = Vec::with_capacity(6 + n); + b.extend_from_slice(MAGIC); + b.push(MSG_PAIR_REQUEST); + b.push(n as u8); + b.extend_from_slice(&name[..n]); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 6 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_REQUEST { + return Err(PunktfunkError::InvalidArg("bad PairRequest")); + } + let n = b[5] as usize; + if n > 64 || b.len() < 6 + n { + return Err(PunktfunkError::InvalidArg("bad PairRequest name")); + } + Ok(PairRequest { + name: String::from_utf8_lossy(&b[6..6 + n]).into_owned(), + }) + } +} + +impl PairChallenge { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(21); + b.extend_from_slice(MAGIC); + b.push(MSG_PAIR_CHALLENGE); + b.extend_from_slice(&self.salt); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 21 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_CHALLENGE { + return Err(PunktfunkError::InvalidArg("bad PairChallenge")); + } + let mut salt = [0u8; 16]; + salt.copy_from_slice(&b[5..21]); + Ok(PairChallenge { salt }) + } +} + +impl PairProof { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(37); + b.extend_from_slice(MAGIC); + b.push(MSG_PAIR_PROOF); + b.extend_from_slice(&self.hmac); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 37 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_PROOF { + return Err(PunktfunkError::InvalidArg("bad PairProof")); + } + let mut hmac = [0u8; 32]; + hmac.copy_from_slice(&b[5..37]); + Ok(PairProof { hmac }) + } +} + +impl PairResult { + pub fn encode(&self) -> Vec { + let mut b = Vec::with_capacity(6); + b.extend_from_slice(MAGIC); + b.push(MSG_PAIR_RESULT); + b.push(self.ok as u8); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 6 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_RESULT { + return Err(PunktfunkError::InvalidArg("bad PairResult")); + } + Ok(PairResult { ok: b[5] != 0 }) + } +} + +/// The pairing proof both sides compute: `HMAC-SHA256(key = PIN ‖ salt, +/// msg = client_fp ‖ host_fp)`. Binding both fingerprints into the MAC means a +/// man-in-the-middle (whose certificates differ on at least one side) cannot replay or +/// forward a valid proof; the PIN is single-attempt on the host, so a 4-digit space +/// cannot be searched online. +pub fn pair_proof( + pin: &str, + salt: &[u8; 16], + client_fp: &[u8; 32], + host_fp: &[u8; 32], +) -> [u8; 32] { + use hmac::{Hmac, Mac}; + let mut key = Vec::with_capacity(pin.len() + 16); + key.extend_from_slice(pin.as_bytes()); + key.extend_from_slice(salt); + let mut mac = as Mac>::new_from_slice(&key).expect("hmac key"); + mac.update(client_fp); + mac.update(host_fp); + mac.finalize().into_bytes().into() +} + impl Hello { pub fn encode(&self) -> Vec { let mut b = Vec::with_capacity(20); @@ -177,6 +350,62 @@ impl Start { } } +impl Reconfigure { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] w[5..9] h[9..13] hz[13..17] + let mut b = Vec::with_capacity(17); + b.extend_from_slice(MAGIC); + b.push(MSG_RECONFIGURE); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 17 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURE { + return Err(PunktfunkError::InvalidArg("bad Reconfigure")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + Ok(Reconfigure { + mode: Mode { + width: u32at(5), + height: u32at(9), + refresh_hz: u32at(13), + }, + }) + } +} + +impl Reconfigured { + pub fn encode(&self) -> Vec { + // magic[0..4] type[4] accepted[5] w[6..10] h[10..14] hz[14..18] + let mut b = Vec::with_capacity(18); + b.extend_from_slice(MAGIC); + b.push(MSG_RECONFIGURED); + b.push(self.accepted as u8); + b.extend_from_slice(&self.mode.width.to_le_bytes()); + b.extend_from_slice(&self.mode.height.to_le_bytes()); + b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b + } + + pub fn decode(b: &[u8]) -> Result { + if b.len() < 18 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURED { + return Err(PunktfunkError::InvalidArg("bad Reconfigured")); + } + let u32at = |o: usize| u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]); + Ok(Reconfigured { + accepted: b[5] != 0, + mode: Mode { + width: u32at(6), + height: u32at(10), + refresh_hz: u32at(14), + }, + }) + } +} + /// Frame a message for the control stream: `u16 LE length || payload`. pub fn frame(payload: &[u8]) -> Vec { let mut b = Vec::with_capacity(2 + payload.len()); @@ -293,11 +522,38 @@ pub mod endpoint { key_der: rustls::pki_types::PrivateKeyDer<'static>, addr: std::net::SocketAddr, ) -> anyhow_result::Result { - let server_config = quinn::ServerConfig::with_single_cert(vec![cert_der], key_der) + let _ = rustls::crypto::ring::default_provider().install_default(); + // Client auth is OFFERED but optional: a client that presents its self-signed + // identity is fingerprinted post-handshake (pairing / --require-pairing checks); + // one that presents none still connects (and is rejected at the app layer when + // pairing is required). + let rustls_cfg = rustls::ServerConfig::builder() + .with_client_cert_verifier(Arc::new(AcceptAnyClientCert)) + .with_single_cert(vec![cert_der], key_der) .map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?; + let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) + .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; + let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); Ok(quinn::Endpoint::server(server_config, addr)?) } + /// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a + /// client persists once and presents on every connect so hosts can recognize it. + pub fn generate_identity() -> anyhow_result::Result<(String, String)> { + let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()]) + .map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?; + Ok((cert.cert.pem(), cert.key_pair.serialize_pem())) + } + + /// Fingerprint of the client certificate a connection presented (host side), if any. + pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> { + let identity = conn.peer_identity()?; + let certs = identity + .downcast::>>() + .ok()?; + certs.first().map(|c| cert_fingerprint(c.as_ref())) + } + /// SHA-256 of a certificate's DER encoding — the fingerprint clients pin. pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] { use sha2::Digest; @@ -332,16 +588,40 @@ pub mod endpoint { /// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is /// written to the returned slot during the handshake, so a TOFU caller can persist it. pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient { + client_pinned_with_identity(pin, None) + } + + /// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8 + /// key) via TLS client auth — how a paired client identifies itself to the host. + pub fn client_pinned_with_identity( + pin: Option<[u8; 32]>, + identity: Option<(&str, &str)>, + ) -> PinnedClient { let observed = Arc::new(Mutex::new(None)); let ep = (|| { let _ = rustls::crypto::ring::default_provider().install_default(); - let rustls_cfg = rustls::ClientConfig::builder() + let builder = rustls::ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new(PinVerify { pin, observed: observed.clone(), - })) - .with_no_client_auth(); + })); + let rustls_cfg = match identity { + None => builder.with_no_client_auth(), + Some((cert_pem, key_pem)) => { + use rustls::pki_types::pem::PemObject; + let cert = + rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes()) + .map_err(|e| { + anyhow_result::Error::msg(format!("client cert pem: {e}")) + })?; + let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes()) + .map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?; + builder + .with_client_auth_cert(vec![cert], key) + .map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))? + } + }; let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg) .map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?; let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?; @@ -377,6 +657,69 @@ pub mod endpoint { /// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf /// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what /// it saw, so the embedder can persist the fingerprint and pin it from then on. + /// Server-side client-cert verifier: accept any (self-signed) client certificate but + /// verify the handshake signature for real — possession of the presented cert's key is + /// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful. + /// Authorization (is this fingerprint paired?) happens at the application layer. + #[derive(Debug)] + struct AcceptAnyClientCert; + + impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert { + fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] { + &[] + } + + fn client_auth_mandatory(&self) -> bool { + false // unpaired/legacy clients still connect; gating is per-feature + } + + fn verify_client_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _now: rustls::pki_types::UnixTime, + ) -> std::result::Result + { + Ok(rustls::server::danger::ClientCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result + { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &rustls::pki_types::CertificateDer<'_>, + dss: &rustls::DigitallySignedStruct, + ) -> std::result::Result + { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &rustls::crypto::ring::default_provider().signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + rustls::crypto::ring::default_provider() + .signature_verification_algorithms + .supported_schemes() + } + } + #[derive(Debug)] struct PinVerify { pin: Option<[u8; 32]>, @@ -492,6 +835,34 @@ mod tests { assert_eq!(Start::decode(&s.encode()).unwrap(), s); } + #[test] + fn reconfigure_roundtrip() { + let rq = Reconfigure { + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 144, + }, + }; + assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq); + for accepted in [true, false] { + let rs = Reconfigured { + accepted, + mode: rq.mode, + }; + assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs); + } + // The type byte separates the post-handshake messages from each other. + assert!(Reconfigure::decode( + &Reconfigured { + accepted: true, + mode: rq.mode + } + .encode() + ) + .is_err()); + } + #[test] fn audio_datagram_roundtrip() { let opus = [0x42u8; 97]; diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 7a92ec6..b9dfdcd 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -26,7 +26,10 @@ use anyhow::{anyhow, Context, Result}; use punktfunk_core::config::{FecConfig, FecScheme, Role}; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF}; -use punktfunk_core::quic::{endpoint, io, Hello, Start, Welcome}; +use punktfunk_core::quic::{ + endpoint, io, Hello, PairChallenge, PairProof, PairRequest, PairResult, Reconfigure, + Reconfigured, Start, Welcome, +}; use punktfunk_core::transport::UdpTransport; use punktfunk_core::Session; use rand::RngCore; @@ -50,6 +53,62 @@ pub struct M3Options { pub frames: u32, /// Exit after this many sessions (0 = serve forever). pub max_sessions: u32, + /// Only serve clients whose certificate fingerprint is in the paired set (pairing + /// ceremonies themselves are always allowed — that's how a client gets in). + pub require_pairing: bool, + /// Fixed pairing PIN (tests); `None` = a fresh random 4-digit PIN per ceremony. + pub pairing_pin: Option, + /// Paired-clients store path override (tests); `None` = the default config path. + pub paired_store: Option, +} + +/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`. +/// (Separate from GameStream pairing, which has its own store and ceremony.) +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct PairedClients { + clients: Vec, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct PairedClient { + name: String, + /// Hex SHA-256 of the client's certificate. + fingerprint: String, +} + +/// The store plus where it persists (the path is injectable for tests). +struct PairedState { + path: std::path::PathBuf, + clients: PairedClients, +} + +type PairedStore = Arc>; + +fn paired_path() -> Result { + let home = std::env::var("HOME").context("HOME unset")?; + Ok(std::path::PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json")) +} + +fn load_paired(path: &std::path::Path) -> PairedClients { + std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default() +} + +fn save_paired(state: &PairedState) -> Result<()> { + if let Some(dir) = state.path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&state.path, serde_json::to_vec_pretty(&state.clients)?)?; + Ok(()) +} + +impl PairedClients { + fn contains(&self, fp: &[u8; 32]) -> bool { + let hex = fingerprint_hex(fp); + self.clients.iter().any(|c| c.fingerprint == hex) + } } /// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping). @@ -107,6 +166,18 @@ async fn serve(opts: M3Options) -> Result<()> { // One audio capturer for the whole host lifetime, handed from session to session // (PipeWire streams have no cheap teardown — see AudioCapSlot). let audio_cap: AudioCapSlot = Arc::new(std::sync::Mutex::new(None)); + let paired_at = match &opts.paired_store { + Some(p) => p.clone(), + None => paired_path()?, + }; + let paired: PairedStore = Arc::new(std::sync::Mutex::new(PairedState { + clients: load_paired(&paired_at), + path: paired_at, + })); + if opts.require_pairing { + let n = paired.lock().unwrap().clients.clients.len(); + tracing::info!(paired = n, "pairing required for sessions"); + } let mut served = 0u32; loop { @@ -123,7 +194,7 @@ async fn serve(opts: M3Options) -> Result<()> { }; let peer = conn.remote_address(); tracing::info!(%peer, "punktfunk/1 client connected"); - if let Err(e) = serve_session(conn, &opts, &audio_cap).await { + if let Err(e) = serve_session(conn, &opts, &audio_cap, &fingerprint, &paired).await { tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error"); } else { tracing::info!(%peer, "session complete"); @@ -147,28 +218,119 @@ const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10 /// PipeWire thread + core connection + live capture node on the daemon every session. type AudioCapSlot = Arc>>>; +/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the +/// client), so its budget is far larger than the machine-speed session handshake. +const PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// The host side of the PIN ceremony (see `punktfunk_core::quic::pair_proof`): generate a +/// PIN, display it (log), challenge with a fresh salt, verify the client's single proof +/// attempt, and persist the client's certificate fingerprint on success. +async fn pair_ceremony( + conn: &quinn::Connection, + mut send: quinn::SendStream, + mut recv: quinn::RecvStream, + req: PairRequest, + host_fp: &[u8; 32], + paired: &PairedStore, + opts: &M3Options, +) -> Result<()> { + let client_fp = endpoint::peer_fingerprint(conn) + .ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?; + + let pin = opts.pairing_pin.clone().unwrap_or_else(|| { + use rand::Rng; + format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) + }); + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + tracing::info!( + name = %req.name, + client = %fingerprint_hex(&client_fp), + "PAIRING REQUEST — enter this PIN on the client: {pin}" + ); + + io::write_msg(&mut send, &PairChallenge { salt }.encode()).await?; + let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv)) + .await + .map_err(|_| anyhow!("pairing timed out waiting for the PIN proof"))??; + let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?; + + let expected = punktfunk_core::quic::pair_proof(&pin, &salt, &client_fp, host_fp); + // Constant-time compare — don't leak a prefix-match timing oracle on the proof. + let ok = proof + .hmac + .iter() + .zip(expected.iter()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0; + + if ok { + let mut store = paired.lock().unwrap(); + let hex = fingerprint_hex(&client_fp); + store.clients.clients.retain(|c| c.fingerprint != hex); // re-pair updates the name + store.clients.clients.push(PairedClient { + name: req.name.clone(), + fingerprint: hex, + }); + if let Err(e) = save_paired(&store) { + tracing::error!(error = %format!("{e:#}"), "could not persist paired clients"); + } + tracing::info!(name = %req.name, "pairing complete — client trusted"); + } else { + tracing::warn!(name = %req.name, "pairing FAILED (wrong PIN) — fingerprint not stored"); + } + io::write_msg(&mut send, &PairResult { ok }.encode()).await?; + let _ = send.finish(); + // Let the result reach the client before the connection drops. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + conn.close(0u32.into(), b"pairing done"); + anyhow::ensure!(ok, "pairing rejected (wrong PIN)"); + Ok(()) +} + /// One client session: handshake → input/audio planes → data plane until done/disconnect. /// Everything torn down on return (RAII: virtual output, encoder, threads via channel close). +/// A connection whose first message is a PairRequest runs the pairing ceremony instead. async fn serve_session( conn: quinn::Connection, opts: &M3Options, audio_cap: &AudioCapSlot, + host_fp: &[u8; 32], + paired: &PairedStore, ) -> Result<()> { let peer = conn.remote_address(); + // First message decides what this connection is: a pairing ceremony or a session. + let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi()) + .await + .map_err(|_| anyhow!("control stream timeout"))? + .context("accept control stream")?; + let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv)) + .await + .map_err(|_| anyhow!("first message timeout"))??; + if let Ok(req) = PairRequest::decode(&first) { + return pair_ceremony(&conn, send, recv, req, host_fp, paired, opts).await; + } + let source = opts.source; let frames = opts.frames; let handshake = async { - let (mut send, mut recv) = conn.accept_bi().await.context("accept control stream")?; - - let hello = Hello::decode(&io::read_msg(&mut recv).await?) - .map_err(|e| anyhow!("Hello decode: {e:?}"))?; + let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; anyhow::ensure!( hello.abi_version == punktfunk_core::ABI_VERSION, "ABI mismatch: client {} host {}", hello.abi_version, punktfunk_core::ABI_VERSION ); + if opts.require_pairing { + let known = endpoint::peer_fingerprint(&conn) + .map(|fp| paired.lock().unwrap().clients.contains(&fp)) + .unwrap_or(false); + anyhow::ensure!( + known, + "unpaired client rejected (this host requires pairing — run the PIN ceremony first)" + ); + } crate::encode::validate_dimensions( crate::encode::Codec::H265, hello.mode.width, @@ -211,9 +373,46 @@ async fn serve_session( let (hello, welcome, udp_port, start) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) .await .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; + let (mut ctrl_send, mut ctrl_recv) = (send, recv); let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port); tracing::info!(%client_udp, udp_port, mode = ?hello.mode, "handshake complete — streaming"); + // Control task: the handshake stream stays open for mid-stream renegotiation. A + // validated Reconfigure is acked, then handed to the data-plane thread, which rebuilds + // capture/encoder/virtual output at the new mode (the data plane itself is untouched). + let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::(); + tokio::spawn(async move { + let mut active = hello.mode; + while let Ok(msg) = io::read_msg(&mut ctrl_recv).await { + let Ok(req) = Reconfigure::decode(&msg) else { + tracing::warn!("unknown control message — ignoring"); + continue; + }; + let ok = crate::encode::validate_dimensions( + crate::encode::Codec::H265, + req.mode.width, + req.mode.height, + ) + .is_ok(); + if ok { + active = req.mode; + tracing::info!(mode = ?req.mode, "mode switch accepted"); + } else { + tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)"); + } + let ack = Reconfigured { + accepted: ok, + mode: active, + }; + if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() { + break; + } + if ok && reconfig_tx.send(req.mode).is_err() { + break; // data plane gone + } + } + }); + // Input plane: QUIC datagrams → channel → a native injector thread (the injector owns // non-Send compositor state, so it lives on its own thread). The thread also owns the // session's virtual gamepads and sends force feedback back over `conn`. It exits when @@ -283,7 +482,9 @@ async fn serve_session( .map_err(|e| anyhow!("host session: {e:?}"))?; match source { M3Source::Synthetic => synthetic_stream(&mut session, frames, &stop_stream), - M3Source::Virtual => virtual_stream(&mut session, mode, seconds, &stop_stream), + M3Source::Virtual => { + virtual_stream(&mut session, mode, seconds, &stop_stream, &reconfig_rx) + } } }) .await @@ -548,37 +749,36 @@ fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Re /// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs /// stamped with the capture wall clock (the client derives per-frame pipeline latency). +/// +/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is +/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its +/// keepalive, the virtual output) while the data-plane `session` continues untouched — +/// the rebuilt encoder opens with an IDR + in-band parameter sets. fn virtual_stream( session: &mut Session, mode: punktfunk_core::Mode, seconds: u32, stop: &AtomicBool, + reconfig: &std::sync::mpsc::Receiver, ) -> Result<()> { let compositor = crate::vdisplay::detect().context("detect compositor")?; tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display"); let mut vd = crate::vdisplay::open(compositor)?; - let vout = vd.create(mode).context("create virtual output")?; - let mut capturer = - crate::capture::capture_virtual_output(vout).context("capture virtual output")?; - capturer.set_active(true); + let (mut capturer, mut enc, mut frame, mut interval) = build_pipeline(&mut vd, mode)?; - let mut frame = capturer.next_frame().context("first frame")?; - let mut enc = crate::encode::open_video( - crate::encode::Codec::H265, - frame.format, - frame.width, - frame.height, - mode.refresh_hz, - 20_000_000, - frame.is_cuda(), - ) - .context("open NVENC")?; - - let interval = std::time::Duration::from_secs_f64(1.0 / mode.refresh_hz.max(1) as f64); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64); let mut next = std::time::Instant::now(); let mut sent: u64 = 0; while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline { + if let Ok(new_mode) = reconfig.try_recv() { + tracing::info!(?new_mode, "rebuilding pipeline for mode switch"); + // Tear down in order — capture stream (and with it the virtual output) before + // the new output appears, encoder with it. The data plane keeps running. + drop(enc); + drop(capturer); + (capturer, enc, frame, interval) = build_pipeline(&mut vd, new_mode)?; + next = std::time::Instant::now(); + } if let Some(f) = capturer.try_latest().context("capture")? { frame = f; } @@ -605,6 +805,38 @@ fn virtual_stream( Ok(()) } +/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval). +/// Dropping the capturer tears down the PipeWire stream and the virtual output with it. +type Pipeline = ( + Box, + Box, + crate::capture::CapturedFrame, + std::time::Duration, +); + +fn build_pipeline( + vd: &mut Box, + mode: punktfunk_core::Mode, +) -> Result { + let vout = vd.create(mode).context("create virtual output")?; + let mut capturer = + crate::capture::capture_virtual_output(vout).context("capture virtual output")?; + capturer.set_active(true); + let frame = capturer.next_frame().context("first frame")?; + let enc = crate::encode::open_video( + crate::encode::Codec::H265, + frame.format, + frame.width, + frame.height, + mode.refresh_hz, + 20_000_000, + frame.is_cuda(), + ) + .context("open NVENC")?; + let interval = std::time::Duration::from_secs_f64(1.0 / mode.refresh_hz.max(1) as f64); + Ok((capturer, enc, frame, interval)) +} + #[cfg(test)] mod tests { use super::*; @@ -692,6 +924,9 @@ mod tests { seconds: 0, frames: 25, max_sessions: 3, + require_pairing: false, + pairing_pin: None, + paired_store: None, }) }); std::thread::sleep(std::time::Duration::from_millis(500)); @@ -708,6 +943,8 @@ mod tests { 60, std::ptr::null(), observed.as_mut_ptr(), + std::ptr::null(), + std::ptr::null(), 10_000, ) }; @@ -721,6 +958,30 @@ mod tests { ); assert_eq!((w, h, hz), (1280, 720, 60)); + // Mid-stream renegotiation: request a new mode, the host acks on the control + // stream, and punktfunk_connection_mode reflects the switch. + assert_eq!( + unsafe { + punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144) + }, + PunktfunkStatus::Ok + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + assert_eq!( + unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) }, + PunktfunkStatus::Ok + ); + if (w, h, hz) == (1920, 1080, 144) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "mode switch not acked (still {w}x{h}@{hz})" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + } + unsafe { pull_verified(conn, 25) }; let ev = punktfunk_core::input::InputEvent { @@ -747,6 +1008,8 @@ mod tests { 60, observed.as_ptr(), std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), 10_000, ) }; @@ -765,6 +1028,8 @@ mod tests { 60, bad.as_ptr(), std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), 10_000, ) }; @@ -782,6 +1047,8 @@ mod tests { 60, std::ptr::null(), std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), 10_000, ) }; @@ -791,4 +1058,75 @@ mod tests { host.join().unwrap().unwrap(); } + + fn test_paired_path() -> std::path::PathBuf { + std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id())) + } + + /// The PIN pairing ceremony + the --require-pairing gate, end to end in-process: + /// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired + /// identity gets a session on a pairing-required host; an anonymous client does not. + #[test] + fn pairing_ceremony_and_gate() { + use punktfunk_core::client::NativeClient; + use punktfunk_core::quic::endpoint; + + let host = std::thread::spawn(|| { + run(M3Options { + port: 19778, + source: M3Source::Synthetic, + seconds: 0, + frames: 25, + max_sessions: 4, + require_pairing: true, + pairing_pin: Some("4321".into()), + paired_store: Some(test_paired_path()), + }) + }); + std::thread::sleep(std::time::Duration::from_millis(500)); + let timeout = std::time::Duration::from_secs(10); + let (cert, key) = endpoint::generate_identity().unwrap(); + let identity = (cert.as_str(), key.as_str()); + let mode = punktfunk_core::Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }; + + // 1: wrong PIN → Crypto, nothing stored. + let err = NativeClient::pair("127.0.0.1", 19778, identity, "0000", "imposter", timeout) + .unwrap_err(); + assert!( + matches!(err, punktfunk_core::PunktfunkError::Crypto), + "{err:?}" + ); + + // 2: anonymous session on a pairing-required host → rejected (connect fails). + assert!( + NativeClient::connect("127.0.0.1", 19778, mode, None, None, timeout).is_err(), + "anonymous session must be rejected" + ); + + // 3: correct PIN → paired, host fingerprint returned. + let host_fp = + NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout) + .expect("pairing with the right PIN"); + assert!(test_paired_path().exists()); + let _ = std::fs::remove_file(test_paired_path()); // already loaded; tidy /tmp + + // 4: the paired identity gets a session — pinned to the ceremony's fingerprint. + let client = NativeClient::connect( + "127.0.0.1", + 19778, + mode, + Some(host_fp), + Some((cert.clone(), key.clone())), + timeout, + ) + .expect("paired session"); + assert_eq!(client.host_fingerprint, host_fp); + drop(client); + + host.join().unwrap().unwrap(); + } } diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index f812888..7902ab0 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -90,6 +90,9 @@ fn real_main() -> Result<()> { max_sessions: get("--max-sessions") .and_then(|s| s.parse().ok()) .unwrap_or(0), + require_pairing: args.iter().any(|a| a == "--require-pairing"), + pairing_pin: None, + paired_store: None, }) } Some("-h") | Some("--help") | Some("help") | None => { @@ -317,6 +320,8 @@ M3-HOST OPTIONS: --seconds per-session stream duration, virtual source (default: 30) --frames per-session frame count, synthetic source (default: 300) --max-sessions exit after N sessions; 0 = serve forever (default: 0) + --require-pairing only serve PIN-paired clients (the host logs a 4-digit + PIN when a client starts the ceremony) M0 OPTIONS: --source diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 96a6c37..e67785c 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -82,6 +82,36 @@ // `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`. #define MAX_DATAGRAM_BYTES 2048 +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`Reconfigure`] (first byte after the magic). +#define MSG_RECONFIGURE 1 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`Reconfigured`]. +#define MSG_RECONFIGURED 2 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`PairRequest`]. +#define MSG_PAIR_REQUEST 16 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`PairChallenge`]. +#define MSG_PAIR_CHALLENGE 17 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`PairProof`]. +#define MSG_PAIR_PROOF 18 +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Type byte of [`PairResult`]. +#define MSG_PAIR_RESULT 19 +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams, // demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8), @@ -324,9 +354,15 @@ PunktfunkStatus punktfunk_get_stats(PunktfunkSession *s, PunktfunkStats *out); // fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and // pass it as the pin on every later connect. // +// Identity: `client_cert_pem`/`client_key_pem` (both NULL, or both NUL-terminated PEM +// strings — see [`punktfunk_generate_identity`]) are presented via TLS client auth so a +// host can recognize this client once paired ([`punktfunk_pair`]). NULL = anonymous; +// hosts running `--require-pairing` reject anonymous sessions. +// // # Safety // `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform); -// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes. +// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes; +// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8. PunktfunkConnection *punktfunk_connect(const char *host, uint16_t port, uint32_t width, @@ -334,9 +370,47 @@ PunktfunkConnection *punktfunk_connect(const char *host, uint32_t refresh_hz, const uint8_t *pin_sha256, uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Generate a persistent client identity: a self-signed certificate + private key, both +// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both +// strings (Keychain etc.), pass them to [`punktfunk_pair`] and every +// [`punktfunk_connect`] — the certificate's fingerprint is how hosts recognize this +// client. 4096-byte buffers are ample. +// +// # Safety +// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`. +PunktfunkStatus punktfunk_generate_identity(char *cert_pem_out, + uintptr_t cert_cap, + char *key_pem_out, + uintptr_t key_cap); +#endif + +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core): +// the host displays a short PIN; the user types it into the client app, which passes it +// here. On success the host has stored this client's identity, the now-verified host +// fingerprint is written to `host_sha256_out` (32 bytes) — persist it and pass it as +// `pin_sha256` to [`punktfunk_connect`] from then on. Returns +// [`PunktfunkStatus::Crypto`] for a wrong PIN. +// +// # Safety +// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8; +// `host_sha256_out` is writable for 32 bytes. +PunktfunkStatus punktfunk_pair(const char *host, + uint16_t port, + const char *client_cert_pem, + const char *client_key_pem, + const char *pin, + const char *name, + uint8_t *host_sha256_out, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns // [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended. @@ -391,7 +465,8 @@ PunktfunkStatus punktfunk_connection_send_input(PunktfunkConnection *c, #endif #if defined(PUNKTFUNK_FEATURE_QUIC) -// The host-confirmed session mode (from the Welcome). Safe any time after connect. +// The currently active session mode — the Welcome's, until an accepted +// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect. // // # Safety // `c` is a valid connection handle; out pointers are writable (NULLs are skipped). @@ -401,6 +476,22 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c, uint32_t *refresh_hz); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without +// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the +// stream continues at the new mode — the first new-mode access unit is an IDR with +// in-band parameter sets (rebuild the decoder from it) — and +// [`punktfunk_connection_mode`] reflects the switch. A rejected request leaves the +// session unchanged. +// +// # Safety +// `c` is a valid connection handle. +PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c, + uint32_t width, + uint32_t height, + uint32_t refresh_hz); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Close the connection and free the handle (joins the internal threads). NULL is a no-op. //