fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 16:26:48 +00:00
parent 429bd1e6ac
commit ff4fe197be
15 changed files with 556 additions and 154 deletions
+24 -12
View File
@@ -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: <NAME>` — 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 <NAME> 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: <NAME>\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: <NAME>\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::<Result<(OwnedFd, u32), String>>();
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<String> {
/// 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"])