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
+159 -5
View File
@@ -465,6 +465,18 @@ pub struct PunktfunkConnection {
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
}
/// Read an optional NUL-terminated UTF-8 string parameter; `Err` = invalid pointer/UTF-8.
#[cfg(feature = "quic")]
unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result<Option<&'a str>, ()> {
if p.is_null() {
return Ok(None);
}
unsafe { std::ffi::CStr::from_ptr(p) }
.to_str()
.map(Some)
.map_err(|_| ())
}
/// Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`.
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure.
///
@@ -473,9 +485,15 @@ pub struct PunktfunkConnection {
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
/// pass it as the pin on every later connect.
///
/// Identity: `client_cert_pem`/`client_key_pem` (both NULL, or both NUL-terminated PEM
/// strings — see [`punktfunk_generate_identity`]) are presented via TLS client auth so a
/// host can recognize this client once paired ([`punktfunk_pair`]). NULL = anonymous;
/// hosts running `--require-pairing` reject anonymous sessions.
///
/// # Safety
/// `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform);
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes.
/// `pin_sha256`/`observed_sha256_out` are each NULL or valid for 32 bytes;
/// `client_cert_pem`/`client_key_pem` are each NULL or NUL-terminated UTF-8.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connect(
@@ -486,6 +504,8 @@ pub unsafe extern "C" fn punktfunk_connect(
refresh_hz: u32,
pin_sha256: *const u8,
observed_sha256_out: *mut u8,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
timeout_ms: u32,
) -> *mut PunktfunkConnection {
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
@@ -508,11 +528,19 @@ pub unsafe extern "C" fn punktfunk_connect(
p.copy_from_slice(unsafe { std::slice::from_raw_parts(pin_sha256, 32) });
Some(p)
};
let identity = match (unsafe { opt_cstr(client_cert_pem) }, unsafe {
opt_cstr(client_key_pem)
}) {
(Ok(Some(c)), Ok(Some(k))) => Some((c.to_string(), k.to_string())),
(Ok(None), Ok(None)) => None,
_ => return std::ptr::null_mut(), // half an identity / bad UTF-8: fail closed
};
match crate::client::NativeClient::connect(
host,
port,
mode,
pin,
identity,
std::time::Duration::from_millis(timeout_ms as u64),
) {
Ok(c) => {
@@ -534,6 +562,97 @@ pub unsafe extern "C" fn punktfunk_connect(
r.unwrap_or(std::ptr::null_mut())
}
/// Generate a persistent client identity: a self-signed certificate + private key, both
/// PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both
/// strings (Keychain etc.), pass them to [`punktfunk_pair`] and every
/// [`punktfunk_connect`] — the certificate's fingerprint is how hosts recognize this
/// client. 4096-byte buffers are ample.
///
/// # Safety
/// `cert_pem_out` is writable for `cert_cap` bytes; `key_pem_out` for `key_cap`.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_generate_identity(
cert_pem_out: *mut std::os::raw::c_char,
cert_cap: usize,
key_pem_out: *mut std::os::raw::c_char,
key_cap: usize,
) -> PunktfunkStatus {
guard(|| {
if cert_pem_out.is_null() || key_pem_out.is_null() {
return PunktfunkStatus::NullPointer;
}
let (cert, key) = match crate::quic::endpoint::generate_identity() {
Ok(t) => t,
Err(_) => return PunktfunkStatus::Io,
};
if cert.len() + 1 > cert_cap || key.len() + 1 > key_cap {
return PunktfunkStatus::InvalidArg;
}
unsafe {
std::ptr::copy_nonoverlapping(cert.as_ptr(), cert_pem_out as *mut u8, cert.len());
*cert_pem_out.add(cert.len()) = 0;
std::ptr::copy_nonoverlapping(key.as_ptr(), key_pem_out as *mut u8, key.len());
*key_pem_out.add(key.len()) = 0;
}
PunktfunkStatus::Ok
})
}
/// Run the PIN pairing ceremony against a host (see the protocol docs in punktfunk-core):
/// the host displays a short PIN; the user types it into the client app, which passes it
/// here. On success the host has stored this client's identity, the now-verified host
/// fingerprint is written to `host_sha256_out` (32 bytes) — persist it and pass it as
/// `pin_sha256` to [`punktfunk_connect`] from then on. Returns
/// [`PunktfunkStatus::Crypto`] for a wrong PIN.
///
/// # Safety
/// `host`/`client_cert_pem`/`client_key_pem`/`pin`/`name` are NUL-terminated UTF-8;
/// `host_sha256_out` is writable for 32 bytes.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_pair(
host: *const std::os::raw::c_char,
port: u16,
client_cert_pem: *const std::os::raw::c_char,
client_key_pem: *const std::os::raw::c_char,
pin: *const std::os::raw::c_char,
name: *const std::os::raw::c_char,
host_sha256_out: *mut u8,
timeout_ms: u32,
) -> PunktfunkStatus {
guard(|| {
let (Ok(Some(host)), Ok(Some(cert)), Ok(Some(key)), Ok(Some(pin)), Ok(Some(name))) = (
unsafe { opt_cstr(host) },
unsafe { opt_cstr(client_cert_pem) },
unsafe { opt_cstr(client_key_pem) },
unsafe { opt_cstr(pin) },
unsafe { opt_cstr(name) },
) else {
return PunktfunkStatus::NullPointer;
};
if host_sha256_out.is_null() {
return PunktfunkStatus::NullPointer;
}
match crate::client::NativeClient::pair(
host,
port,
(cert, key),
pin,
name,
std::time::Duration::from_millis(timeout_ms as u64),
) {
Ok(fp) => {
unsafe {
std::slice::from_raw_parts_mut(host_sha256_out, 32).copy_from_slice(&fp);
}
PunktfunkStatus::Ok
}
Err(e) => e.status(),
}
})
}
/// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
/// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended.
/// On `Ok`, `*out` borrows connection memory **until the next `next_au` call** on this
@@ -710,7 +829,8 @@ pub unsafe extern "C" fn punktfunk_connection_send_input(
})
}
/// The host-confirmed session mode (from the Welcome). Safe any time after connect.
/// The currently active session mode the Welcome's, until an accepted
/// [`punktfunk_connection_request_mode`] switches it. Safe any time after connect.
///
/// # Safety
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
@@ -727,21 +847,55 @@ pub unsafe extern "C" fn punktfunk_connection_mode(
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
let mode = c.inner.mode();
unsafe {
if !width.is_null() {
*width = c.inner.mode.width;
*width = mode.width;
}
if !height.is_null() {
*height = c.inner.mode.height;
*height = mode.height;
}
if !refresh_hz.is_null() {
*refresh_hz = c.inner.mode.refresh_hz;
*refresh_hz = mode.refresh_hz;
}
}
PunktfunkStatus::Ok
})
}
/// Ask the host to switch the live session to `width`x`height`@`refresh_hz` without
/// reconnecting (window resized, refresh changed). Non-blocking enqueue: on acceptance the
/// stream continues at the new mode — the first new-mode access unit is an IDR with
/// in-band parameter sets (rebuild the decoder from it) — and
/// [`punktfunk_connection_mode`] reflects the switch. A rejected request leaves the
/// session unchanged.
///
/// # Safety
/// `c` is a valid connection handle.
#[cfg(feature = "quic")]
#[no_mangle]
pub unsafe extern "C" fn punktfunk_connection_request_mode(
c: *const PunktfunkConnection,
width: u32,
height: u32,
refresh_hz: u32,
) -> PunktfunkStatus {
guard(|| {
let c = match unsafe { c.as_ref() } {
Some(c) => c,
None => return PunktfunkStatus::NullPointer,
};
match c.inner.request_mode(crate::config::Mode {
width,
height,
refresh_hz,
}) {
Ok(()) => PunktfunkStatus::Ok,
Err(e) => e.status(),
}
})
}
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
///
/// # Safety