fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled
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:
@@ -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
|
||||
|
||||
Generated
+63
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
//!
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user