feat: punktfunk/1 — mid-stream mode renegotiation + PIN pairing ceremony

Renegotiation (no reconnect on resize): the handshake bi-stream stays open; the client
sends Reconfigure{mode} (typed post-handshake message), the host validates + acks
Reconfigured and rebuilds capture/encoder/virtual output at the new mode while the data
plane (keys, ports, FEC) runs untouched — the first new-mode AU is an IDR with in-band
parameter sets. NativeClient::request_mode / punktfunk_connection_request_mode; mode()
reflects the active mode. Validated live on KWin: one continuous stream, 225 frames
@1280x720 then 395 @1920x1080, ~90 ms pipeline rebuild (ffprobe shows both resolutions).

PIN pairing (mutual trust, kills TOFU MITM): clients get persistent self-signed
identities presented via QUIC client auth (generate_identity / client auth offered but
optional server-side — legacy clients still connect). Ceremony on the control stream:
PairRequest{name} → host shows a 4-digit PIN (log) + PairChallenge{salt} → client proves
with HMAC-SHA256(PIN‖salt, client_fp‖host_fp) — binding both certs means a MITM can't
forward a proof, single attempt per PIN, constant-time compare → PairResult; host
persists the fingerprint (~/.config/punktfunk/punktfunk1-paired.json), client pins the
host's. m3-host --require-pairing gates sessions on the paired set.
NativeClient::pair + punktfunk_pair/punktfunk_generate_identity in the ABI; reference
client: --pair PIN --name LABEL + auto-generated persistent identity, --remode for live
renegotiation testing. Swift wrapper: ClientIdentity/generateIdentity()/pair(),
requestMode()/currentMode(); README handoff updated.

Tested: reconfigure/pairing wire roundtrips, C-ABI mode switch ack, full in-process
ceremony (wrong PIN → Crypto, anonymous-vs-gate rejection, success → pinned session);
live wrong-PIN ceremony against the serving host (PIN logged, proof rejected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:42:29 +00:00
parent 7381ba8218
commit 4d26ac5c85
12 changed files with 1386 additions and 91 deletions
+103 -16
View File
@@ -20,7 +20,7 @@
use anyhow::{anyhow, Context, Result};
use punktfunk_core::config::Role;
use punktfunk_core::input::{InputEvent, InputKind};
use punktfunk_core::quic::{endpoint, io, Hello, Start, Welcome};
use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome};
use punktfunk_core::transport::UdpTransport;
use punktfunk_core::{Mode, PunktfunkError, Session};
use std::io::Write;
@@ -31,6 +31,21 @@ struct Args {
out: Option<String>,
input_test: bool,
pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>,
/// `--pair PIN` — run the pairing ceremony instead of a session.
pair: Option<String>,
/// `--name LABEL` — how the host labels this client when pairing.
name: String,
}
fn parse_mode(m: &str) -> Option<Mode> {
let mut it = m.split('x');
Some(Mode {
width: it.next()?.parse().ok()?,
height: it.next()?.parse().ok()?,
refresh_hz: it.next()?.parse().ok()?,
})
}
fn parse_hex32(s: &str) -> Option<[u8; 32]> {
@@ -48,6 +63,24 @@ fn hex(fp: &[u8; 32]) -> String {
fp.iter().map(|b| format!("{b:02x}")).collect()
}
/// This client's persistent identity (`~/.config/punktfunk/client-{cert,key}.pem`),
/// generated on first use — presented on every connect so hosts can recognize it once
/// paired.
fn load_or_create_identity() -> Result<(String, String)> {
let home = std::env::var("HOME").context("HOME unset")?;
let dir = std::path::PathBuf::from(home).join(".config/punktfunk");
let (cp, kp) = (dir.join("client-cert.pem"), dir.join("client-key.pem"));
if let (Ok(c), Ok(k)) = (std::fs::read_to_string(&cp), std::fs::read_to_string(&kp)) {
return Ok((c, k));
}
let (c, k) = endpoint::generate_identity().map_err(|e| anyhow!("generate identity: {e}"))?;
std::fs::create_dir_all(&dir)?;
std::fs::write(&cp, &c)?;
std::fs::write(&kp, &k)?;
tracing::info!(cert = %cp.display(), "generated client identity");
Ok((c, k))
}
fn parse_args() -> Args {
let argv: Vec<String> = std::env::args().collect();
let get = |flag: &str| {
@@ -56,20 +89,15 @@ fn parse_args() -> Args {
.nth(1)
.map(String::as_str)
};
let mode = get("--mode")
.and_then(|m| {
let mut it = m.split('x');
Some(Mode {
width: it.next()?.parse().ok()?,
height: it.next()?.parse().ok()?,
refresh_hz: it.next()?.parse().ok()?,
})
})
.unwrap_or(Mode {
width: 1280,
height: 720,
refresh_hz: 60,
});
let mode = get("--mode").and_then(parse_mode).unwrap_or(Mode {
width: 1280,
height: 720,
refresh_hz: 60,
});
let remode = get("--remode").and_then(|s| {
let (m, secs) = s.split_once(':')?;
Some((parse_mode(m)?, secs.parse().ok()?))
});
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
// (the user asked for verification; fail closed).
let pin = match get("--pin") {
@@ -90,6 +118,9 @@ fn parse_args() -> Args {
out: get("--out").map(String::from),
input_test: argv.iter().any(|a| a == "--input-test"),
pin,
remode,
pair: get("--pair").map(String::from),
name: get("--name").unwrap_or("punktfunk-client-rs").to_string(),
}
}
@@ -114,6 +145,29 @@ fn main() {
}
fn run(args: Args) -> Result<()> {
// Pairing mode: run the PIN ceremony and print the fingerprint to pin, then exit.
if let Some(pin) = &args.pair {
let (host, port) = args
.connect
.rsplit_once(':')
.context("--connect host:port")?;
let identity = load_or_create_identity()?;
let fp = punktfunk_core::client::NativeClient::pair(
host,
port.parse().context("port")?,
(&identity.0, &identity.1),
pin,
&args.name,
std::time::Duration::from_secs(90),
)
.map_err(|e| anyhow!("pairing failed: {e:?} (wrong PIN?)"))?;
tracing::info!(
fingerprint = %hex(&fp),
"PAIRED — connect with --pin {} from now on",
hex(&fp)
);
return Ok(());
}
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
@@ -123,7 +177,11 @@ fn run(args: Args) -> Result<()> {
async fn session(args: Args) -> Result<()> {
let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?;
let (ep, observed) = endpoint::client_pinned(args.pin);
let identity = load_or_create_identity()?;
let (ep, observed) = endpoint::client_pinned_with_identity(
args.pin,
Some((identity.0.as_str(), identity.1.as_str())),
);
let ep = ep.map_err(|e| anyhow!("QUIC client endpoint: {e}"))?;
let conn = ep
.connect(remote, "punktfunk")
@@ -173,6 +231,35 @@ async fn session(args: Args) -> Result<()> {
)
.await?;
// Mid-stream renegotiation test: after a delay, ask the host to switch modes on the
// still-open control stream. The stream then carries new-mode AUs (IDR + in-band
// parameter sets) — ffprobe the --out file to see both resolutions.
if let Some((new_mode, after_secs)) = args.remode {
let mut rs = send;
let mut rr = recv;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(after_secs as u64)).await;
tracing::info!(?new_mode, "requesting mid-stream mode switch");
if io::write_msg(&mut rs, &Reconfigure { mode: new_mode }.encode())
.await
.is_err()
{
tracing::error!("Reconfigure write failed");
return;
}
match io::read_msg(&mut rr)
.await
.map(|b| Reconfigured::decode(&b))
{
Ok(Ok(ack)) if ack.accepted => {
tracing::info!(mode = ?ack.mode, "mode switch ACCEPTED")
}
Ok(Ok(ack)) => tracing::warn!(active = ?ack.mode, "mode switch REJECTED"),
other => tracing::error!(?other, "bad Reconfigured"),
}
});
}
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
// low-latency input path without a real input device.
if args.input_test {