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
@@ -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<f32> = Vec::with_capacity(frame_len * 4);
+7 -4
View File
@@ -321,10 +321,13 @@ fn audio_params(map: &HashMap<String, String>) -> 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,
+120 -45
View File
@@ -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<String>,
/// 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::<std::time::Instant>);
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<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCaptu
/// 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.
/// The host side of the SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
/// client's key-confirmation MAC (its single online guess), and persist the client's
/// fingerprint on success.
async fn pair_ceremony(
conn: &quinn::Connection,
mut send: quinn::SendStream,
@@ -232,37 +272,40 @@ async fn pair_ceremony(
req: PairRequest,
host_fp: &[u8; 32],
paired: &PairedStore,
opts: &M3Options,
pin: &str,
) -> 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<Option<std::time::Instant>>,
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");
+4 -2
View File
@@ -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 <N> per-session stream duration, virtual source (default: 30)
--frames <N> per-session frame count, synthetic source (default: 300)
--max-sessions <N> 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 <synthetic|portal|kwin-virtual>
+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"])