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
+3
View File
@@ -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 <PIN>` 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.
//!
+2 -1
View File
@@ -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]
+47 -19
View File
@@ -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
})
}
+4 -1
View File
@@ -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;
+253 -56
View File
@@ -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<u8>,
}
/// `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<u8>,
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<u8>, 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<u8> {
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<PairRequest> {
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<u8> {
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<PairChallenge> {
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<u8> {
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<PairProof> {
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<u8> {
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<PairResult> {
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 = <Hmac<sha2::Sha256> 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<Ed25519Group>,
transcript: Vec<u8>,
}
/// 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<u8>) {
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::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
} else {
Spake2::<Ed25519Group>::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 =
<Hmac<sha2::Sha256> 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<Confirmations> {
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<u8> {
// 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<Reconfigure> {
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<u8> {
// 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<Reconfigured> {
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];
@@ -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"])