From ff4fe197be21bb01f3908eb4ea3af01e00db1746 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 16:26:48 +0000 Subject: [PATCH] =?UTF-8?q?fix(punktfunk/1):=20adversarial-review=20fixes?= =?UTF-8?q?=20=E2=80=94=20SPAKE2=20pairing,=20renegotiation=20hardening,?= =?UTF-8?q?=20+more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch (1 critical, 11 major/minor confirmed). Fixes: CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search (all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2** (balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an "arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the PIN to build its first message, so it can't be minted per-connection). Validated live: wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams. Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout; atomic (temp+rename) paired-store writes. Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from the positional Hello namespace (a future abi_version can't be misparsed as a control message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature gained the identity params; header regenerated). Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a rebuild failure keeps the session on its current mode instead of killing it. GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed world-writable /tmp path — DoS / capture-misdirection by another local user). Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue, UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green, clippy -D warnings clean (both feature sets), fmt, C-ABI harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 5 +- Cargo.lock | 63 ++++ clients/apple/README.md | 12 +- .../PunktfunkKit/PunktfunkConnection.swift | 13 +- crates/punktfunk-client-rs/src/main.rs | 3 + crates/punktfunk-core/Cargo.toml | 3 +- crates/punktfunk-core/src/client.rs | 66 ++-- crates/punktfunk-core/src/lib.rs | 5 +- crates/punktfunk-core/src/quic.rs | 309 ++++++++++++++---- crates/punktfunk-host/src/gamestream/audio.rs | 8 +- crates/punktfunk-host/src/gamestream/rtsp.rs | 11 +- crates/punktfunk-host/src/m3.rs | 165 +++++++--- crates/punktfunk-host/src/main.rs | 6 +- crates/punktfunk-host/src/vdisplay/wlroots.rs | 36 +- include/punktfunk_core.h | 5 +- 15 files changed, 556 insertions(+), 154 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8ee1b03..0aa103f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,8 +38,9 @@ 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`), and a **PIN pairing ceremony** (host displays a 4-digit PIN, - proof = HMAC over both cert fingerprints, single attempt) establishes mutual trust: + `endpoint::client_pinned`), and a **SPAKE2 PIN pairing ceremony** (host arms pairing and displays a + 4-digit PIN; a PAKE binds both cert fingerprints so an attacker gets one online guess, + no offline dictionary attack) 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 diff --git a/Cargo.lock b/Cargo.lock index 293e09c..652556d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -669,6 +669,32 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version", + "subtle", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "data-encoding" version = "2.11.0" @@ -864,6 +890,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1143,6 +1175,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1936,6 +1977,7 @@ dependencies = [ "rustls", "rustls-pki-types", "sha2", + "spake2", "thiserror 2.0.18", "tokio", "tracing", @@ -2296,6 +2338,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -2667,6 +2718,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spake2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5482afe85a0b6ce956c945401598dbc527593c77ba51d0a87a586938b1b893a" +dependencies = [ + "curve25519-dalek", + "hkdf", + "rand_core 0.6.4", + "sha2", +] + [[package]] name = "spin" version = "0.9.8" diff --git a/clients/apple/README.md b/clients/apple/README.md index 304e852..e6a46a6 100644 --- a/clients/apple/README.md +++ b/clients/apple/README.md @@ -117,11 +117,13 @@ 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 — 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 +7. **Trust — the full ceremony exists now (SPAKE2).** `generateIdentity()` once (persist + both PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN + the host prints when it ARMS pairing (`--allow-pairing`/`--require-pairing`; one PIN + per arming window, shown at startup — the user reads it before pairing). Returns the + host's VERIFIED fingerprint; persist it and pass `pinSHA256:` + `identity:` to every + connect. Pairing is a real PAKE: a wrong PIN gets ONE online guess (no offline + dictionary attack), throwing `.wrongPIN`; a wrong-size pin throws `.invalidPin`. The TOFU flow `PunktfunkClient` already implements (fingerprint confirmation sheet, per-host `HostStore`, "Forget Identity") keeps working against hosts not running `--require-pairing`; upgrading the sheet to a PIN-entry field closes the remaining gap — with `--require-pairing` the host now diff --git a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift index 0eceb03..c9db306 100644 --- a/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift +++ b/clients/apple/Sources/PunktfunkKit/PunktfunkConnection.swift @@ -71,9 +71,9 @@ public struct ClientIdentity: Sendable { 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) + let rc = punktfunk_generate_identity(&cert, UInt(cert.count), &key, UInt(key.count)) + guard rc == PUNKTFUNK_STATUS_OK.rawValue else { + throw PunktfunkClientError.status(rc) } return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key)) } @@ -88,6 +88,9 @@ public func pair( timeoutMs: UInt32 = 90_000 ) throws -> Data { var observed = [UInt8](repeating: 0, count: 32) + // The C header types PunktfunkStatus as a bare int32 (C17, no enum import), so the ABI + // functions return Int32 directly — compare against the enum constants' rawValue, the + // same bridging the connection methods use (statusOK etc.). let rc = host.withCString { cs in identity.certPEM.withCString { cert in identity.keyPEM.withCString { key in @@ -99,10 +102,10 @@ public func pair( } } } - switch rc.rawValue { + switch rc { case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed) case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN - default: throw PunktfunkClientError.status(rc.rawValue) + default: throw PunktfunkClientError.status(rc) } } diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 7349c48..77efd27 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -11,6 +11,9 @@ //! //! `--pin <64-hex>` pins the host's certificate fingerprint (the host logs it at startup); //! without it the client trusts on first use and prints the observed fingerprint to pin. +//! `--pair ` runs the SPAKE2 pairing ceremony: read the PIN the host prints when it +//! arms pairing (`--allow-pairing`/`--require-pairing`), pass it here; on success the +//! client prints the verified host fingerprint to `--pin` from then on. //! Host→client datagrams (Opus audio, rumble) are counted and reported with the stream //! stats — decode/playback is the platform clients' job. //! diff --git a/crates/punktfunk-core/Cargo.toml b/crates/punktfunk-core/Cargo.toml index 7e90f6b..4b4609d 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", "dep:hmac"] +quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2"] [dependencies] reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2) @@ -42,6 +42,7 @@ rcgen = { version = "0.13", optional = true, default-features = false, features rustls-pki-types = { version = "1", optional = true } sha2 = { version = "0.10", optional = true } hmac = { version = "0.12", optional = true } +spake2 = { version = "0.4", optional = true } tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] } [dev-dependencies] diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 9e94172..9d5bd08 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -162,7 +162,7 @@ impl NativeClient { name: &str, timeout: Duration, ) -> Result<[u8; 32]> { - use crate::quic::{PairChallenge, PairRequest, PairResult}; + use crate::quic::{pake, PairChallenge, PairProof, PairRequest, PairResult}; let client_fp = endpoint::fingerprint_of_pem(identity.0) .map_err(|_| PunktfunkError::InvalidArg("client cert pem"))?; @@ -180,6 +180,40 @@ impl NativeClient { // 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())))?; + + // The SPAKE2 exchange over an already-open bi-stream; never closes the conn (the + // caller does, then flushes), so any early exit still lets the host see the close. + let exchange = |conn: quinn::Connection, host_fp: [u8; 32]| async move { + let (mut send, mut recv) = conn + .open_bi() + .await + .map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?; + // SPAKE2 as A, binding our fingerprint + the host cert we observed (TOFU). + let (pake, spake_a) = pake::start(true, &pin, &client_fp, &host_fp); + io::write_msg(&mut send, &PairRequest { name, spake_a }.encode()).await?; + let challenge = PairChallenge::decode(&io::read_msg(&mut recv).await?)?; + let confirms = pake.finish(&challenge.spake_b)?; + // The host's confirmation proves it reached the same key (right PIN, same + // certs) — only then do we pin it and send our own confirmation. + if !pake::verify(&confirms.host, &challenge.confirm) { + return Err(PunktfunkError::Crypto); // wrong PIN or MITM + } + io::write_msg( + &mut send, + &PairProof { + confirm: confirms.client, + } + .encode(), + ) + .await?; + let result = PairResult::decode(&io::read_msg(&mut recv).await?)?; + if result.ok { + Ok(host_fp) + } else { + Err(PunktfunkError::Crypto) // host rejected post-confirm + } + }; + let ceremony = async { let conn = ep .connect(remote, "punktfunk") @@ -187,26 +221,20 @@ impl NativeClient { .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) - } + let outcome = exchange(conn.clone(), host_fp).await; + // Always tell the host we're done so it never blocks at its read — code 0 on + // success, 1 on a refused/aborted ceremony. + let code: u32 = if outcome.is_ok() { 0 } else { 1 }; + conn.close(code.into(), b"pair done"); + outcome }; - tokio::time::timeout(timeout, ceremony) + let outcome = tokio::time::timeout(timeout, ceremony) .await - .map_err(|_| PunktfunkError::Timeout)? + .map_err(|_| PunktfunkError::Timeout)?; + // Flush the CONNECTION_CLOSE before the runtime is dropped — otherwise the host + // may never see it and would block at its read for the full pairing timeout. + let _ = tokio::time::timeout(Duration::from_secs(2), ep.wait_idle()).await; + outcome }) } diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 090b351..0253129 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -46,4 +46,7 @@ pub use stats::Stats; /// Bump on any breaking change to the [C ABI](crate::abi). Mirrors /// `punktfunk_abi_version()` and is checked by clients before use. -pub const ABI_VERSION: u32 = 1; +/// +/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities); +/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. +pub const ABI_VERSION: u32 = 2; diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 08e3532..4e2d291 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -25,9 +25,15 @@ use crate::config::{Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; use crate::error::{PunktfunkError, Result}; -/// Protocol magic + version, first bytes of every message payload. +/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start). pub const MAGIC: &[u8; 4] = b"PKF1"; +/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the +/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version +/// byte sits where a type byte would) can never be misparsed as a control message, and +/// vice-versa, regardless of field values. +pub const CTL_MAGIC: &[u8; 4] = b"PKFc"; + /// `client → host`: open the session, requesting a display mode (the host creates its /// virtual output at exactly this size/refresh — native resolution end to end). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -86,12 +92,19 @@ pub const MSG_RECONFIGURE: u8 = 0x01; 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. +// Pairing ceremony (typed control messages): 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. +// +// Trust is established by **SPAKE2** (a balanced PAKE), NOT a hash of the PIN. SPAKE2 turns +// the low-entropy PIN into a high-entropy shared key via a Diffie-Hellman exchange; the only +// thing an active man-in-the-middle who terminates the (TOFU) ceremony learns is whether a +// single PIN guess was right — there is no transcript value that reveals the PIN to an +// *offline* dictionary search (the fatal flaw of an HMAC-of-PIN proof over a 4-digit space). +// Both peers' certificate fingerprints are bound in as the SPAKE2 identities, so the +// established key — and the key-confirmation MACs derived from it — only agree when both +// sides saw the same two certificates. After mutual key confirmation the host persists the +// client's fingerprint and the client pins the host's. // --------------------------------------------------------------------------------------------- /// Type byte of [`PairRequest`]. @@ -103,24 +116,27 @@ 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. +/// `client → host`: begin pairing. `name` is the human label the host stores (≤64 bytes +/// UTF-8); `spake_a` is the client's SPAKE2 message (see [`SpakeRole::start`]). #[derive(Clone, Debug, PartialEq, Eq)] pub struct PairRequest { pub name: String, + pub spake_a: Vec, } -/// `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)] +/// `host → client`: the host's SPAKE2 message + its key-confirmation MAC. The client +/// finishes SPAKE2, verifies `confirm` (proving the host derived the same key, i.e. knows +/// the PIN and saw the same certs), then sends its own confirmation. +#[derive(Clone, Debug, PartialEq, Eq)] pub struct PairChallenge { - pub salt: [u8; 16], + pub spake_b: Vec, + pub confirm: [u8; 32], } -/// `client → host`: the proof, see [`pair_proof`]. +/// `client → host`: the client's key-confirmation MAC (its single proof attempt). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct PairProof { - pub hmac: [u8; 32], + pub confirm: [u8; 32], } /// `host → client`: ceremony outcome. @@ -129,106 +145,207 @@ pub struct PairResult { pub ok: bool, } +/// A length-prefixed (u16 LE) byte field within a control message. +fn put_bytes(b: &mut Vec, x: &[u8]) { + b.extend_from_slice(&(x.len() as u16).to_le_bytes()); + b.extend_from_slice(x); +} + +/// Read a length-prefixed field at `off`, returning the bytes and the next offset. +fn get_bytes(b: &[u8], off: usize) -> Result<(&[u8], usize)> { + if off + 2 > b.len() { + return Err(PunktfunkError::InvalidArg("truncated field")); + } + let n = u16::from_le_bytes([b[off], b[off + 1]]) as usize; + let start = off + 2; + if start + n > b.len() { + return Err(PunktfunkError::InvalidArg("field overruns message")); + } + Ok((&b[start..start + n], start + n)) +} + 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); + let mut b = Vec::with_capacity(8 + n + self.spake_a.len()); + b.extend_from_slice(CTL_MAGIC); b.push(MSG_PAIR_REQUEST); b.push(n as u8); b.extend_from_slice(&name[..n]); + put_bytes(&mut b, &self.spake_a); b } pub fn decode(b: &[u8]) -> Result { - if b.len() < 6 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_REQUEST { + if b.len() < 6 || &b[0..4] != CTL_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")); } + let name = String::from_utf8_lossy(&b[6..6 + n]).into_owned(); + let (spake_a, end) = get_bytes(b, 6 + n)?; + if end != b.len() { + return Err(PunktfunkError::InvalidArg("trailing bytes")); + } Ok(PairRequest { - name: String::from_utf8_lossy(&b[6..6 + n]).into_owned(), + name, + spake_a: spake_a.to_vec(), }) } } impl PairChallenge { pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(21); - b.extend_from_slice(MAGIC); + let mut b = Vec::with_capacity(7 + self.spake_b.len() + 32); + b.extend_from_slice(CTL_MAGIC); b.push(MSG_PAIR_CHALLENGE); - b.extend_from_slice(&self.salt); + put_bytes(&mut b, &self.spake_b); + b.extend_from_slice(&self.confirm); b } pub fn decode(b: &[u8]) -> Result { - if b.len() < 21 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_CHALLENGE { + if b.len() < 5 || &b[0..4] != CTL_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 }) + let (spake_b, end) = get_bytes(b, 5)?; + if end + 32 != b.len() { + return Err(PunktfunkError::InvalidArg("bad PairChallenge confirm")); + } + let mut confirm = [0u8; 32]; + confirm.copy_from_slice(&b[end..end + 32]); + Ok(PairChallenge { + spake_b: spake_b.to_vec(), + confirm, + }) } } impl PairProof { pub fn encode(&self) -> Vec { let mut b = Vec::with_capacity(37); - b.extend_from_slice(MAGIC); + b.extend_from_slice(CTL_MAGIC); b.push(MSG_PAIR_PROOF); - b.extend_from_slice(&self.hmac); + b.extend_from_slice(&self.confirm); b } pub fn decode(b: &[u8]) -> Result { - if b.len() < 37 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_PROOF { + if b.len() != 37 || &b[0..4] != CTL_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 }) + let mut confirm = [0u8; 32]; + confirm.copy_from_slice(&b[5..37]); + Ok(PairProof { confirm }) } } impl PairResult { pub fn encode(&self) -> Vec { let mut b = Vec::with_capacity(6); - b.extend_from_slice(MAGIC); + b.extend_from_slice(CTL_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 { + if b.len() != 6 || &b[0..4] != CTL_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] { +/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so +/// the identities are ordered; each side binds **both** certificate fingerprints as the +/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN +/// *and* saw the same two certificates (a MITM, presenting different certs to each leg, +/// cannot reach a shared key). +pub mod pake { + use super::*; 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() + use spake2::{Ed25519Group, Identity, Password, Spake2}; + + /// In-progress SPAKE2 state plus the identity transcript for key confirmation. + pub struct PairingPake { + state: Spake2, + transcript: Vec, + } + + /// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the + /// client passes what it observed via TOFU; the host passes its own + the client's + /// presented cert). Returns the state and this side's outbound SPAKE2 message. + pub fn start( + is_client: bool, + pin: &str, + client_fp: &[u8; 32], + host_fp: &[u8; 32], + ) -> (PairingPake, Vec) { + let pw = Password::new(pin.as_bytes()); + let id_client = Identity::new(client_fp); + let id_host = Identity::new(host_fp); + let (state, msg) = if is_client { + Spake2::::start_a(&pw, &id_client, &id_host) + } else { + Spake2::::start_b(&pw, &id_client, &id_host) + }; + let mut transcript = Vec::with_capacity(64); + transcript.extend_from_slice(client_fp); + transcript.extend_from_slice(host_fp); + (PairingPake { state, transcript }, msg) + } + + /// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed + /// by the SPAKE2 shared key and bound to the fingerprint transcript. + fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] { + let mut mac = + as Mac>::new_from_slice(key).expect("hmac takes any key length"); + mac.update(label); + mac.update(transcript); + mac.finalize().into_bytes().into() + } + + /// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare + /// our recomputed tag the same way. + fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { + a.iter() + .zip(b.iter()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 + } + + /// Confirmation tags both sides expect, given the agreed SPAKE2 key. + pub struct Confirmations { + /// MAC the host sends (client verifies). + pub host: [u8; 32], + /// MAC the client sends (host verifies). + pub client: [u8; 32], + } + + impl PairingPake { + /// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if + /// the peer's message is malformed (a wrong PIN does NOT error here — it yields a + /// *different* key, so the confirmation MACs simply won't match). + pub fn finish(self, peer_msg: &[u8]) -> Result { + let key = self + .state + .finish(peer_msg) + .map_err(|_| PunktfunkError::Crypto)?; + Ok(Confirmations { + host: confirm(&key, b"punktfunk-pair-host", &self.transcript), + client: confirm(&key, b"punktfunk-pair-client", &self.transcript), + }) + } + } + + /// Constant-time tag comparison for the confirmation step. + pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool { + ct_eq(expected, got) + } } impl Hello { @@ -354,7 +471,7 @@ 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.extend_from_slice(CTL_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()); @@ -363,7 +480,7 @@ impl Reconfigure { } pub fn decode(b: &[u8]) -> Result { - if b.len() < 17 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURE { + if b.len() != 17 || &b[0..4] != CTL_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]]); @@ -381,7 +498,7 @@ 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.extend_from_slice(CTL_MAGIC); b.push(MSG_RECONFIGURED); b.push(self.accepted as u8); b.extend_from_slice(&self.mode.width.to_le_bytes()); @@ -391,7 +508,7 @@ impl Reconfigured { } pub fn decode(b: &[u8]) -> Result { - if b.len() < 18 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURED { + if b.len() != 18 || &b[0..4] != CTL_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]]); @@ -863,6 +980,86 @@ mod tests { .is_err()); } + #[test] + fn control_messages_disjoint_from_hello() { + // A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at + // any abi_version — can be misparsed as a control message, and vice-versa. + for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] { + let h = Hello { + abi_version: abi, + mode: Mode { + width: 1280, + height: 720, + refresh_hz: 60, + }, + } + .encode(); + assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); + assert!(Reconfigure::decode(&h).is_err()); + } + // And a PairRequest never parses as a Hello. + let pr = PairRequest { + name: "x".into(), + spake_a: vec![0u8; 33], + } + .encode(); + assert!(Hello::decode(&pr).is_err()); + } + + #[test] + fn pair_messages_roundtrip() { + let pr = PairRequest { + name: "Enrico's Mac".into(), + spake_a: vec![1, 2, 3, 4, 5], + }; + assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr); + let pc = PairChallenge { + spake_b: vec![9; 33], + confirm: [7u8; 32], + }; + assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc); + let pp = PairProof { confirm: [3u8; 32] }; + assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp); + for ok in [true, false] { + assert_eq!( + PairResult::decode(&PairResult { ok }.encode()).unwrap().ok, + ok + ); + } + // Length-exact: a truncated/padded PairProof is rejected. + let mut bad = pp.encode(); + bad.push(0); + assert!(PairProof::decode(&bad).is_err()); + } + + #[test] + fn spake2_pairing_agrees_only_on_matching_pin_and_certs() { + let cfp = [0x11u8; 32]; + let hfp = [0x22u8; 32]; + + // Right PIN, same fingerprint views on both sides → both confirmations agree. + let (ca, ma) = pake::start(true, "4321", &cfp, &hfp); + let (cb, mb) = pake::start(false, "4321", &cfp, &hfp); + let a = ca.finish(&mb).unwrap(); + let b = cb.finish(&ma).unwrap(); + assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client)); + + // Wrong PIN → different keys → confirmations DON'T match (one online guess wasted). + let (ca, ma) = pake::start(true, "0000", &cfp, &hfp); + let (cb, mb) = pake::start(false, "4321", &cfp, &hfp); + let a = ca.finish(&mb).unwrap(); + let b = cb.finish(&ma).unwrap(); + assert!(!pake::verify(&a.client, &b.client)); + + // MITM: the two legs saw different host certs → no agreement even with the right PIN. + let attacker_hfp = [0x33u8; 32]; + let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp); + let (cb, mb) = pake::start(false, "4321", &cfp, &hfp); + let a = ca.finish(&mb).unwrap(); + let b = cb.finish(&ma).unwrap(); + assert!(!pake::verify(&a.client, &b.client)); + } + #[test] fn audio_datagram_roundtrip() { let opus = [0x42u8; 97]; diff --git a/crates/punktfunk-host/src/gamestream/audio.rs b/crates/punktfunk-host/src/gamestream/audio.rs index 09e2d7f..65f0691 100644 --- a/crates/punktfunk-host/src/gamestream/audio.rs +++ b/crates/punktfunk-host/src/gamestream/audio.rs @@ -466,7 +466,13 @@ fn audio_body( let mut enc = SessionEncoder::new(layout)?; // Opus frame duration; Moonlight negotiates 5 ms (default) or 10 ms via // `x-nv-aqos.packetDuration` and sizes its decoder at `48 * duration` samples. - let frame_ms = params.packet_duration_ms.clamp(5, 10) as usize; + // Already snapped to {5, 10} at parse time; guard here too so only legal Opus frame + // sizes (48 kHz × {5,10} ms = 240/480 samples) ever reach the encoder. + let frame_ms = if params.packet_duration_ms >= 10 { + 10 + } else { + 5 + } as usize; let samples_per_channel = SAMPLE_RATE as usize * frame_ms / 1000; let frame_len = samples_per_channel * layout.channels as usize; // interleaved samples let mut acc: Vec = Vec::with_capacity(frame_len * 4); diff --git a/crates/punktfunk-host/src/gamestream/rtsp.rs b/crates/punktfunk-host/src/gamestream/rtsp.rs index 6322317..271ca8a 100644 --- a/crates/punktfunk-host/src/gamestream/rtsp.rs +++ b/crates/punktfunk-host/src/gamestream/rtsp.rs @@ -321,10 +321,13 @@ fn audio_params(map: &HashMap) -> audio::AudioParams { } }; let high_quality = parse_u("x-nv-audio.surround.AudioQuality") == Some(1); - // Moonlight uses 5 ms (default) or 10 ms (slow decoder / low-bitrate links). - let packet_duration_ms = parse_u("x-nv-aqos.packetDuration") - .map(|d| d.clamp(5, 10) as u8) - .unwrap_or(5); + // Moonlight uses 5 ms (default) or 10 ms (slow decoder / low-bitrate links). Snap to + // those two — an in-between value like 7 isn't a legal Opus frame size and would make + // every encode fail; clamping (not snapping) would let it through. + let packet_duration_ms = match parse_u("x-nv-aqos.packetDuration") { + Some(d) if d >= 10 => 10, + _ => 5, + }; audio::AudioParams { channels, high_quality, diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index 957642e..8f20be7 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -53,9 +53,14 @@ 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). + /// Only serve clients whose certificate fingerprint is in the paired set. Implies + /// `allow_pairing` (a host that requires pairing must accept ceremonies to admit + /// anyone). pub require_pairing: bool, + /// Accept pairing ceremonies (the operator "arming" pairing mode). Default off: a host + /// with neither flag set rejects unsolicited PairRequests outright, closing that + /// attack surface. `require_pairing` forces this on. + pub allow_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. @@ -100,10 +105,19 @@ 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)?)?; + // Atomic replace: a crash/full-disk mid-write must not truncate the trust store (which + // would silently lock out every paired client on a --require-pairing host). Write a + // temp beside the target, then rename. + let tmp = state.path.with_extension("json.tmp"); + std::fs::write(&tmp, serde_json::to_vec_pretty(&state.clients)?)?; + std::fs::rename(&tmp, &state.path)?; Ok(()) } +/// Minimum spacing between accepted pairing ceremonies (bounds online PIN guessing — with +/// SPAKE2 an attacker already gets only one guess per ceremony; this caps the rate). +const PAIRING_COOLDOWN: std::time::Duration = std::time::Duration::from_secs(2); + impl PairedClients { fn contains(&self, fp: &[u8; 32]) -> bool { let hex = fingerprint_hex(fp); @@ -174,10 +188,25 @@ async fn serve(opts: M3Options) -> Result<()> { clients: load_paired(&paired_at), path: paired_at, })); - if opts.require_pairing { + // The arming PIN: one PIN for the whole pairing window (NOT per-ceremony), because the + // SPAKE2 client must know the PIN to build its first message — so the user has to read + // the PIN before connecting. Generated once when pairing is armed, displayed here. + let arming_pin = if opts.allow_pairing || opts.require_pairing { + let pin = opts.pairing_pin.clone().unwrap_or_else(|| { + use rand::Rng; + format!("{:04}", rand::thread_rng().gen_range(0..10_000u32)) + }); let n = paired.lock().unwrap().clients.clients.len(); - tracing::info!(paired = n, "pairing required for sessions"); - } + tracing::info!( + paired = n, + require = opts.require_pairing, + "PAIRING ARMED — enter this PIN on the client to pair: {pin}" + ); + Some(pin) + } else { + None + }; + let last_pairing = std::sync::Mutex::new(None::); let mut served = 0u32; loop { @@ -194,7 +223,17 @@ 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, &fingerprint, &paired).await { + if let Err(e) = serve_session( + conn, + &opts, + &audio_cap, + &fingerprint, + &paired, + &last_pairing, + arming_pin.as_deref(), + ) + .await + { tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error"); } else { tracing::info!(%peer, "session complete"); @@ -222,9 +261,10 @@ type AudioCapSlot = Arc Result<()> { + use punktfunk_core::quic::pake; 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}" + "PAIRING REQUEST — verifying against the armed PIN" ); - io::write_msg(&mut send, &PairChallenge { salt }.encode()).await?; + // SPAKE2 as B; bind our own host_fp + the client cert we actually received. + let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp); + let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message + + io::write_msg( + &mut send, + &PairChallenge { + spake_b, + confirm: confirms.host, + } + .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"))??; + .map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??; 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; + // A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so + // the client's confirmation MAC won't match ours — one online attempt, no offline search. + let ok = pake::verify(&confirms.client, &proof.confirm); if ok { let mut store = paired.lock().unwrap(); @@ -281,8 +324,9 @@ async fn pair_ceremony( } 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; + // Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our + // close on a slow link (bounded so a vanished client can't wedge the sequential host). + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await; conn.close(0u32.into(), b"pairing done"); anyhow::ensure!(ok, "pairing rejected (wrong PIN)"); Ok(()) @@ -297,6 +341,8 @@ async fn serve_session( audio_cap: &AudioCapSlot, host_fp: &[u8; 32], paired: &PairedStore, + last_pairing: &std::sync::Mutex>, + arming_pin: Option<&str>, ) -> Result<()> { let peer = conn.remote_address(); @@ -309,7 +355,18 @@ async fn serve_session( .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 pin = arming_pin.context("pairing not armed (start with --allow-pairing)")?; + { + let mut last = last_pairing.lock().unwrap(); + if let Some(t) = *last { + anyhow::ensure!( + t.elapsed() >= PAIRING_COOLDOWN, + "pairing rate-limited — retry shortly" + ); + } + *last = Some(std::time::Instant::now()); + } + return pair_ceremony(&conn, send, recv, req, host_fp, paired, pin).await; } let source = opts.source; @@ -388,12 +445,13 @@ async fn serve_session( 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(); + let ok = req.mode.refresh_hz > 0 + && 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"); @@ -770,14 +828,27 @@ fn virtual_stream( 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() { + // Drain to the NEWEST requested mode (a resize drag queues many) so we rebuild once, + // not once per stale intermediate mode. + let mut want = None; + while let Ok(m) = reconfig.try_recv() { + want = Some(m); + } + if let Some(new_mode) = want { 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(); + // Build the new pipeline BEFORE dropping the old one: the host already acked + // the switch as accepted, so a rebuild failure must not kill an otherwise + // healthy session — keep streaming the current mode and log instead. + match build_pipeline(&mut vd, new_mode) { + Ok(next_pipe) => { + (capturer, enc, frame, interval) = next_pipe; + next = std::time::Instant::now(); + } + Err(e) => { + tracing::error!(error = %format!("{e:#}"), ?new_mode, + "mode-switch rebuild failed — staying on the current mode"); + } + } } if let Some(f) = capturer.try_latest().context("capture")? { frame = f; @@ -925,6 +996,7 @@ mod tests { frames: 25, max_sessions: 3, require_pairing: false, + allow_pairing: false, pairing_pin: None, paired_store: None, }) @@ -1079,6 +1151,7 @@ mod tests { frames: 25, max_sessions: 4, require_pairing: true, + allow_pairing: false, pairing_pin: Some("4321".into()), paired_store: Some(test_paired_path()), }) @@ -1107,7 +1180,9 @@ mod tests { "anonymous session must be rejected" ); - // 3: correct PIN → paired, host fingerprint returned. + // 3: correct PIN → paired, host fingerprint returned. Space past the pairing + // cooldown that the wrong-PIN attempt above just triggered (a real retry is slower). + std::thread::sleep(PAIRING_COOLDOWN + std::time::Duration::from_millis(200)); let host_fp = NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout) .expect("pairing with the right PIN"); diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index 7902ab0..ff2d30f 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -91,6 +91,7 @@ fn real_main() -> Result<()> { .and_then(|s| s.parse().ok()) .unwrap_or(0), require_pairing: args.iter().any(|a| a == "--require-pairing"), + allow_pairing: args.iter().any(|a| a == "--allow-pairing"), pairing_pin: None, paired_store: None, }) @@ -320,8 +321,9 @@ 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) + --allow-pairing accept PIN pairing ceremonies (arm pairing mode) + --require-pairing only serve PIN-paired clients (implies --allow-pairing; + the host logs a 4-digit PIN when a client starts pairing) M0 OPTIONS: --source diff --git a/crates/punktfunk-host/src/vdisplay/wlroots.rs b/crates/punktfunk-host/src/vdisplay/wlroots.rs index 48e078f..45596ff 100644 --- a/crates/punktfunk-host/src/vdisplay/wlroots.rs +++ b/crates/punktfunk-host/src/vdisplay/wlroots.rs @@ -9,7 +9,7 @@ //! 3. The ScreenCast portal yields the output's PipeWire node. There is no GUI to pick an //! output headlessly, so xdpw is steered through its chooser hook: a managed config //! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change) -//! sets `chooser_type=simple` with a `chooser_cmd` that cats [`CHOOSER_FILE`], which we +//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we //! write per session (`Monitor: ` — xdpw 0.8 parses that prefix strictly). //! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and //! runs `swaymsg output unplug` (headless outputs support unplug since sway 1.8). @@ -29,18 +29,28 @@ use std::sync::Arc; use std::thread; use std::time::{Duration, Instant}; -/// File the xdpw output chooser reads the selected output from (see [`XDPW_CONFIG`]); we write -/// `Monitor: \n` here right before the portal handshake selects sources. -const CHOOSER_FILE: &str = "/tmp/punktfunk-xdpw-output"; +/// File the xdpw output chooser reads the selected output from (see [`xdpw_config`]); we +/// write `Monitor: \n` here right before the portal handshake selects sources. Lives +/// under `$XDG_RUNTIME_DIR` (per-user, mode 0700) — NOT a fixed world-writable /tmp path, +/// where another local user could pre-create it (DoS) or rewrite it between our write and +/// xdpw's read (steer capture at a different output). +fn chooser_file() -> String { + let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into()); + format!("{dir}/punktfunk-xdpw-output") +} /// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback /// keeps plain portal capture (`--source portal`, M0 flow) working when no session has written /// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout. -const XDPW_CONFIG: &str = - "# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\ +fn xdpw_config() -> String { + format!( + "# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\ [screencast]\n\ chooser_type=simple\n\ -chooser_cmd=cat /tmp/punktfunk-xdpw-output 2>/dev/null || echo 'Monitor: HEADLESS-1'\n"; +chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n", + chooser_file() + ) +} /// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) /// adds one headless output and spins up a portal thread owning the cast on it. @@ -82,8 +92,9 @@ impl VirtualDisplay for WlrootsDisplay { // Steer xdpw's headless output chooser at our new output, then run the portal // handshake on its own thread (it parks to keep the cast alive, like the other backends). ensure_xdpw_config()?; - std::fs::write(CHOOSER_FILE, format!("Monitor: {name}\n")) - .with_context(|| format!("write {CHOOSER_FILE}"))?; + let chooser = chooser_file(); + std::fs::write(&chooser, format!("Monitor: {name}\n")) + .with_context(|| format!("write {chooser}"))?; let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); let stop = Arc::new(AtomicBool::new(false)); let stop_thread = stop.clone(); @@ -207,7 +218,7 @@ fn wait_new_output(before: &[String], timeout: Duration) -> Result { /// Make sure xdpw uses our output chooser. xdpw reads its config only at startup, so on a /// change restart it if running (`try-restart`; if it isn't, D-Bus activation will start it -/// with the new config). The config itself is static — the *selection* is [`CHOOSER_FILE`]. +/// with the new config). The config itself is static — the *selection* is the chooser file. fn ensure_xdpw_config() -> Result<()> { let base = std::env::var_os("XDG_CONFIG_HOME") .map(std::path::PathBuf::from) @@ -215,11 +226,12 @@ fn ensure_xdpw_config() -> Result<()> { .ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?; let dir = base.join("xdg-desktop-portal-wlr"); let path = dir.join("config"); - if std::fs::read_to_string(&path).is_ok_and(|c| c == XDPW_CONFIG) { + let cfg = xdpw_config(); + if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) { return Ok(()); } std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?; - std::fs::write(&path, XDPW_CONFIG).with_context(|| format!("write {}", path.display()))?; + std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?; tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config"); let _ = Command::new("systemctl") .args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"]) diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index e67785c..5cc4599 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -14,7 +14,10 @@ // Bump on any breaking change to the [C ABI](crate::abi). Mirrors // `punktfunk_abi_version()` and is checked by clients before use. -#define ABI_VERSION 1 +// +// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities); +// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. +#define ABI_VERSION 2 // 16-byte AEAD authentication tag appended by GCM. #define TAG_LEN 16