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:
@@ -17,7 +17,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
**KWin** (`zkde_screencast stream_virtual_output`, needs KWin ≥ 6.5.6 headless; >60 Hz via
|
||||||
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
custom modes), **gamescope** (spawned headless at WxH@Hz, its PipeWire node captured, needs
|
||||||
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
gamescope ≥ 3.16.22 — older deadlocks on PipeWire ≥ 1.6), **Mutter** (D-Bus
|
||||||
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy).
|
`RecordVirtual` virtual monitor; validated live on headless GNOME Shell 50, zero-copy),
|
||||||
|
**Sway/wlroots** (`swaymsg create_output` + custom mode, xdpw portal capture with a
|
||||||
|
managed chooser config; validated live on sway 1.11, zero-copy).
|
||||||
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
Performance work landed and measured: GPU **zero-copy** on all paths (tiled dmabuf →
|
||||||
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
EGL/GL → CUDA; LINEAR dmabuf → **Vulkan bridge** → CUDA → NVENC), auto 2-way NVENC
|
||||||
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
split-encode above ~1 Gpix/s (5K@240), infinite GOP + RFI keyframes (killed the periodic
|
||||||
@@ -36,7 +38,13 @@ 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:**
|
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
|
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 —
|
pairing) and logs the SHA-256 fingerprint; clients pin it (TOFU on first connect —
|
||||||
`endpoint::client_pinned`). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
`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:
|
||||||
|
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
|
||||||
|
host rebuilds output+encoder at the new mode in ~90 ms while the data plane runs on
|
||||||
|
(validated live: one .h265 with 720p and 1080p segments). Measured on-box at 720p120: 1680/1680 frames, **p50 0.83 ms**
|
||||||
capture→…→reassembled; audio measured live (~200 pkts/s). `punktfunk-client-rs` is the
|
capture→…→reassembled; audio measured live (~200 pkts/s). `punktfunk-client-rs` is the
|
||||||
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
working reference client (`--pin`, datagram counters, `--input-test` incl. gamepad).
|
||||||
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
The embeddable connector (`NativeClient`) exposes it all over the C ABI: `punktfunk_connect`
|
||||||
@@ -58,11 +66,9 @@ Low-latency desktop/game streaming stack, Linux-first, with a shared Rust protoc
|
|||||||
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
2. **Sub-frame pipelining**: overlap encode and transmit within a frame. Requires a direct
|
||||||
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
NVENC SDK wrapper (libavcodec only emits whole AUs) — the next big latency lever (~2–4 ms
|
||||||
at high res).
|
at high res).
|
||||||
3. **punktfunk/1 protocol growth**: a PIN-style pairing ceremony on top of fingerprint pinning,
|
3. **punktfunk/1 protocol growth**: concurrent sessions (today: one at a time, extras wait
|
||||||
mid-stream mode renegotiation (the Welcome is one-shot today), concurrent sessions
|
in the accept queue); mgmt REST endpoints for the punktfunk/1 paired-client list.
|
||||||
(today: one at a time, extras wait in the accept queue).
|
4. **M2 polish**: HDR negotiation, reconnect-at-new-mode robustness.
|
||||||
4. **M2 polish**: wlroots/Sway `VirtualDisplay` backend (deferred; swaymsg `create_output`),
|
|
||||||
HDR/10-bit/AV1 negotiation, surround audio, reconnect-at-new-mode robustness.
|
|
||||||
5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`.
|
5. **Native clients** (`clients/{apple,android}` scaffolds) consuming `punktfunk_core.h`.
|
||||||
|
|
||||||
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
Box one-time setup is complete: udev rule + `input` group (gamepads validated live),
|
||||||
@@ -73,7 +79,7 @@ backend validated live). All three compositor backends are live-validated.
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo build --workspace # green on Linux and macOS
|
cargo build --workspace # green on Linux and macOS
|
||||||
cargo test --workspace # unit + loopback + proptest + C ABI harness (~97 tests)
|
cargo test --workspace # unit + loopback + proptest + C ABI harness (~100 tests)
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
cargo fmt --all --check
|
cargo fmt --all --check
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ Generated artifacts are **checked in** and CI fails on drift: `include/punktfunk
|
|||||||
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
crates/punktfunk-core/ protocol · FEC · crypto · quic (punktfunk/1 control plane, feature-gated)
|
||||||
crates/punktfunk-host/
|
crates/punktfunk-host/
|
||||||
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
gamestream/ Moonlight compat: nvhttp · pairing · rtsp · control · stream · gamepad · apps
|
||||||
vdisplay/{kwin,gamescope,mutter}.rs per-compositor client-sized virtual outputs
|
vdisplay/{kwin,gamescope,mutter,wlroots}.rs per-compositor client-sized virtual outputs
|
||||||
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
zerocopy/{egl,cuda,vulkan}.rs dmabuf → CUDA → NVENC (tiled via EGL/GL, LINEAR via Vulkan)
|
||||||
inject/{libei,wlr,gamepad}.rs input backends (+ uinput virtual gamepads)
|
inject/{libei,wlr,gamepad}.rs input backends (+ uinput virtual gamepads)
|
||||||
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs
|
capture.rs · encode.rs · audio.rs · m0.rs · m3.rs · mgmt.rs
|
||||||
|
|||||||
Generated
+11
@@ -718,6 +718,7 @@ dependencies = [
|
|||||||
"block-buffer",
|
"block-buffer",
|
||||||
"const-oid",
|
"const-oid",
|
||||||
"crypto-common",
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1142,6 +1143,15 @@ version = "0.4.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hmac"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
|
dependencies = [
|
||||||
|
"digest",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -1917,6 +1927,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"cbindgen",
|
"cbindgen",
|
||||||
"fec-rs",
|
"fec-rs",
|
||||||
|
"hmac",
|
||||||
"proptest",
|
"proptest",
|
||||||
"quinn",
|
"quinn",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
|
|||||||
+13
-9
@@ -117,15 +117,19 @@ signing, bundle id `io.unom.punktfunk`. Notes:
|
|||||||
contract documented on the constructors; the host accumulates them into a virtual
|
contract documented on the constructors; the host accumulates them into a virtual
|
||||||
Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback.
|
Xbox 360 pad). Poll `nextRumble()` and feed `GCDeviceHaptics` for force feedback.
|
||||||
Client-side capture isn't in `InputCapture` yet.
|
Client-side capture isn't in `InputCapture` yet.
|
||||||
7. **Trust**: connect once with `pinSHA256: nil` (TOFU), persist `hostFingerprint` keyed
|
7. **Trust — the full ceremony exists now.** `generateIdentity()` once (persist both
|
||||||
by host, pass it on every later connect — a mismatch throws `.connectFailed`. The host
|
PEMs in the Keychain), then `pair(host:identity:pin:name:)` with the 4-digit PIN the
|
||||||
logs its fingerprint at startup ("clients pin this fingerprint") for out-of-band
|
host displays (its log; UI later) — returns the host's VERIFIED fingerprint; persist
|
||||||
verification UX; a PIN-style pairing ceremony is a later punktfunk-core task.
|
it and pass `pinSHA256:` + `identity:` to every connect. A wrong-size pin throws
|
||||||
`PunktfunkClient` implements exactly this: explicit fingerprint confirmation on first
|
`.invalidPin`, a wrong PIN `.wrongPIN`. The TOFU flow `PunktfunkClient` already
|
||||||
connect (input/cursor capture held back until confirmed), pin stored per host
|
implements (fingerprint confirmation sheet, per-host `HostStore`, "Forget Identity")
|
||||||
(`HostStore`), "Forget Identity" in the card's context menu for legitimate host
|
keeps working against hosts not running `--require-pairing`; upgrading the sheet to a
|
||||||
reinstalls. Note the OTHER direction is still open: the host authorizes no one — any
|
PIN-entry field closes the remaining gap — with `--require-pairing` the host now
|
||||||
client that reaches the port gets a session (fine on a LAN, not on the internet).
|
authorizes clients too (the "other direction" is no longer open, opt-in per host).
|
||||||
|
7b. **Resize without reconnect**: `requestMode(width:height:refreshHz:)` mid-stream —
|
||||||
|
the host rebuilds at the new mode in ~90 ms; the first new-mode AU is an IDR with
|
||||||
|
fresh parameter sets (the refresh-on-IDR decode flow handles it untouched) and
|
||||||
|
`currentMode()` reflects the switch. Wire it to window-resize events.
|
||||||
8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus —
|
8. **Input capture caveats** (stage 1): GC handlers only fire while the app has focus —
|
||||||
on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so
|
on focus loss `InputCapture` auto-releases everything still held (keys + buttons) so
|
||||||
nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden
|
nothing sticks down host-side. While the stream has focus the LOCAL cursor is hidden
|
||||||
|
|||||||
@@ -49,10 +49,69 @@ public enum PunktfunkClientError: Error {
|
|||||||
/// `pinSHA256` was non-nil but not exactly 32 bytes. Failing closed: connecting
|
/// `pinSHA256` was non-nil but not exactly 32 bytes. Failing closed: connecting
|
||||||
/// unpinned when the caller asked for verification would be a silent trust downgrade.
|
/// unpinned when the caller asked for verification would be a silent trust downgrade.
|
||||||
case invalidPin
|
case invalidPin
|
||||||
|
/// Pairing rejected — wrong PIN.
|
||||||
|
case wrongPIN
|
||||||
case closed
|
case closed
|
||||||
case status(Int32)
|
case status(Int32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This client's persistent self-signed identity. Generate ONCE with `generateIdentity()`,
|
||||||
|
/// store both PEMs (Keychain), present on every connect — the certificate's fingerprint is
|
||||||
|
/// how hosts recognize this client after pairing.
|
||||||
|
public struct ClientIdentity: Sendable {
|
||||||
|
public let certPEM: String
|
||||||
|
public let keyPEM: String
|
||||||
|
public init(certPEM: String, keyPEM: String) {
|
||||||
|
self.certPEM = certPEM
|
||||||
|
self.keyPEM = keyPEM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh client identity (self-signed cert + key, PEM).
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return ClientIdentity(certPEM: String(cString: cert), keyPEM: String(cString: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the PIN pairing ceremony: the host displays a 4-digit PIN (its log/UI), the user
|
||||||
|
/// types it here. On success the host stores this client's identity and the returned
|
||||||
|
/// fingerprint is the host's now-VERIFIED identity — persist it and pass it as `pinSHA256`
|
||||||
|
/// to every later connect. Throws `.wrongPIN` when the proof is rejected.
|
||||||
|
public func pair(
|
||||||
|
host: String, port: UInt16 = 9777,
|
||||||
|
identity: ClientIdentity, pin: String, name: String,
|
||||||
|
timeoutMs: UInt32 = 90_000
|
||||||
|
) throws -> Data {
|
||||||
|
var observed = [UInt8](repeating: 0, count: 32)
|
||||||
|
let rc = host.withCString { cs in
|
||||||
|
identity.certPEM.withCString { cert in
|
||||||
|
identity.keyPEM.withCString { key in
|
||||||
|
pin.withCString { p in
|
||||||
|
name.withCString { n in
|
||||||
|
punktfunk_pair(cs, port, cert, key, p, n, &observed, timeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch rc.rawValue {
|
||||||
|
case PUNKTFUNK_STATUS_OK.rawValue: return Data(observed)
|
||||||
|
case PUNKTFUNK_STATUS_CRYPTO.rawValue: throw PunktfunkClientError.wrongPIN
|
||||||
|
default: throw PunktfunkClientError.status(rc.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `withCString` over an optional — nil maps to a NULL C pointer.
|
||||||
|
func withOptionalCString<R>(_ s: String?, _ body: (UnsafePointer<CChar>?) -> R) -> R {
|
||||||
|
guard let s else { return body(nil) }
|
||||||
|
return s.withCString { body($0) }
|
||||||
|
}
|
||||||
|
|
||||||
public final class PunktfunkConnection {
|
public final class PunktfunkConnection {
|
||||||
private var handle: OpaquePointer?
|
private var handle: OpaquePointer?
|
||||||
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
/// Set by close() before it contends for the plane locks: the pullers see it at their
|
||||||
@@ -82,23 +141,34 @@ public final class PunktfunkConnection {
|
|||||||
/// `pinSHA256`: the host's expected certificate fingerprint (exactly 32 bytes, else
|
/// `pinSHA256`: the host's expected certificate fingerprint (exactly 32 bytes, else
|
||||||
/// `invalidPin` is thrown — never silently downgraded); nil = trust on first use
|
/// `invalidPin` is thrown — never silently downgraded); nil = trust on first use
|
||||||
/// (check `hostFingerprint` afterwards). A pinned mismatch throws.
|
/// (check `hostFingerprint` afterwards). A pinned mismatch throws.
|
||||||
|
///
|
||||||
|
/// `identity`: this client's persistent identity (from `generateIdentity()`, stored in
|
||||||
|
/// the Keychain) — presented so a host recognizes a paired client. nil = anonymous;
|
||||||
|
/// hosts running `--require-pairing` reject anonymous sessions.
|
||||||
public init(
|
public init(
|
||||||
host: String, port: UInt16 = 9777,
|
host: String, port: UInt16 = 9777,
|
||||||
width: UInt32, height: UInt32, refreshHz: UInt32,
|
width: UInt32, height: UInt32, refreshHz: UInt32,
|
||||||
pinSHA256: Data? = nil,
|
pinSHA256: Data? = nil,
|
||||||
|
identity: ClientIdentity? = nil,
|
||||||
timeoutMs: UInt32 = 10_000
|
timeoutMs: UInt32 = 10_000
|
||||||
) throws {
|
) throws {
|
||||||
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
if let pin = pinSHA256, pin.count != 32 { throw PunktfunkClientError.invalidPin }
|
||||||
var observed = [UInt8](repeating: 0, count: 32)
|
var observed = [UInt8](repeating: 0, count: 32)
|
||||||
handle = host.withCString { cs in
|
handle = host.withCString { cs in
|
||||||
if let pin = pinSHA256 {
|
withOptionalCString(identity?.certPEM) { cert in
|
||||||
return pin.withUnsafeBytes { p in
|
withOptionalCString(identity?.keyPEM) { key in
|
||||||
punktfunk_connect(
|
if let pin = pinSHA256 {
|
||||||
cs, port, width, height, refreshHz,
|
return pin.withUnsafeBytes { p in
|
||||||
p.bindMemory(to: UInt8.self).baseAddress, &observed, timeoutMs)
|
punktfunk_connect(
|
||||||
|
cs, port, width, height, refreshHz,
|
||||||
|
p.bindMemory(to: UInt8.self).baseAddress, &observed,
|
||||||
|
cert, key, timeoutMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return punktfunk_connect(
|
||||||
|
cs, port, width, height, refreshHz, nil, &observed, cert, key, timeoutMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return punktfunk_connect(cs, port, width, height, refreshHz, nil, &observed, timeoutMs)
|
|
||||||
}
|
}
|
||||||
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
guard handle != nil else { throw PunktfunkClientError.connectFailed }
|
||||||
hostFingerprint = Data(observed)
|
hostFingerprint = Data(observed)
|
||||||
@@ -109,6 +179,28 @@ public final class PunktfunkConnection {
|
|||||||
self.refreshHz = hz
|
self.refreshHz = hz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask the host to switch the live session to a new mode (window resized) — no
|
||||||
|
/// reconnect. Non-blocking; on acceptance the stream continues at the new mode (the
|
||||||
|
/// first new-mode AU is an IDR with fresh parameter sets — `AnnexB.formatDescription`
|
||||||
|
/// refresh-on-IDR already handles it) and `currentMode()` reflects the switch.
|
||||||
|
public func requestMode(width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
guard let h = handle, !closeRequested else { return }
|
||||||
|
_ = punktfunk_connection_request_mode(h, width, height, refreshHz)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently active session mode (updated by accepted `requestMode` switches).
|
||||||
|
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
|
||||||
|
abiLock.lock()
|
||||||
|
defer { abiLock.unlock() }
|
||||||
|
var w: UInt32 = 0, h: UInt32 = 0, hz: UInt32 = 0
|
||||||
|
if let hd = handle, !closeRequested {
|
||||||
|
_ = punktfunk_connection_mode(hd, &w, &h, &hz)
|
||||||
|
}
|
||||||
|
return (w, h, hz)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next access unit; nil on timeout, throws `.closed` once the session ended.
|
/// Pull the next access unit; nil on timeout, throws `.closed` once the session ended.
|
||||||
/// Call from a single pump thread.
|
/// Call from a single pump thread.
|
||||||
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {
|
public func nextAU(timeoutMs: UInt32 = 100) throws -> AccessUnit? {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
use anyhow::{anyhow, Context, Result};
|
use anyhow::{anyhow, Context, Result};
|
||||||
use punktfunk_core::config::Role;
|
use punktfunk_core::config::Role;
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
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::transport::UdpTransport;
|
||||||
use punktfunk_core::{Mode, PunktfunkError, Session};
|
use punktfunk_core::{Mode, PunktfunkError, Session};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
@@ -31,6 +31,21 @@ struct Args {
|
|||||||
out: Option<String>,
|
out: Option<String>,
|
||||||
input_test: bool,
|
input_test: bool,
|
||||||
pin: Option<[u8; 32]>,
|
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]> {
|
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()
|
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 {
|
fn parse_args() -> Args {
|
||||||
let argv: Vec<String> = std::env::args().collect();
|
let argv: Vec<String> = std::env::args().collect();
|
||||||
let get = |flag: &str| {
|
let get = |flag: &str| {
|
||||||
@@ -56,20 +89,15 @@ fn parse_args() -> Args {
|
|||||||
.nth(1)
|
.nth(1)
|
||||||
.map(String::as_str)
|
.map(String::as_str)
|
||||||
};
|
};
|
||||||
let mode = get("--mode")
|
let mode = get("--mode").and_then(parse_mode).unwrap_or(Mode {
|
||||||
.and_then(|m| {
|
width: 1280,
|
||||||
let mut it = m.split('x');
|
height: 720,
|
||||||
Some(Mode {
|
refresh_hz: 60,
|
||||||
width: it.next()?.parse().ok()?,
|
});
|
||||||
height: it.next()?.parse().ok()?,
|
let remode = get("--remode").and_then(|s| {
|
||||||
refresh_hz: it.next()?.parse().ok()?,
|
let (m, secs) = s.split_once(':')?;
|
||||||
})
|
Some((parse_mode(m)?, secs.parse().ok()?))
|
||||||
})
|
});
|
||||||
.unwrap_or(Mode {
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
refresh_hz: 60,
|
|
||||||
});
|
|
||||||
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
|
// A present-but-malformed --pin must abort, not silently downgrade to trust-on-first-use
|
||||||
// (the user asked for verification; fail closed).
|
// (the user asked for verification; fail closed).
|
||||||
let pin = match get("--pin") {
|
let pin = match get("--pin") {
|
||||||
@@ -90,6 +118,9 @@ fn parse_args() -> Args {
|
|||||||
out: get("--out").map(String::from),
|
out: get("--out").map(String::from),
|
||||||
input_test: argv.iter().any(|a| a == "--input-test"),
|
input_test: argv.iter().any(|a| a == "--input-test"),
|
||||||
pin,
|
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<()> {
|
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()
|
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||||
.worker_threads(2)
|
.worker_threads(2)
|
||||||
.enable_all()
|
.enable_all()
|
||||||
@@ -123,7 +177,11 @@ fn run(args: Args) -> Result<()> {
|
|||||||
|
|
||||||
async fn session(args: Args) -> Result<()> {
|
async fn session(args: Args) -> Result<()> {
|
||||||
let remote: std::net::SocketAddr = args.connect.parse().context("--connect host:port")?;
|
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 ep = ep.map_err(|e| anyhow!("QUIC client endpoint: {e}"))?;
|
||||||
let conn = ep
|
let conn = ep
|
||||||
.connect(remote, "punktfunk")
|
.connect(remote, "punktfunk")
|
||||||
@@ -173,6 +231,35 @@ async fn session(args: Args) -> Result<()> {
|
|||||||
)
|
)
|
||||||
.await?;
|
.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
|
// Input plane: scripted events as QUIC datagrams (mouse square + 'A' taps), proving the
|
||||||
// low-latency input path without a real input device.
|
// low-latency input path without a real input device.
|
||||||
if args.input_test {
|
if args.input_test {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
|||||||
default = []
|
default = []
|
||||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
# 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.
|
# 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"]
|
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||||
@@ -38,9 +38,10 @@ zeroize = "1"
|
|||||||
|
|
||||||
quinn = { version = "0.11", optional = true }
|
quinn = { version = "0.11", optional = true }
|
||||||
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
rustls = { version = "0.23", optional = true, default-features = false, features = ["ring", "std"] }
|
||||||
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs"] }
|
rcgen = { version = "0.13", optional = true, default-features = false, features = ["aws_lc_rs", "pem"] }
|
||||||
rustls-pki-types = { version = "1", optional = true }
|
rustls-pki-types = { version = "1", optional = true }
|
||||||
sha2 = { version = "0.10", optional = true }
|
sha2 = { version = "0.10", optional = true }
|
||||||
|
hmac = { version = "0.12", optional = true }
|
||||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -465,6 +465,18 @@ pub struct PunktfunkConnection {
|
|||||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
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`.
|
/// 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.
|
/// 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
|
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||||
/// pass it as the pin on every later connect.
|
/// 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
|
/// # Safety
|
||||||
/// `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform);
|
/// `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")]
|
#[cfg(feature = "quic")]
|
||||||
#[no_mangle]
|
#[no_mangle]
|
||||||
pub unsafe extern "C" fn punktfunk_connect(
|
pub unsafe extern "C" fn punktfunk_connect(
|
||||||
@@ -486,6 +504,8 @@ pub unsafe extern "C" fn punktfunk_connect(
|
|||||||
refresh_hz: u32,
|
refresh_hz: u32,
|
||||||
pin_sha256: *const u8,
|
pin_sha256: *const u8,
|
||||||
observed_sha256_out: *mut 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,
|
timeout_ms: u32,
|
||||||
) -> *mut PunktfunkConnection {
|
) -> *mut PunktfunkConnection {
|
||||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
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) });
|
p.copy_from_slice(unsafe { std::slice::from_raw_parts(pin_sha256, 32) });
|
||||||
Some(p)
|
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(
|
match crate::client::NativeClient::connect(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
mode,
|
mode,
|
||||||
pin,
|
pin,
|
||||||
|
identity,
|
||||||
std::time::Duration::from_millis(timeout_ms as u64),
|
std::time::Duration::from_millis(timeout_ms as u64),
|
||||||
) {
|
) {
|
||||||
Ok(c) => {
|
Ok(c) => {
|
||||||
@@ -534,6 +562,97 @@ pub unsafe extern "C" fn punktfunk_connect(
|
|||||||
r.unwrap_or(std::ptr::null_mut())
|
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
|
/// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
|
||||||
/// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended.
|
/// [`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
|
/// 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
|
/// # Safety
|
||||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
|
/// `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,
|
Some(c) => c,
|
||||||
None => return PunktfunkStatus::NullPointer,
|
None => return PunktfunkStatus::NullPointer,
|
||||||
};
|
};
|
||||||
|
let mode = c.inner.mode();
|
||||||
unsafe {
|
unsafe {
|
||||||
if !width.is_null() {
|
if !width.is_null() {
|
||||||
*width = c.inner.mode.width;
|
*width = mode.width;
|
||||||
}
|
}
|
||||||
if !height.is_null() {
|
if !height.is_null() {
|
||||||
*height = c.inner.mode.height;
|
*height = mode.height;
|
||||||
}
|
}
|
||||||
if !refresh_hz.is_null() {
|
if !refresh_hz.is_null() {
|
||||||
*refresh_hz = c.inner.mode.refresh_hz;
|
*refresh_hz = mode.refresh_hz;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PunktfunkStatus::Ok
|
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.
|
/// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
///
|
///
|
||||||
/// # Safety
|
/// # Safety
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
use crate::config::{Mode, Role};
|
use crate::config::{Mode, Role};
|
||||||
use crate::error::{PunktfunkError, Result};
|
use crate::error::{PunktfunkError, Result};
|
||||||
use crate::input::InputEvent;
|
use crate::input::InputEvent;
|
||||||
use crate::quic::{endpoint, io, Hello, Start, Welcome};
|
use crate::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome};
|
||||||
use crate::session::{Frame, Session};
|
use crate::session::{Frame, Session};
|
||||||
use crate::transport::UdpTransport;
|
use crate::transport::UdpTransport;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -50,10 +50,12 @@ pub struct NativeClient {
|
|||||||
audio: Receiver<AudioPacket>,
|
audio: Receiver<AudioPacket>,
|
||||||
rumble: Receiver<(u16, u16, u16)>,
|
rumble: Receiver<(u16, u16, u16)>,
|
||||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||||
|
reconfig_tx: tokio::sync::mpsc::UnboundedSender<Mode>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
worker: Option<std::thread::JoinHandle<()>>,
|
worker: Option<std::thread::JoinHandle<()>>,
|
||||||
/// The host-confirmed session mode (from the Welcome).
|
/// The currently active session mode (the Welcome's, then updated by every accepted
|
||||||
pub mode: Mode,
|
/// [`NativeClient::request_mode`]).
|
||||||
|
mode: Arc<std::sync::Mutex<Mode>>,
|
||||||
/// SHA-256 fingerprint of the certificate the host actually presented. A TOFU caller
|
/// SHA-256 fingerprint of the certificate the host actually presented. A TOFU caller
|
||||||
/// (`pin = None`) persists this and passes it as the pin from then on.
|
/// (`pin = None`) persists this and passes it as the pin from then on.
|
||||||
pub host_fingerprint: [u8; 32],
|
pub host_fingerprint: [u8; 32],
|
||||||
@@ -66,22 +68,30 @@ impl NativeClient {
|
|||||||
/// `pin`: expected SHA-256 of the host's certificate. `Some` and the host presents
|
/// `pin`: expected SHA-256 of the host's certificate. `Some` and the host presents
|
||||||
/// anything else → the handshake is rejected ([`PunktfunkError::Crypto`]). `None` = trust on
|
/// anything else → the handshake is rejected ([`PunktfunkError::Crypto`]). `None` = trust on
|
||||||
/// first use; check [`NativeClient::host_fingerprint`] after connecting.
|
/// first use; check [`NativeClient::host_fingerprint`] after connecting.
|
||||||
|
///
|
||||||
|
/// `identity`: this client's persistent self-signed identity (PEM cert + PKCS#8 key,
|
||||||
|
/// see [`endpoint::generate_identity`]), presented via TLS client auth so a host can
|
||||||
|
/// recognize a paired client. `None` = anonymous (rejected by hosts requiring pairing).
|
||||||
pub fn connect(
|
pub fn connect(
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
|
identity: Option<(String, String)>,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<NativeClient> {
|
) -> Result<NativeClient> {
|
||||||
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE);
|
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Frame>(FRAME_QUEUE);
|
||||||
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
let (audio_tx, audio_rx) = std::sync::mpsc::sync_channel::<AudioPacket>(AUDIO_QUEUE);
|
||||||
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
let (rumble_tx, rumble_rx) = std::sync::mpsc::sync_channel::<(u16, u16, u16)>(RUMBLE_QUEUE);
|
||||||
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
let (input_tx, input_rx) = tokio::sync::mpsc::unbounded_channel::<InputEvent>();
|
||||||
|
let (reconfig_tx, reconfig_rx) = tokio::sync::mpsc::unbounded_channel::<Mode>();
|
||||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||||
let shutdown = Arc::new(AtomicBool::new(false));
|
let shutdown = Arc::new(AtomicBool::new(false));
|
||||||
|
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
|
||||||
|
|
||||||
let host = host.to_string();
|
let host = host.to_string();
|
||||||
let shutdown_w = shutdown.clone();
|
let shutdown_w = shutdown.clone();
|
||||||
|
let mode_slot_w = mode_slot.clone();
|
||||||
let worker = std::thread::Builder::new()
|
let worker = std::thread::Builder::new()
|
||||||
.name("punktfunk-client".into())
|
.name("punktfunk-client".into())
|
||||||
.spawn(move || {
|
.spawn(move || {
|
||||||
@@ -101,12 +111,15 @@ impl NativeClient {
|
|||||||
port,
|
port,
|
||||||
mode,
|
mode,
|
||||||
pin,
|
pin,
|
||||||
|
identity,
|
||||||
frame_tx,
|
frame_tx,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
input_rx,
|
input_rx,
|
||||||
|
reconfig_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown: shutdown_w,
|
shutdown: shutdown_w,
|
||||||
|
mode_slot: mode_slot_w,
|
||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.map_err(PunktfunkError::Io)?;
|
.map_err(PunktfunkError::Io)?;
|
||||||
@@ -119,18 +132,100 @@ impl NativeClient {
|
|||||||
return Err(PunktfunkError::Timeout);
|
return Err(PunktfunkError::Timeout);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
*mode_slot.lock().unwrap() = negotiated;
|
||||||
Ok(NativeClient {
|
Ok(NativeClient {
|
||||||
frames: frame_rx,
|
frames: frame_rx,
|
||||||
audio: audio_rx,
|
audio: audio_rx,
|
||||||
rumble: rumble_rx,
|
rumble: rumble_rx,
|
||||||
input_tx,
|
input_tx,
|
||||||
|
reconfig_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
worker: Some(worker),
|
worker: Some(worker),
|
||||||
mode: negotiated,
|
mode: mode_slot,
|
||||||
host_fingerprint: fingerprint,
|
host_fingerprint: fingerprint,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run the PIN pairing ceremony against a host: connect (trust-on-first-use — the PIN
|
||||||
|
/// proof is what authenticates the certificates), prove knowledge of the PIN the host
|
||||||
|
/// is displaying, and return the host's now-verified fingerprint for pinning. The host
|
||||||
|
/// persists this client's fingerprint in its paired set.
|
||||||
|
///
|
||||||
|
/// `identity` is this client's persistent PEM identity (cert, key) — the same one
|
||||||
|
/// later passed to [`NativeClient::connect`]; `pin` is what the user read off the host
|
||||||
|
/// (its log / UI); `name` is the label the host stores.
|
||||||
|
pub fn pair(
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
identity: (&str, &str),
|
||||||
|
pin: &str,
|
||||||
|
name: &str,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<[u8; 32]> {
|
||||||
|
use crate::quic::{PairChallenge, PairRequest, PairResult};
|
||||||
|
|
||||||
|
let client_fp = endpoint::fingerprint_of_pem(identity.0)
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("client cert pem"))?;
|
||||||
|
let rt = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.map_err(PunktfunkError::Io)?;
|
||||||
|
let pin = pin.to_string();
|
||||||
|
let name = name.to_string();
|
||||||
|
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
||||||
|
.parse()
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
|
||||||
|
|
||||||
|
rt.block_on(async move {
|
||||||
|
// 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())))?;
|
||||||
|
let ceremony = async {
|
||||||
|
let conn = ep
|
||||||
|
.connect(remote, "punktfunk")
|
||||||
|
.map_err(|_| PunktfunkError::InvalidArg("connect"))?
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tokio::time::timeout(timeout, ceremony)
|
||||||
|
.await
|
||||||
|
.map_err(|_| PunktfunkError::Timeout)?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The currently active session mode — the Welcome's, until an accepted
|
||||||
|
/// [`NativeClient::request_mode`] switches it.
|
||||||
|
pub fn mode(&self) -> Mode {
|
||||||
|
*self.mode.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask the host to switch the live session to `mode` (no reconnect). Non-blocking:
|
||||||
|
/// the request is queued; on acceptance the stream continues at the new mode (next
|
||||||
|
/// frames open with an IDR carrying new parameter sets) and [`NativeClient::mode`]
|
||||||
|
/// reflects it. A rejected request leaves the session unchanged.
|
||||||
|
pub fn request_mode(&self, mode: Mode) -> Result<()> {
|
||||||
|
self.reconfig_tx
|
||||||
|
.send(mode)
|
||||||
|
.map_err(|_| PunktfunkError::Closed)
|
||||||
|
}
|
||||||
|
|
||||||
/// Pull the next reassembled, FEC-recovered access unit; [`PunktfunkError::NoFrame`] on
|
/// Pull the next reassembled, FEC-recovered access unit; [`PunktfunkError::NoFrame`] on
|
||||||
/// timeout, [`PunktfunkError::Closed`]-class errors once the session ended.
|
/// timeout, [`PunktfunkError::Closed`]-class errors once the session ended.
|
||||||
///
|
///
|
||||||
@@ -187,33 +282,43 @@ struct WorkerArgs {
|
|||||||
port: u16,
|
port: u16,
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
|
identity: Option<(String, String)>,
|
||||||
frame_tx: SyncSender<Frame>,
|
frame_tx: SyncSender<Frame>,
|
||||||
audio_tx: SyncSender<AudioPacket>,
|
audio_tx: SyncSender<AudioPacket>,
|
||||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||||
|
reconfig_rx: tokio::sync::mpsc::UnboundedReceiver<Mode>,
|
||||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||||
shutdown: Arc<AtomicBool>,
|
shutdown: Arc<AtomicBool>,
|
||||||
|
mode_slot: Arc<std::sync::Mutex<Mode>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The worker: QUIC handshake, then the input/datagram tasks + the blocking data-plane pump.
|
/// The worker: QUIC handshake, then the input/datagram/control tasks + the blocking
|
||||||
|
/// data-plane pump.
|
||||||
async fn worker_main(args: WorkerArgs) {
|
async fn worker_main(args: WorkerArgs) {
|
||||||
let WorkerArgs {
|
let WorkerArgs {
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
mode,
|
mode,
|
||||||
pin,
|
pin,
|
||||||
|
identity,
|
||||||
frame_tx,
|
frame_tx,
|
||||||
audio_tx,
|
audio_tx,
|
||||||
rumble_tx,
|
rumble_tx,
|
||||||
mut input_rx,
|
mut input_rx,
|
||||||
|
mut reconfig_rx,
|
||||||
ready_tx,
|
ready_tx,
|
||||||
shutdown,
|
shutdown,
|
||||||
|
mode_slot,
|
||||||
} = args;
|
} = args;
|
||||||
let setup = async {
|
let setup = async {
|
||||||
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
|
.map_err(|_| PunktfunkError::InvalidArg("host:port"))?;
|
||||||
let (ep, observed) = endpoint::client_pinned(pin);
|
let (ep, observed) = endpoint::client_pinned_with_identity(
|
||||||
|
pin,
|
||||||
|
identity.as_ref().map(|(c, k)| (c.as_str(), k.as_str())),
|
||||||
|
);
|
||||||
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
let ep = ep.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
let conn = ep
|
let conn = ep
|
||||||
.connect(remote, "punktfunk")
|
.connect(remote, "punktfunk")
|
||||||
@@ -264,16 +369,17 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
let transport =
|
let transport =
|
||||||
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
UdpTransport::connect(&format!("0.0.0.0:{udp_port}"), &host_udp.to_string())?;
|
||||||
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
let session = Session::new(welcome.session_config(Role::Client), Box::new(transport))?;
|
||||||
Ok::<_, PunktfunkError>((conn, session, welcome.mode, fingerprint))
|
Ok::<_, PunktfunkError>((conn, session, send, recv, welcome.mode, fingerprint))
|
||||||
};
|
};
|
||||||
|
|
||||||
let (conn, mut session, negotiated, fingerprint) = match setup.await {
|
let (conn, mut session, mut ctrl_send, mut ctrl_recv, negotiated, fingerprint) =
|
||||||
Ok(t) => t,
|
match setup.await {
|
||||||
Err(e) => {
|
Ok(t) => t,
|
||||||
let _ = ready_tx.send(Err(e));
|
Err(e) => {
|
||||||
return;
|
let _ = ready_tx.send(Err(e));
|
||||||
}
|
return;
|
||||||
};
|
}
|
||||||
|
};
|
||||||
let _ = ready_tx.send(Ok((negotiated, fingerprint)));
|
let _ = ready_tx.send(Ok((negotiated, fingerprint)));
|
||||||
|
|
||||||
// Input task: embedder events → QUIC datagrams.
|
// Input task: embedder events → QUIC datagrams.
|
||||||
@@ -284,6 +390,35 @@ async fn worker_main(args: WorkerArgs) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Control task: the handshake stream stays open for mid-stream renegotiation. One
|
||||||
|
// request at a time — write Reconfigure, await Reconfigured, publish the active mode.
|
||||||
|
{
|
||||||
|
let mode_slot = mode_slot.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(want) = reconfig_rx.recv().await {
|
||||||
|
if io::write_msg(&mut ctrl_send, &Reconfigure { mode: want }.encode())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let ack = match io::read_msg(&mut ctrl_recv).await {
|
||||||
|
Ok(b) => match Reconfigured::decode(&b) {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => break, // protocol error — stop renegotiating
|
||||||
|
},
|
||||||
|
Err(_) => break, // stream closed
|
||||||
|
};
|
||||||
|
if ack.accepted {
|
||||||
|
*mode_slot.lock().unwrap() = ack.mode;
|
||||||
|
tracing::info!(mode = ?ack.mode, "host accepted mode switch");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(requested = ?want, active = ?ack.mode, "host rejected mode switch");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
||||||
// newest packet rather than backing up the QUIC receive path).
|
// newest packet rather than backing up the QUIC receive path).
|
||||||
let dgram_conn = conn.clone();
|
let dgram_conn = conn.clone();
|
||||||
|
|||||||
@@ -58,6 +58,179 @@ pub struct Start {
|
|||||||
pub client_udp_port: u16,
|
pub client_udp_port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `client → host`, any time after [`Start`]: switch the session to a new display mode
|
||||||
|
/// (window resized, refresh changed) without reconnecting. The host answers with
|
||||||
|
/// [`Reconfigured`]; on acceptance it rebuilds its virtual output + encoder at the new
|
||||||
|
/// mode and the stream continues over the unchanged data plane — the first new-mode frame
|
||||||
|
/// is an IDR with in-band parameter sets, which is all a decoder needs to follow.
|
||||||
|
///
|
||||||
|
/// Post-handshake messages carry a type byte after the magic (the handshake itself is
|
||||||
|
/// positional and stays untyped for wire compatibility).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Reconfigure {
|
||||||
|
pub mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host → client`: answer to [`Reconfigure`]. `accepted = false` means the requested
|
||||||
|
/// mode was rejected (e.g. exceeds encoder limits) and the session continues at `mode`
|
||||||
|
/// (the still-active one); `true` means `mode` is now being switched to live.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct Reconfigured {
|
||||||
|
pub accepted: bool,
|
||||||
|
pub mode: Mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
|
pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||||
|
/// Type byte of [`Reconfigured`].
|
||||||
|
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.
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Type byte of [`PairRequest`].
|
||||||
|
pub const MSG_PAIR_REQUEST: u8 = 0x10;
|
||||||
|
/// Type byte of [`PairChallenge`].
|
||||||
|
pub const MSG_PAIR_CHALLENGE: u8 = 0x11;
|
||||||
|
/// Type byte of [`PairProof`].
|
||||||
|
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.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PairRequest {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `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)]
|
||||||
|
pub struct PairChallenge {
|
||||||
|
pub salt: [u8; 16],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `client → host`: the proof, see [`pair_proof`].
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PairProof {
|
||||||
|
pub hmac: [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `host → client`: ceremony outcome.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PairResult {
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
b.push(MSG_PAIR_REQUEST);
|
||||||
|
b.push(n as u8);
|
||||||
|
b.extend_from_slice(&name[..n]);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<PairRequest> {
|
||||||
|
if b.len() < 6 || &b[0..4] != 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"));
|
||||||
|
}
|
||||||
|
Ok(PairRequest {
|
||||||
|
name: String::from_utf8_lossy(&b[6..6 + n]).into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairChallenge {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(21);
|
||||||
|
b.extend_from_slice(MAGIC);
|
||||||
|
b.push(MSG_PAIR_CHALLENGE);
|
||||||
|
b.extend_from_slice(&self.salt);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<PairChallenge> {
|
||||||
|
if b.len() < 21 || &b[0..4] != 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairProof {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(37);
|
||||||
|
b.extend_from_slice(MAGIC);
|
||||||
|
b.push(MSG_PAIR_PROOF);
|
||||||
|
b.extend_from_slice(&self.hmac);
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<PairProof> {
|
||||||
|
if b.len() < 37 || &b[0..4] != 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairResult {
|
||||||
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
|
let mut b = Vec::with_capacity(6);
|
||||||
|
b.extend_from_slice(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 {
|
||||||
|
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] {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
impl Hello {
|
impl Hello {
|
||||||
pub fn encode(&self) -> Vec<u8> {
|
pub fn encode(&self) -> Vec<u8> {
|
||||||
let mut b = Vec::with_capacity(20);
|
let mut b = Vec::with_capacity(20);
|
||||||
@@ -177,6 +350,62 @@ impl Start {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.push(MSG_RECONFIGURE);
|
||||||
|
b.extend_from_slice(&self.mode.width.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.mode.height.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<Reconfigure> {
|
||||||
|
if b.len() < 17 || &b[0..4] != 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]]);
|
||||||
|
Ok(Reconfigure {
|
||||||
|
mode: Mode {
|
||||||
|
width: u32at(5),
|
||||||
|
height: u32at(9),
|
||||||
|
refresh_hz: u32at(13),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.push(MSG_RECONFIGURED);
|
||||||
|
b.push(self.accepted as u8);
|
||||||
|
b.extend_from_slice(&self.mode.width.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.mode.height.to_le_bytes());
|
||||||
|
b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes());
|
||||||
|
b
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decode(b: &[u8]) -> Result<Reconfigured> {
|
||||||
|
if b.len() < 18 || &b[0..4] != 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]]);
|
||||||
|
Ok(Reconfigured {
|
||||||
|
accepted: b[5] != 0,
|
||||||
|
mode: Mode {
|
||||||
|
width: u32at(6),
|
||||||
|
height: u32at(10),
|
||||||
|
refresh_hz: u32at(14),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frame a message for the control stream: `u16 LE length || payload`.
|
/// Frame a message for the control stream: `u16 LE length || payload`.
|
||||||
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||||
let mut b = Vec::with_capacity(2 + payload.len());
|
let mut b = Vec::with_capacity(2 + payload.len());
|
||||||
@@ -293,11 +522,38 @@ pub mod endpoint {
|
|||||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||||
let server_config = quinn::ServerConfig::with_single_cert(vec![cert_der], key_der)
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
|
// Client auth is OFFERED but optional: a client that presents its self-signed
|
||||||
|
// identity is fingerprinted post-handshake (pairing / --require-pairing checks);
|
||||||
|
// one that presents none still connects (and is rejected at the app layer when
|
||||||
|
// pairing is required).
|
||||||
|
let rustls_cfg = rustls::ServerConfig::builder()
|
||||||
|
.with_client_cert_verifier(Arc::new(AcceptAnyClientCert))
|
||||||
|
.with_single_cert(vec![cert_der], key_der)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||||
|
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
|
||||||
|
let server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
|
||||||
Ok(quinn::Endpoint::server(server_config, addr)?)
|
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh self-signed identity (certificate + PKCS#8 key, both PEM) — what a
|
||||||
|
/// client persists once and presents on every connect so hosts can recognize it.
|
||||||
|
pub fn generate_identity() -> anyhow_result::Result<(String, String)> {
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["punktfunk-client".into()])
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||||
|
Ok((cert.cert.pem(), cert.key_pair.serialize_pem()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fingerprint of the client certificate a connection presented (host side), if any.
|
||||||
|
pub fn peer_fingerprint(conn: &quinn::Connection) -> Option<[u8; 32]> {
|
||||||
|
let identity = conn.peer_identity()?;
|
||||||
|
let certs = identity
|
||||||
|
.downcast::<Vec<rustls::pki_types::CertificateDer<'static>>>()
|
||||||
|
.ok()?;
|
||||||
|
certs.first().map(|c| cert_fingerprint(c.as_ref()))
|
||||||
|
}
|
||||||
|
|
||||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
||||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||||
use sha2::Digest;
|
use sha2::Digest;
|
||||||
@@ -332,16 +588,40 @@ pub mod endpoint {
|
|||||||
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
|
/// `None` accepts any (trust-on-first-use). Either way the observed fingerprint is
|
||||||
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
|
/// written to the returned slot during the handshake, so a TOFU caller can persist it.
|
||||||
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
|
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
|
||||||
|
client_pinned_with_identity(pin, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`client_pinned`], additionally presenting a client identity (PEM cert + PKCS#8
|
||||||
|
/// key) via TLS client auth — how a paired client identifies itself to the host.
|
||||||
|
pub fn client_pinned_with_identity(
|
||||||
|
pin: Option<[u8; 32]>,
|
||||||
|
identity: Option<(&str, &str)>,
|
||||||
|
) -> PinnedClient {
|
||||||
let observed = Arc::new(Mutex::new(None));
|
let observed = Arc::new(Mutex::new(None));
|
||||||
let ep = (|| {
|
let ep = (|| {
|
||||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||||
let rustls_cfg = rustls::ClientConfig::builder()
|
let builder = rustls::ClientConfig::builder()
|
||||||
.dangerous()
|
.dangerous()
|
||||||
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
||||||
pin,
|
pin,
|
||||||
observed: observed.clone(),
|
observed: observed.clone(),
|
||||||
}))
|
}));
|
||||||
.with_no_client_auth();
|
let rustls_cfg = match identity {
|
||||||
|
None => builder.with_no_client_auth(),
|
||||||
|
Some((cert_pem, key_pem)) => {
|
||||||
|
use rustls::pki_types::pem::PemObject;
|
||||||
|
let cert =
|
||||||
|
rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow_result::Error::msg(format!("client cert pem: {e}"))
|
||||||
|
})?;
|
||||||
|
let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client key pem: {e}")))?;
|
||||||
|
builder
|
||||||
|
.with_client_auth_cert(vec![cert], key)
|
||||||
|
.map_err(|e| anyhow_result::Error::msg(format!("client auth: {e}")))?
|
||||||
|
}
|
||||||
|
};
|
||||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||||
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
.map_err(|e| anyhow_result::Error::msg(format!("quic client config: {e}")))?;
|
||||||
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
let mut ep = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||||
@@ -377,6 +657,69 @@ pub mod endpoint {
|
|||||||
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
/// Fingerprint-pinning verifier: trust is the SHA-256 of the host's (self-signed) leaf
|
||||||
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
/// cert, not a CA chain. With no pin it accepts any cert (TOFU) but still records what
|
||||||
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
||||||
|
/// Server-side client-cert verifier: accept any (self-signed) client certificate but
|
||||||
|
/// verify the handshake signature for real — possession of the presented cert's key is
|
||||||
|
/// what makes the post-handshake fingerprint ([`peer_fingerprint`]) meaningful.
|
||||||
|
/// Authorization (is this fingerprint paired?) happens at the application layer.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AcceptAnyClientCert;
|
||||||
|
|
||||||
|
impl rustls::server::danger::ClientCertVerifier for AcceptAnyClientCert {
|
||||||
|
fn root_hint_subjects(&self) -> &[rustls::DistinguishedName] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client_auth_mandatory(&self) -> bool {
|
||||||
|
false // unpaired/legacy clients still connect; gating is per-feature
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_client_cert(
|
||||||
|
&self,
|
||||||
|
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||||
|
_now: rustls::pki_types::UnixTime,
|
||||||
|
) -> std::result::Result<rustls::server::danger::ClientCertVerified, rustls::Error>
|
||||||
|
{
|
||||||
|
Ok(rustls::server::danger::ClientCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls12_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
message: &[u8],
|
||||||
|
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||||
|
{
|
||||||
|
rustls::crypto::verify_tls13_signature(
|
||||||
|
message,
|
||||||
|
cert,
|
||||||
|
dss,
|
||||||
|
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.signature_verification_algorithms
|
||||||
|
.supported_schemes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct PinVerify {
|
struct PinVerify {
|
||||||
pin: Option<[u8; 32]>,
|
pin: Option<[u8; 32]>,
|
||||||
@@ -492,6 +835,34 @@ mod tests {
|
|||||||
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reconfigure_roundtrip() {
|
||||||
|
let rq = Reconfigure {
|
||||||
|
mode: Mode {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
refresh_hz: 144,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigure::decode(&rq.encode()).unwrap(), rq);
|
||||||
|
for accepted in [true, false] {
|
||||||
|
let rs = Reconfigured {
|
||||||
|
accepted,
|
||||||
|
mode: rq.mode,
|
||||||
|
};
|
||||||
|
assert_eq!(Reconfigured::decode(&rs.encode()).unwrap(), rs);
|
||||||
|
}
|
||||||
|
// The type byte separates the post-handshake messages from each other.
|
||||||
|
assert!(Reconfigure::decode(
|
||||||
|
&Reconfigured {
|
||||||
|
accepted: true,
|
||||||
|
mode: rq.mode
|
||||||
|
}
|
||||||
|
.encode()
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn audio_datagram_roundtrip() {
|
fn audio_datagram_roundtrip() {
|
||||||
let opus = [0x42u8; 97];
|
let opus = [0x42u8; 97];
|
||||||
|
|||||||
+362
-24
@@ -26,7 +26,10 @@ use anyhow::{anyhow, Context, Result};
|
|||||||
use punktfunk_core::config::{FecConfig, FecScheme, Role};
|
use punktfunk_core::config::{FecConfig, FecScheme, Role};
|
||||||
use punktfunk_core::input::{InputEvent, InputKind};
|
use punktfunk_core::input::{InputEvent, InputKind};
|
||||||
use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF};
|
use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF};
|
||||||
use punktfunk_core::quic::{endpoint, io, Hello, Start, Welcome};
|
use punktfunk_core::quic::{
|
||||||
|
endpoint, io, Hello, PairChallenge, PairProof, PairRequest, PairResult, Reconfigure,
|
||||||
|
Reconfigured, Start, Welcome,
|
||||||
|
};
|
||||||
use punktfunk_core::transport::UdpTransport;
|
use punktfunk_core::transport::UdpTransport;
|
||||||
use punktfunk_core::Session;
|
use punktfunk_core::Session;
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
@@ -50,6 +53,62 @@ pub struct M3Options {
|
|||||||
pub frames: u32,
|
pub frames: u32,
|
||||||
/// Exit after this many sessions (0 = serve forever).
|
/// Exit after this many sessions (0 = serve forever).
|
||||||
pub max_sessions: u32,
|
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).
|
||||||
|
pub require_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.
|
||||||
|
pub paired_store: Option<std::path::PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host's paired punktfunk/1 clients: `~/.config/punktfunk/punktfunk1-paired.json`.
|
||||||
|
/// (Separate from GameStream pairing, which has its own store and ceremony.)
|
||||||
|
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
struct PairedClients {
|
||||||
|
clients: Vec<PairedClient>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
|
struct PairedClient {
|
||||||
|
name: String,
|
||||||
|
/// Hex SHA-256 of the client's certificate.
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store plus where it persists (the path is injectable for tests).
|
||||||
|
struct PairedState {
|
||||||
|
path: std::path::PathBuf,
|
||||||
|
clients: PairedClients,
|
||||||
|
}
|
||||||
|
|
||||||
|
type PairedStore = Arc<std::sync::Mutex<PairedState>>;
|
||||||
|
|
||||||
|
fn paired_path() -> Result<std::path::PathBuf> {
|
||||||
|
let home = std::env::var("HOME").context("HOME unset")?;
|
||||||
|
Ok(std::path::PathBuf::from(home).join(".config/punktfunk/punktfunk1-paired.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_paired(path: &std::path::Path) -> PairedClients {
|
||||||
|
std::fs::read(path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)?)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PairedClients {
|
||||||
|
fn contains(&self, fp: &[u8; 32]) -> bool {
|
||||||
|
let hex = fingerprint_hex(fp);
|
||||||
|
self.clients.iter().any(|c| c.fingerprint == hex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping).
|
/// Deterministic test frame: `u32 LE index` then `data[i] = idx + i` (wrapping).
|
||||||
@@ -107,6 +166,18 @@ async fn serve(opts: M3Options) -> Result<()> {
|
|||||||
// One audio capturer for the whole host lifetime, handed from session to session
|
// One audio capturer for the whole host lifetime, handed from session to session
|
||||||
// (PipeWire streams have no cheap teardown — see AudioCapSlot).
|
// (PipeWire streams have no cheap teardown — see AudioCapSlot).
|
||||||
let audio_cap: AudioCapSlot = Arc::new(std::sync::Mutex::new(None));
|
let audio_cap: AudioCapSlot = Arc::new(std::sync::Mutex::new(None));
|
||||||
|
let paired_at = match &opts.paired_store {
|
||||||
|
Some(p) => p.clone(),
|
||||||
|
None => paired_path()?,
|
||||||
|
};
|
||||||
|
let paired: PairedStore = Arc::new(std::sync::Mutex::new(PairedState {
|
||||||
|
clients: load_paired(&paired_at),
|
||||||
|
path: paired_at,
|
||||||
|
}));
|
||||||
|
if opts.require_pairing {
|
||||||
|
let n = paired.lock().unwrap().clients.clients.len();
|
||||||
|
tracing::info!(paired = n, "pairing required for sessions");
|
||||||
|
}
|
||||||
|
|
||||||
let mut served = 0u32;
|
let mut served = 0u32;
|
||||||
loop {
|
loop {
|
||||||
@@ -123,7 +194,7 @@ async fn serve(opts: M3Options) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
tracing::info!(%peer, "punktfunk/1 client connected");
|
tracing::info!(%peer, "punktfunk/1 client connected");
|
||||||
if let Err(e) = serve_session(conn, &opts, &audio_cap).await {
|
if let Err(e) = serve_session(conn, &opts, &audio_cap, &fingerprint, &paired).await {
|
||||||
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error");
|
tracing::warn!(%peer, error = %format!("{e:#}"), "session ended with error");
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(%peer, "session complete");
|
tracing::info!(%peer, "session complete");
|
||||||
@@ -147,28 +218,119 @@ const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10
|
|||||||
/// PipeWire thread + core connection + live capture node on the daemon every session.
|
/// PipeWire thread + core connection + live capture node on the daemon every session.
|
||||||
type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>;
|
type AudioCapSlot = Arc<std::sync::Mutex<Option<Box<dyn crate::audio::AudioCapturer>>>>;
|
||||||
|
|
||||||
|
/// Pairing needs a human in the loop (reading the PIN off the host, typing it into the
|
||||||
|
/// 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.
|
||||||
|
async fn pair_ceremony(
|
||||||
|
conn: &quinn::Connection,
|
||||||
|
mut send: quinn::SendStream,
|
||||||
|
mut recv: quinn::RecvStream,
|
||||||
|
req: PairRequest,
|
||||||
|
host_fp: &[u8; 32],
|
||||||
|
paired: &PairedStore,
|
||||||
|
opts: &M3Options,
|
||||||
|
) -> Result<()> {
|
||||||
|
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}"
|
||||||
|
);
|
||||||
|
|
||||||
|
io::write_msg(&mut send, &PairChallenge { salt }.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"))??;
|
||||||
|
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;
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
let mut store = paired.lock().unwrap();
|
||||||
|
let hex = fingerprint_hex(&client_fp);
|
||||||
|
store.clients.clients.retain(|c| c.fingerprint != hex); // re-pair updates the name
|
||||||
|
store.clients.clients.push(PairedClient {
|
||||||
|
name: req.name.clone(),
|
||||||
|
fingerprint: hex,
|
||||||
|
});
|
||||||
|
if let Err(e) = save_paired(&store) {
|
||||||
|
tracing::error!(error = %format!("{e:#}"), "could not persist paired clients");
|
||||||
|
}
|
||||||
|
tracing::info!(name = %req.name, "pairing complete — client trusted");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(name = %req.name, "pairing FAILED (wrong PIN) — fingerprint not stored");
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
conn.close(0u32.into(), b"pairing done");
|
||||||
|
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
/// One client session: handshake → input/audio planes → data plane until done/disconnect.
|
||||||
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
/// Everything torn down on return (RAII: virtual output, encoder, threads via channel close).
|
||||||
|
/// A connection whose first message is a PairRequest runs the pairing ceremony instead.
|
||||||
async fn serve_session(
|
async fn serve_session(
|
||||||
conn: quinn::Connection,
|
conn: quinn::Connection,
|
||||||
opts: &M3Options,
|
opts: &M3Options,
|
||||||
audio_cap: &AudioCapSlot,
|
audio_cap: &AudioCapSlot,
|
||||||
|
host_fp: &[u8; 32],
|
||||||
|
paired: &PairedStore,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let peer = conn.remote_address();
|
let peer = conn.remote_address();
|
||||||
|
|
||||||
|
// First message decides what this connection is: a pairing ceremony or a session.
|
||||||
|
let (mut send, mut recv) = tokio::time::timeout(HANDSHAKE_TIMEOUT, conn.accept_bi())
|
||||||
|
.await
|
||||||
|
.map_err(|_| anyhow!("control stream timeout"))?
|
||||||
|
.context("accept control stream")?;
|
||||||
|
let first = tokio::time::timeout(HANDSHAKE_TIMEOUT, io::read_msg(&mut recv))
|
||||||
|
.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 source = opts.source;
|
let source = opts.source;
|
||||||
let frames = opts.frames;
|
let frames = opts.frames;
|
||||||
let handshake = async {
|
let handshake = async {
|
||||||
let (mut send, mut recv) = conn.accept_bi().await.context("accept control stream")?;
|
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
||||||
|
|
||||||
let hello = Hello::decode(&io::read_msg(&mut recv).await?)
|
|
||||||
.map_err(|e| anyhow!("Hello decode: {e:?}"))?;
|
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(
|
||||||
hello.abi_version == punktfunk_core::ABI_VERSION,
|
hello.abi_version == punktfunk_core::ABI_VERSION,
|
||||||
"ABI mismatch: client {} host {}",
|
"ABI mismatch: client {} host {}",
|
||||||
hello.abi_version,
|
hello.abi_version,
|
||||||
punktfunk_core::ABI_VERSION
|
punktfunk_core::ABI_VERSION
|
||||||
);
|
);
|
||||||
|
if opts.require_pairing {
|
||||||
|
let known = endpoint::peer_fingerprint(&conn)
|
||||||
|
.map(|fp| paired.lock().unwrap().clients.contains(&fp))
|
||||||
|
.unwrap_or(false);
|
||||||
|
anyhow::ensure!(
|
||||||
|
known,
|
||||||
|
"unpaired client rejected (this host requires pairing — run the PIN ceremony first)"
|
||||||
|
);
|
||||||
|
}
|
||||||
crate::encode::validate_dimensions(
|
crate::encode::validate_dimensions(
|
||||||
crate::encode::Codec::H265,
|
crate::encode::Codec::H265,
|
||||||
hello.mode.width,
|
hello.mode.width,
|
||||||
@@ -211,9 +373,46 @@ async fn serve_session(
|
|||||||
let (hello, welcome, udp_port, start) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
|
let (hello, welcome, udp_port, start) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
|
||||||
|
let (mut ctrl_send, mut ctrl_recv) = (send, recv);
|
||||||
let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port);
|
let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port);
|
||||||
tracing::info!(%client_udp, udp_port, mode = ?hello.mode, "handshake complete — streaming");
|
tracing::info!(%client_udp, udp_port, mode = ?hello.mode, "handshake complete — streaming");
|
||||||
|
|
||||||
|
// Control task: the handshake stream stays open for mid-stream renegotiation. A
|
||||||
|
// validated Reconfigure is acked, then handed to the data-plane thread, which rebuilds
|
||||||
|
// capture/encoder/virtual output at the new mode (the data plane itself is untouched).
|
||||||
|
let (reconfig_tx, reconfig_rx) = std::sync::mpsc::channel::<punktfunk_core::Mode>();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut active = hello.mode;
|
||||||
|
while let Ok(msg) = io::read_msg(&mut ctrl_recv).await {
|
||||||
|
let Ok(req) = Reconfigure::decode(&msg) else {
|
||||||
|
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();
|
||||||
|
if ok {
|
||||||
|
active = req.mode;
|
||||||
|
tracing::info!(mode = ?req.mode, "mode switch accepted");
|
||||||
|
} else {
|
||||||
|
tracing::warn!(mode = ?req.mode, "mode switch rejected (invalid dimensions)");
|
||||||
|
}
|
||||||
|
let ack = Reconfigured {
|
||||||
|
accepted: ok,
|
||||||
|
mode: active,
|
||||||
|
};
|
||||||
|
if io::write_msg(&mut ctrl_send, &ack.encode()).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ok && reconfig_tx.send(req.mode).is_err() {
|
||||||
|
break; // data plane gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Input plane: QUIC datagrams → channel → a native injector thread (the injector owns
|
// Input plane: QUIC datagrams → channel → a native injector thread (the injector owns
|
||||||
// non-Send compositor state, so it lives on its own thread). The thread also owns the
|
// non-Send compositor state, so it lives on its own thread). The thread also owns the
|
||||||
// session's virtual gamepads and sends force feedback back over `conn`. It exits when
|
// session's virtual gamepads and sends force feedback back over `conn`. It exits when
|
||||||
@@ -283,7 +482,9 @@ async fn serve_session(
|
|||||||
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
.map_err(|e| anyhow!("host session: {e:?}"))?;
|
||||||
match source {
|
match source {
|
||||||
M3Source::Synthetic => synthetic_stream(&mut session, frames, &stop_stream),
|
M3Source::Synthetic => synthetic_stream(&mut session, frames, &stop_stream),
|
||||||
M3Source::Virtual => virtual_stream(&mut session, mode, seconds, &stop_stream),
|
M3Source::Virtual => {
|
||||||
|
virtual_stream(&mut session, mode, seconds, &stop_stream, &reconfig_rx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -548,37 +749,36 @@ fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Re
|
|||||||
|
|
||||||
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
/// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs
|
||||||
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
/// stamped with the capture wall clock (the client derives per-frame pipeline latency).
|
||||||
|
///
|
||||||
|
/// `reconfig` delivers accepted mid-stream mode switches: the capture/encode pipeline is
|
||||||
|
/// rebuilt at the new mode (capturer drop tears down the PipeWire stream and, via its
|
||||||
|
/// keepalive, the virtual output) while the data-plane `session` continues untouched —
|
||||||
|
/// the rebuilt encoder opens with an IDR + in-band parameter sets.
|
||||||
fn virtual_stream(
|
fn virtual_stream(
|
||||||
session: &mut Session,
|
session: &mut Session,
|
||||||
mode: punktfunk_core::Mode,
|
mode: punktfunk_core::Mode,
|
||||||
seconds: u32,
|
seconds: u32,
|
||||||
stop: &AtomicBool,
|
stop: &AtomicBool,
|
||||||
|
reconfig: &std::sync::mpsc::Receiver<punktfunk_core::Mode>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let compositor = crate::vdisplay::detect().context("detect compositor")?;
|
let compositor = crate::vdisplay::detect().context("detect compositor")?;
|
||||||
tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display");
|
tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display");
|
||||||
let mut vd = crate::vdisplay::open(compositor)?;
|
let mut vd = crate::vdisplay::open(compositor)?;
|
||||||
let vout = vd.create(mode).context("create virtual output")?;
|
let (mut capturer, mut enc, mut frame, mut interval) = build_pipeline(&mut vd, mode)?;
|
||||||
let mut capturer =
|
|
||||||
crate::capture::capture_virtual_output(vout).context("capture virtual output")?;
|
|
||||||
capturer.set_active(true);
|
|
||||||
|
|
||||||
let mut frame = capturer.next_frame().context("first frame")?;
|
|
||||||
let mut enc = crate::encode::open_video(
|
|
||||||
crate::encode::Codec::H265,
|
|
||||||
frame.format,
|
|
||||||
frame.width,
|
|
||||||
frame.height,
|
|
||||||
mode.refresh_hz,
|
|
||||||
20_000_000,
|
|
||||||
frame.is_cuda(),
|
|
||||||
)
|
|
||||||
.context("open NVENC")?;
|
|
||||||
|
|
||||||
let interval = std::time::Duration::from_secs_f64(1.0 / mode.refresh_hz.max(1) as f64);
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(seconds as u64);
|
||||||
let mut next = std::time::Instant::now();
|
let mut next = std::time::Instant::now();
|
||||||
let mut sent: u64 = 0;
|
let mut sent: u64 = 0;
|
||||||
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
while !stop.load(Ordering::SeqCst) && std::time::Instant::now() < deadline {
|
||||||
|
if let Ok(new_mode) = reconfig.try_recv() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
if let Some(f) = capturer.try_latest().context("capture")? {
|
if let Some(f) = capturer.try_latest().context("capture")? {
|
||||||
frame = f;
|
frame = f;
|
||||||
}
|
}
|
||||||
@@ -605,6 +805,38 @@ fn virtual_stream(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One mode's capture/encode pipeline: (capturer, encoder, first frame, frame interval).
|
||||||
|
/// Dropping the capturer tears down the PipeWire stream and the virtual output with it.
|
||||||
|
type Pipeline = (
|
||||||
|
Box<dyn crate::capture::Capturer>,
|
||||||
|
Box<dyn crate::encode::Encoder>,
|
||||||
|
crate::capture::CapturedFrame,
|
||||||
|
std::time::Duration,
|
||||||
|
);
|
||||||
|
|
||||||
|
fn build_pipeline(
|
||||||
|
vd: &mut Box<dyn crate::vdisplay::VirtualDisplay>,
|
||||||
|
mode: punktfunk_core::Mode,
|
||||||
|
) -> Result<Pipeline> {
|
||||||
|
let vout = vd.create(mode).context("create virtual output")?;
|
||||||
|
let mut capturer =
|
||||||
|
crate::capture::capture_virtual_output(vout).context("capture virtual output")?;
|
||||||
|
capturer.set_active(true);
|
||||||
|
let frame = capturer.next_frame().context("first frame")?;
|
||||||
|
let enc = crate::encode::open_video(
|
||||||
|
crate::encode::Codec::H265,
|
||||||
|
frame.format,
|
||||||
|
frame.width,
|
||||||
|
frame.height,
|
||||||
|
mode.refresh_hz,
|
||||||
|
20_000_000,
|
||||||
|
frame.is_cuda(),
|
||||||
|
)
|
||||||
|
.context("open NVENC")?;
|
||||||
|
let interval = std::time::Duration::from_secs_f64(1.0 / mode.refresh_hz.max(1) as f64);
|
||||||
|
Ok((capturer, enc, frame, interval))
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -692,6 +924,9 @@ mod tests {
|
|||||||
seconds: 0,
|
seconds: 0,
|
||||||
frames: 25,
|
frames: 25,
|
||||||
max_sessions: 3,
|
max_sessions: 3,
|
||||||
|
require_pairing: false,
|
||||||
|
pairing_pin: None,
|
||||||
|
paired_store: None,
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
@@ -708,6 +943,8 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
observed.as_mut_ptr(),
|
observed.as_mut_ptr(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -721,6 +958,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!((w, h, hz), (1280, 720, 60));
|
assert_eq!((w, h, hz), (1280, 720, 60));
|
||||||
|
|
||||||
|
// Mid-stream renegotiation: request a new mode, the host acks on the control
|
||||||
|
// stream, and punktfunk_connection_mode reflects the switch.
|
||||||
|
assert_eq!(
|
||||||
|
unsafe {
|
||||||
|
punktfunk_core::abi::punktfunk_connection_request_mode(conn, 1920, 1080, 144)
|
||||||
|
},
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
);
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||||
|
loop {
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { punktfunk_connection_mode(conn, &mut w, &mut h, &mut hz) },
|
||||||
|
PunktfunkStatus::Ok
|
||||||
|
);
|
||||||
|
if (w, h, hz) == (1920, 1080, 144) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
std::time::Instant::now() < deadline,
|
||||||
|
"mode switch not acked (still {w}x{h}@{hz})"
|
||||||
|
);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
|
}
|
||||||
|
|
||||||
unsafe { pull_verified(conn, 25) };
|
unsafe { pull_verified(conn, 25) };
|
||||||
|
|
||||||
let ev = punktfunk_core::input::InputEvent {
|
let ev = punktfunk_core::input::InputEvent {
|
||||||
@@ -747,6 +1008,8 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
observed.as_ptr(),
|
observed.as_ptr(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -765,6 +1028,8 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
bad.as_ptr(),
|
bad.as_ptr(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -782,6 +1047,8 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
std::ptr::null(),
|
std::ptr::null(),
|
||||||
std::ptr::null_mut(),
|
std::ptr::null_mut(),
|
||||||
|
std::ptr::null(),
|
||||||
|
std::ptr::null(),
|
||||||
10_000,
|
10_000,
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
@@ -791,4 +1058,75 @@ mod tests {
|
|||||||
|
|
||||||
host.join().unwrap().unwrap();
|
host.join().unwrap().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_paired_path() -> std::path::PathBuf {
|
||||||
|
std::env::temp_dir().join(format!("punktfunk-paired-test-{}.json", std::process::id()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The PIN pairing ceremony + the --require-pairing gate, end to end in-process:
|
||||||
|
/// wrong PIN rejected; right PIN pairs and returns the host fingerprint; a paired
|
||||||
|
/// identity gets a session on a pairing-required host; an anonymous client does not.
|
||||||
|
#[test]
|
||||||
|
fn pairing_ceremony_and_gate() {
|
||||||
|
use punktfunk_core::client::NativeClient;
|
||||||
|
use punktfunk_core::quic::endpoint;
|
||||||
|
|
||||||
|
let host = std::thread::spawn(|| {
|
||||||
|
run(M3Options {
|
||||||
|
port: 19778,
|
||||||
|
source: M3Source::Synthetic,
|
||||||
|
seconds: 0,
|
||||||
|
frames: 25,
|
||||||
|
max_sessions: 4,
|
||||||
|
require_pairing: true,
|
||||||
|
pairing_pin: Some("4321".into()),
|
||||||
|
paired_store: Some(test_paired_path()),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
let timeout = std::time::Duration::from_secs(10);
|
||||||
|
let (cert, key) = endpoint::generate_identity().unwrap();
|
||||||
|
let identity = (cert.as_str(), key.as_str());
|
||||||
|
let mode = punktfunk_core::Mode {
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
refresh_hz: 60,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1: wrong PIN → Crypto, nothing stored.
|
||||||
|
let err = NativeClient::pair("127.0.0.1", 19778, identity, "0000", "imposter", timeout)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, punktfunk_core::PunktfunkError::Crypto),
|
||||||
|
"{err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2: anonymous session on a pairing-required host → rejected (connect fails).
|
||||||
|
assert!(
|
||||||
|
NativeClient::connect("127.0.0.1", 19778, mode, None, None, timeout).is_err(),
|
||||||
|
"anonymous session must be rejected"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3: correct PIN → paired, host fingerprint returned.
|
||||||
|
let host_fp =
|
||||||
|
NativeClient::pair("127.0.0.1", 19778, identity, "4321", "test-client", timeout)
|
||||||
|
.expect("pairing with the right PIN");
|
||||||
|
assert!(test_paired_path().exists());
|
||||||
|
let _ = std::fs::remove_file(test_paired_path()); // already loaded; tidy /tmp
|
||||||
|
|
||||||
|
// 4: the paired identity gets a session — pinned to the ceremony's fingerprint.
|
||||||
|
let client = NativeClient::connect(
|
||||||
|
"127.0.0.1",
|
||||||
|
19778,
|
||||||
|
mode,
|
||||||
|
Some(host_fp),
|
||||||
|
Some((cert.clone(), key.clone())),
|
||||||
|
timeout,
|
||||||
|
)
|
||||||
|
.expect("paired session");
|
||||||
|
assert_eq!(client.host_fingerprint, host_fp);
|
||||||
|
drop(client);
|
||||||
|
|
||||||
|
host.join().unwrap().unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ fn real_main() -> Result<()> {
|
|||||||
max_sessions: get("--max-sessions")
|
max_sessions: get("--max-sessions")
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(0),
|
.unwrap_or(0),
|
||||||
|
require_pairing: args.iter().any(|a| a == "--require-pairing"),
|
||||||
|
pairing_pin: None,
|
||||||
|
paired_store: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Some("-h") | Some("--help") | Some("help") | None => {
|
Some("-h") | Some("--help") | Some("help") | None => {
|
||||||
@@ -317,6 +320,8 @@ M3-HOST OPTIONS:
|
|||||||
--seconds <N> per-session stream duration, virtual source (default: 30)
|
--seconds <N> per-session stream duration, virtual source (default: 30)
|
||||||
--frames <N> per-session frame count, synthetic source (default: 300)
|
--frames <N> per-session frame count, synthetic source (default: 300)
|
||||||
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
|
--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)
|
||||||
|
|
||||||
M0 OPTIONS:
|
M0 OPTIONS:
|
||||||
--source <synthetic|portal|kwin-virtual>
|
--source <synthetic|portal|kwin-virtual>
|
||||||
|
|||||||
@@ -82,6 +82,36 @@
|
|||||||
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
// `shard_payload` so `HEADER_LEN + shard_payload + CRYPTO_OVERHEAD ≤ MAX_DATAGRAM_BYTES`.
|
||||||
#define MAX_DATAGRAM_BYTES 2048
|
#define MAX_DATAGRAM_BYTES 2048
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`Reconfigure`] (first byte after the magic).
|
||||||
|
#define MSG_RECONFIGURE 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`Reconfigured`].
|
||||||
|
#define MSG_RECONFIGURED 2
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`PairRequest`].
|
||||||
|
#define MSG_PAIR_REQUEST 16
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`PairChallenge`].
|
||||||
|
#define MSG_PAIR_CHALLENGE 17
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`PairProof`].
|
||||||
|
#define MSG_PAIR_PROOF 18
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// Type byte of [`PairResult`].
|
||||||
|
#define MSG_PAIR_RESULT 19
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||||
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8),
|
// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8),
|
||||||
@@ -324,9 +354,15 @@ PunktfunkStatus punktfunk_get_stats(PunktfunkSession *s, PunktfunkStats *out);
|
|||||||
// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||||
// pass it as the pin on every later connect.
|
// 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
|
// # Safety
|
||||||
// `host` is a NUL-terminated UTF-8 string (IP or hostname resolvable by the platform);
|
// `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.
|
||||||
PunktfunkConnection *punktfunk_connect(const char *host,
|
PunktfunkConnection *punktfunk_connect(const char *host,
|
||||||
uint16_t port,
|
uint16_t port,
|
||||||
uint32_t width,
|
uint32_t width,
|
||||||
@@ -334,9 +370,47 @@ PunktfunkConnection *punktfunk_connect(const char *host,
|
|||||||
uint32_t refresh_hz,
|
uint32_t refresh_hz,
|
||||||
const uint8_t *pin_sha256,
|
const uint8_t *pin_sha256,
|
||||||
uint8_t *observed_sha256_out,
|
uint8_t *observed_sha256_out,
|
||||||
|
const char *client_cert_pem,
|
||||||
|
const char *client_key_pem,
|
||||||
uint32_t timeout_ms);
|
uint32_t timeout_ms);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// 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`.
|
||||||
|
PunktfunkStatus punktfunk_generate_identity(char *cert_pem_out,
|
||||||
|
uintptr_t cert_cap,
|
||||||
|
char *key_pem_out,
|
||||||
|
uintptr_t key_cap);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// 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.
|
||||||
|
PunktfunkStatus punktfunk_pair(const char *host,
|
||||||
|
uint16_t port,
|
||||||
|
const char *client_cert_pem,
|
||||||
|
const char *client_key_pem,
|
||||||
|
const char *pin,
|
||||||
|
const char *name,
|
||||||
|
uint8_t *host_sha256_out,
|
||||||
|
uint32_t timeout_ms);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
|
// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
|
||||||
// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended.
|
// [`PunktfunkStatus::NoFrame`] on timeout and [`PunktfunkStatus::Closed`] once the session ended.
|
||||||
@@ -391,7 +465,8 @@ PunktfunkStatus punktfunk_connection_send_input(PunktfunkConnection *c,
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// 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
|
// # Safety
|
||||||
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
|
// `c` is a valid connection handle; out pointers are writable (NULLs are skipped).
|
||||||
@@ -401,6 +476,22 @@ PunktfunkStatus punktfunk_connection_mode(const PunktfunkConnection *c,
|
|||||||
uint32_t *refresh_hz);
|
uint32_t *refresh_hz);
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
|
// 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.
|
||||||
|
PunktfunkStatus punktfunk_connection_request_mode(const PunktfunkConnection *c,
|
||||||
|
uint32_t width,
|
||||||
|
uint32_t height,
|
||||||
|
uint32_t refresh_hz);
|
||||||
|
#endif
|
||||||
|
|
||||||
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
#if defined(PUNKTFUNK_FEATURE_QUIC)
|
||||||
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
// Close the connection and free the handle (joins the internal threads). NULL is a no-op.
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user