feat: M3 — full lumen/1 session planes: audio, gamepads+rumble, pinned trust, persistent listener
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
m3-host is now a real host, not a one-shot demo. Everything validated live on this box (two back-to-back sessions, pinned + TOFU, ~200 audio pkts/s, p50 0.84 ms at 720p60). lumen-core: - quic.rs: QUIC-datagram side planes demuxed by first byte — Opus audio 0xC9 ([magic][u32 seq][u64 pts_ns][opus], host→client) and rumble 0xCA ([magic][pad][low][high]). - Trust: endpoint::server_with_identity (persistent PEM identity) and endpoint::client_pinned — SHA-256 cert-fingerprint pinning with TOFU (observed fingerprint reported back for persisting). The verifier checks the TLS 1.3 CertificateVerify signature for real (an MITM replaying the host's public cert without its key is rejected; cert pinning alone would not prove key possession). - client.rs: NativeClient gains pin + host_fingerprint, audio/rumble receivers (next_audio / next_rumble); pull methods take &self so the C ABI's per-plane threads never alias a &mut (per-plane mutexed borrow slots in abi.rs). - abi.rs: lumen_connect(pin_sha256, observed_sha256_out) + lumen_connection_next_audio / next_rumble. input.rs: documented gamepad wire contract (GameStream buttonFlags bits, XInput axis conventions, +y = up) — exported as LUMEN_BTN_*/LUMEN_AXIS_* (bare BTN_* collides with <linux/input-event-codes.h> at different values). lumen-host (m3): - Persistent accept loop: sessions back to back on one endpoint (--max-sessions, 0 = forever); per-session failures log and the loop keeps serving; 10 s handshake deadline so a silent client can't wedge the sequential accept queue; teardown on every exit path (stop flag → conn.close → join audio+input threads). - Audio plane: desktop PipeWire capture → Opus 48 kHz stereo 5 ms CBR → datagrams; ONE capturer reused across sessions via an AudioCapSlot (PipeWire streams have no cheap teardown — per-session opens would leak a thread + core connection + live node each). - Gamepad routing: incremental GamepadButton/GamepadAxis datagrams accumulate into per-pad state feeding the uinput xpad manager; force feedback returns as rumble datagrams, with current state re-sent every 500 ms (idempotent-state healing for the lossy channel). QUIC endpoint serves the persistent ~/.config/lumen identity and logs the pinnable fingerprint. lumen-client-rs: --pin (malformed values abort — never silently downgrade to TOFU), TOFU fingerprint logging, audio/rumble datagram counters, gamepad events in --input-test. clients/apple: scaffold synced — pinSHA256/hostFingerprint (wrong-size pin throws, fail-closed), nextAudio/nextRumble, gamepad event constructors; README handoff updated (persistent listener, audio decode notes, trust UX). Adversarially reviewed (5-dimension multi-agent pass over the diff, 2-skeptic verification): fixed the MITM signature-check gap, a Y-axis contract inversion, header macro collisions, ABI aliasing UB, the PipeWire per-session leak, the missing handshake deadline, fail-open pin parsing, and teardown-on-error paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
default = []
|
||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen"]
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
@@ -39,6 +39,8 @@ zeroize = "1"
|
||||
quinn = { version = "0.11", optional = true }
|
||||
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"] }
|
||||
rustls-pki-types = { version = "1", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -15,6 +15,31 @@ parse_deps = false
|
||||
[export.rename]
|
||||
"InputEvent" = "LumenInputEvent"
|
||||
"InputKind" = "LumenInputKind"
|
||||
# Gamepad wire constants: bare BTN_* names collide with <linux/input-event-codes.h> (at
|
||||
# DIFFERENT values — last definition silently wins); prefix everything we export.
|
||||
"BTN_DPAD_UP" = "LUMEN_BTN_DPAD_UP"
|
||||
"BTN_DPAD_DOWN" = "LUMEN_BTN_DPAD_DOWN"
|
||||
"BTN_DPAD_LEFT" = "LUMEN_BTN_DPAD_LEFT"
|
||||
"BTN_DPAD_RIGHT" = "LUMEN_BTN_DPAD_RIGHT"
|
||||
"BTN_START" = "LUMEN_BTN_START"
|
||||
"BTN_BACK" = "LUMEN_BTN_BACK"
|
||||
"BTN_LS_CLICK" = "LUMEN_BTN_LS_CLICK"
|
||||
"BTN_RS_CLICK" = "LUMEN_BTN_RS_CLICK"
|
||||
"BTN_LB" = "LUMEN_BTN_LB"
|
||||
"BTN_RB" = "LUMEN_BTN_RB"
|
||||
"BTN_GUIDE" = "LUMEN_BTN_GUIDE"
|
||||
"BTN_A" = "LUMEN_BTN_A"
|
||||
"BTN_B" = "LUMEN_BTN_B"
|
||||
"BTN_X" = "LUMEN_BTN_X"
|
||||
"BTN_Y" = "LUMEN_BTN_Y"
|
||||
"AXIS_LS_X" = "LUMEN_AXIS_LS_X"
|
||||
"AXIS_LS_Y" = "LUMEN_AXIS_LS_Y"
|
||||
"AXIS_RS_X" = "LUMEN_AXIS_RS_X"
|
||||
"AXIS_RS_Y" = "LUMEN_AXIS_RS_Y"
|
||||
"AXIS_LT" = "LUMEN_AXIS_LT"
|
||||
"AXIS_RT" = "LUMEN_AXIS_RT"
|
||||
"AUDIO_MAGIC" = "LUMEN_AUDIO_MAGIC"
|
||||
"RUMBLE_MAGIC" = "LUMEN_RUMBLE_MAGIC"
|
||||
|
||||
# QualifiedScreamingSnakeCase already qualifies each variant with the enum name
|
||||
# (LumenStatus::Ok -> LUMEN_STATUS_OK); do NOT also set prefix_with_name or it doubles.
|
||||
|
||||
+149
-11
@@ -450,18 +450,31 @@ pub unsafe extern "C" fn lumen_get_stats(
|
||||
|
||||
/// Opaque handle to a live `lumen/1` connection (QUIC control plane + UDP data plane, all
|
||||
/// pumped on internal threads).
|
||||
///
|
||||
/// Thread contract: each plane (video `next_au`, audio `next_audio`, rumble `next_rumble`)
|
||||
/// may be pulled from its own thread, at most one thread per plane. The accessors only
|
||||
/// take shared references internally (per-plane mutexed borrow slots), so cross-plane
|
||||
/// concurrency is sound — never two threads on the *same* plane.
|
||||
#[cfg(feature = "quic")]
|
||||
pub struct LumenConnection {
|
||||
inner: crate::client::NativeClient,
|
||||
/// Backs the pointer returned by the last `lumen_connection_next_au` (borrow-until-next-call).
|
||||
last: Option<crate::session::Frame>,
|
||||
last: std::sync::Mutex<Option<crate::session::Frame>>,
|
||||
/// Same, for `lumen_connection_next_audio` (independent of the video slot).
|
||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
||||
}
|
||||
|
||||
/// Connect to a `lumen/1` host and start a session at `width`x`height`@`refresh_hz`.
|
||||
/// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure.
|
||||
///
|
||||
/// Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's
|
||||
/// certificate — a mismatching host is rejected. NULL = trust on first use; persist the
|
||||
/// fingerprint written to `observed_sha256_out` (NULL or 32 bytes, filled on success) and
|
||||
/// pass it as the pin on every later connect.
|
||||
///
|
||||
/// # 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.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn lumen_connect(
|
||||
@@ -470,6 +483,8 @@ pub unsafe extern "C" fn lumen_connect(
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
timeout_ms: u32,
|
||||
) -> *mut LumenConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
@@ -485,16 +500,33 @@ pub unsafe extern "C" fn lumen_connect(
|
||||
height,
|
||||
refresh_hz,
|
||||
};
|
||||
let pin = if pin_sha256.is_null() {
|
||||
None
|
||||
} else {
|
||||
let mut p = [0u8; 32];
|
||||
p.copy_from_slice(unsafe { std::slice::from_raw_parts(pin_sha256, 32) });
|
||||
Some(p)
|
||||
};
|
||||
match crate::client::NativeClient::connect(
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
pin,
|
||||
std::time::Duration::from_millis(timeout_ms as u64),
|
||||
) {
|
||||
Ok(c) => Box::into_raw(Box::new(LumenConnection {
|
||||
inner: c,
|
||||
last: None,
|
||||
})),
|
||||
Ok(c) => {
|
||||
if !observed_sha256_out.is_null() {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts_mut(observed_sha256_out, 32)
|
||||
.copy_from_slice(&c.host_fingerprint);
|
||||
}
|
||||
}
|
||||
Box::into_raw(Box::new(LumenConnection {
|
||||
inner: c,
|
||||
last: std::sync::Mutex::new(None),
|
||||
last_audio: std::sync::Mutex::new(None),
|
||||
}))
|
||||
}
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
}
|
||||
}));
|
||||
@@ -503,10 +535,12 @@ pub unsafe extern "C" fn lumen_connect(
|
||||
|
||||
/// Pull the next reassembled access unit, waiting up to `timeout_ms`. Returns
|
||||
/// [`LumenStatus::NoFrame`] on timeout and [`LumenStatus::Closed`] once the session ended.
|
||||
/// On `Ok`, `*out` borrows connection memory **until the next call** on this handle.
|
||||
/// On `Ok`, `*out` borrows connection memory **until the next `next_au` call** on this
|
||||
/// handle (the audio/rumble planes do not invalidate it).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle used from a single thread; `out` is writable.
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls video —
|
||||
/// it may run concurrently with one audio-pulling and one rumble-pulling thread.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn lumen_connection_next_au(
|
||||
@@ -515,7 +549,8 @@ pub unsafe extern "C" fn lumen_connection_next_au(
|
||||
timeout_ms: u32,
|
||||
) -> LumenStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_mut() } {
|
||||
// Shared reference only: video and audio threads must never alias a `&mut`.
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return LumenStatus::NullPointer,
|
||||
};
|
||||
@@ -527,8 +562,9 @@ pub unsafe extern "C" fn lumen_connection_next_au(
|
||||
.next_frame(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(frame) => {
|
||||
c.last = Some(frame);
|
||||
let f = c.last.as_ref().unwrap();
|
||||
let mut slot = c.last.lock().unwrap();
|
||||
*slot = Some(frame);
|
||||
let f = slot.as_ref().unwrap();
|
||||
unsafe {
|
||||
*out = LumenFrame {
|
||||
data: f.data.as_ptr(),
|
||||
@@ -545,6 +581,108 @@ pub unsafe extern "C" fn lumen_connection_next_au(
|
||||
})
|
||||
}
|
||||
|
||||
/// One Opus audio packet pulled off a `lumen/1` connection (48 kHz stereo, 5 ms frames).
|
||||
/// `data` borrows connection memory until the next `lumen_connection_next_audio` call.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
pub struct LumenAudioPacket {
|
||||
pub data: *const u8,
|
||||
pub len: usize,
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
}
|
||||
|
||||
/// Pull the next Opus audio packet, waiting up to `timeout_ms`. Returns
|
||||
/// [`LumenStatus::NoFrame`] on timeout and [`LumenStatus::Closed`] once the session ended.
|
||||
/// On `Ok`, `out->data` borrows connection memory **until the next audio call** on this
|
||||
/// handle (independent of the video slot). Drain from a dedicated audio thread — packets
|
||||
/// arrive every 5 ms and the internal queue holds 320 ms.
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio —
|
||||
/// it may run concurrently with the video/rumble pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn lumen_connection_next_audio(
|
||||
c: *mut LumenConnection,
|
||||
out: *mut LumenAudioPacket,
|
||||
timeout_ms: u32,
|
||||
) -> LumenStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return LumenStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return LumenStatus::NullPointer;
|
||||
}
|
||||
match c
|
||||
.inner
|
||||
.next_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(pkt) => {
|
||||
let mut slot = c.last_audio.lock().unwrap();
|
||||
*slot = Some(pkt);
|
||||
let p = slot.as_ref().unwrap();
|
||||
unsafe {
|
||||
*out = LumenAudioPacket {
|
||||
data: p.data.as_ptr(),
|
||||
len: p.data.len(),
|
||||
seq: p.seq,
|
||||
pts_ns: p.pts_ns,
|
||||
};
|
||||
}
|
||||
LumenStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`lumen_connection_next_audio`].
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; out pointers are writable (NULLs are skipped). At
|
||||
/// most one thread pulls rumble — it may run concurrently with the video/audio pullers.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn lumen_connection_next_rumble(
|
||||
c: *mut LumenConnection,
|
||||
pad: *mut u16,
|
||||
low: *mut u16,
|
||||
high: *mut u16,
|
||||
timeout_ms: u32,
|
||||
) -> LumenStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return LumenStatus::NullPointer,
|
||||
};
|
||||
match c
|
||||
.inner
|
||||
.next_rumble(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok((p, l, h)) => {
|
||||
unsafe {
|
||||
if !pad.is_null() {
|
||||
*pad = p;
|
||||
}
|
||||
if !low.is_null() {
|
||||
*low = l;
|
||||
}
|
||||
if !high.is_null() {
|
||||
*high = h;
|
||||
}
|
||||
}
|
||||
LumenStatus::Ok
|
||||
}
|
||||
Err(e) => e.status(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
+145
-19
@@ -27,22 +27,57 @@ use std::time::Duration;
|
||||
/// (display freshness over completeness — FEC/keyframes recover).
|
||||
const FRAME_QUEUE: usize = 16;
|
||||
|
||||
/// Audio packets buffered for the embedder: 64 × 5 ms = 320 ms of slack. A lagging
|
||||
/// embedder drops the newest packet (the audio renderer conceals the gap).
|
||||
const AUDIO_QUEUE: usize = 64;
|
||||
|
||||
/// Rumble updates buffered for the embedder. Overflow drops the NEWEST update (same
|
||||
/// `try_send` discipline as the other planes) — the host re-sends rumble state
|
||||
/// periodically, so a dropped transition (including a stop) heals within ~500 ms.
|
||||
const RUMBLE_QUEUE: usize = 16;
|
||||
|
||||
/// One Opus packet from the host's audio datagram stream (48 kHz stereo, 5 ms frames).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AudioPacket {
|
||||
pub seq: u32,
|
||||
pub pts_ns: u64,
|
||||
/// The raw Opus payload — feed it to an Opus decoder as one frame.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct NativeClient {
|
||||
frames: Receiver<Frame>,
|
||||
audio: Receiver<AudioPacket>,
|
||||
rumble: Receiver<(u16, u16, u16)>,
|
||||
input_tx: tokio::sync::mpsc::UnboundedSender<InputEvent>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
worker: Option<std::thread::JoinHandle<()>>,
|
||||
/// The host-confirmed session mode (from the Welcome).
|
||||
pub mode: Mode,
|
||||
/// 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.
|
||||
pub host_fingerprint: [u8; 32],
|
||||
}
|
||||
|
||||
impl NativeClient {
|
||||
/// Connect to a `lumen/1` host and start the session at (up to) `mode`. Blocks until the
|
||||
/// handshake completes or `timeout` elapses.
|
||||
pub fn connect(host: &str, port: u16, mode: Mode, timeout: Duration) -> Result<NativeClient> {
|
||||
///
|
||||
/// `pin`: expected SHA-256 of the host's certificate. `Some` and the host presents
|
||||
/// anything else → the handshake is rejected ([`LumenError::Crypto`]). `None` = trust on
|
||||
/// first use; check [`NativeClient::host_fingerprint`] after connecting.
|
||||
pub fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
mode: Mode,
|
||||
pin: Option<[u8; 32]>,
|
||||
timeout: Duration,
|
||||
) -> Result<NativeClient> {
|
||||
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 (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 (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Mode>>();
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<(Mode, [u8; 32])>>();
|
||||
let shutdown = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let host = host.to_string();
|
||||
@@ -61,14 +96,23 @@ impl NativeClient {
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(worker_main(
|
||||
host, port, mode, frame_tx, input_rx, ready_tx, shutdown_w,
|
||||
));
|
||||
rt.block_on(worker_main(WorkerArgs {
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
pin,
|
||||
frame_tx,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
input_rx,
|
||||
ready_tx,
|
||||
shutdown: shutdown_w,
|
||||
}));
|
||||
})
|
||||
.map_err(LumenError::Io)?;
|
||||
|
||||
let negotiated = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(m)) => m,
|
||||
let (negotiated, fingerprint) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => {
|
||||
shutdown.store(true, Ordering::SeqCst);
|
||||
@@ -77,16 +121,24 @@ impl NativeClient {
|
||||
};
|
||||
Ok(NativeClient {
|
||||
frames: frame_rx,
|
||||
audio: audio_rx,
|
||||
rumble: rumble_rx,
|
||||
input_tx,
|
||||
shutdown,
|
||||
worker: Some(worker),
|
||||
mode: negotiated,
|
||||
host_fingerprint: fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next reassembled, FEC-recovered access unit; [`LumenError::NoFrame`] on
|
||||
/// timeout, [`LumenError::Closed`]-class errors once the session ended.
|
||||
pub fn next_frame(&mut self, timeout: Duration) -> Result<Frame> {
|
||||
///
|
||||
/// Plane concurrency: each pull method drains its own queue, so video, audio and
|
||||
/// rumble may each be pulled from their own thread — but at most one thread per plane
|
||||
/// (`&self` here supports the cross-plane sharing; a plane's queue is still
|
||||
/// single-consumer by contract).
|
||||
pub fn next_frame(&self, timeout: Duration) -> Result<Frame> {
|
||||
match self.frames.recv_timeout(timeout) {
|
||||
Ok(f) => Ok(f),
|
||||
Err(RecvTimeoutError::Timeout) => Err(LumenError::NoFrame),
|
||||
@@ -94,6 +146,27 @@ impl NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next Opus audio packet; [`LumenError::NoFrame`] on timeout,
|
||||
/// [`LumenError::Closed`] once the session ended. Drain on a dedicated audio thread —
|
||||
/// packets arrive every 5 ms.
|
||||
pub fn next_audio(&self, timeout: Duration) -> Result<AudioPacket> {
|
||||
match self.audio.recv_timeout(timeout) {
|
||||
Ok(p) => Ok(p),
|
||||
Err(RecvTimeoutError::Timeout) => Err(LumenError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(LumenError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the next rumble update `(pad, low, high)`; same semantics as
|
||||
/// [`NativeClient::next_audio`]. Amplitudes are 0..0xFFFF, `(0, 0)` = stop.
|
||||
pub fn next_rumble(&self, timeout: Duration) -> Result<(u16, u16, u16)> {
|
||||
match self.rumble.recv_timeout(timeout) {
|
||||
Ok(r) => Ok(r),
|
||||
Err(RecvTimeoutError::Timeout) => Err(LumenError::NoFrame),
|
||||
Err(RecvTimeoutError::Disconnected) => Err(LumenError::Closed),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue one input event for delivery as a QUIC datagram.
|
||||
pub fn send_input(&self, ev: &InputEvent) -> Result<()> {
|
||||
self.input_tx.send(*ev).map_err(|_| LumenError::Closed)
|
||||
@@ -109,27 +182,55 @@ impl Drop for NativeClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input task + the blocking data-plane pump.
|
||||
async fn worker_main(
|
||||
struct WorkerArgs {
|
||||
host: String,
|
||||
port: u16,
|
||||
mode: Mode,
|
||||
pin: Option<[u8; 32]>,
|
||||
frame_tx: SyncSender<Frame>,
|
||||
mut input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<Mode>>,
|
||||
audio_tx: SyncSender<AudioPacket>,
|
||||
rumble_tx: SyncSender<(u16, u16, u16)>,
|
||||
input_rx: tokio::sync::mpsc::UnboundedReceiver<InputEvent>,
|
||||
ready_tx: std::sync::mpsc::Sender<Result<(Mode, [u8; 32])>>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
) {
|
||||
}
|
||||
|
||||
/// The worker: QUIC handshake, then the input/datagram tasks + the blocking data-plane pump.
|
||||
async fn worker_main(args: WorkerArgs) {
|
||||
let WorkerArgs {
|
||||
host,
|
||||
port,
|
||||
mode,
|
||||
pin,
|
||||
frame_tx,
|
||||
audio_tx,
|
||||
rumble_tx,
|
||||
mut input_rx,
|
||||
ready_tx,
|
||||
shutdown,
|
||||
} = args;
|
||||
let setup = async {
|
||||
let remote: std::net::SocketAddr = format!("{host}:{port}")
|
||||
.parse()
|
||||
.map_err(|_| LumenError::InvalidArg("host:port"))?;
|
||||
let ep = endpoint::client_insecure()
|
||||
.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let (ep, observed) = endpoint::client_pinned(pin);
|
||||
let ep = ep.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
||||
let conn = ep
|
||||
.connect(remote, "lumen")
|
||||
.map_err(|_| LumenError::InvalidArg("connect"))?
|
||||
.await
|
||||
.map_err(|e| LumenError::Io(std::io::Error::other(e.to_string())))?;
|
||||
.map_err(|e| {
|
||||
// A pin mismatch surfaces as a TLS failure; report it as a crypto error so
|
||||
// the embedder can distinguish "wrong host identity" from plain IO trouble.
|
||||
let fp_mismatch = pin.is_some()
|
||||
&& observed.lock().unwrap().map(|fp| Some(fp) != pin) == Some(true);
|
||||
if fp_mismatch {
|
||||
LumenError::Crypto
|
||||
} else {
|
||||
LumenError::Io(std::io::Error::other(e.to_string()))
|
||||
}
|
||||
})?;
|
||||
let fingerprint = observed.lock().unwrap().unwrap_or([0u8; 32]);
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
@@ -163,17 +264,17 @@ async fn worker_main(
|
||||
let transport =
|
||||
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))?;
|
||||
Ok::<_, LumenError>((conn, session, welcome.mode))
|
||||
Ok::<_, LumenError>((conn, session, welcome.mode, fingerprint))
|
||||
};
|
||||
|
||||
let (conn, mut session, negotiated) = match setup.await {
|
||||
let (conn, mut session, negotiated, fingerprint) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
let _ = ready_tx.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready_tx.send(Ok(negotiated));
|
||||
let _ = ready_tx.send(Ok((negotiated, fingerprint)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
let input_conn = conn.clone();
|
||||
@@ -183,6 +284,31 @@ async fn worker_main(
|
||||
}
|
||||
});
|
||||
|
||||
// Datagram demux: host → client audio/rumble (try_send: a lagging embedder drops the
|
||||
// newest packet rather than backing up the QUIC receive path).
|
||||
let dgram_conn = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Ok(d) = dgram_conn.read_datagram().await {
|
||||
match d.first() {
|
||||
Some(&crate::quic::AUDIO_MAGIC) => {
|
||||
if let Some((seq, pts_ns, opus)) = crate::quic::decode_audio_datagram(&d) {
|
||||
let _ = audio_tx.try_send(AudioPacket {
|
||||
seq,
|
||||
pts_ns,
|
||||
data: opus.to_vec(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(&crate::quic::RUMBLE_MAGIC) => {
|
||||
if let Some(r) = crate::quic::decode_rumble_datagram(&d) {
|
||||
let _ = rumble_tx.try_send(r);
|
||||
}
|
||||
}
|
||||
_ => {} // unknown tag — a newer host; ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for connection close → stop the pump.
|
||||
{
|
||||
let shutdown = shutdown.clone();
|
||||
|
||||
@@ -23,11 +23,49 @@ pub enum InputKind {
|
||||
MouseButtonUp = 5,
|
||||
/// `x` carries the (signed) scroll delta.
|
||||
MouseScroll = 6,
|
||||
/// `code` = button bit ([`gamepad`] `BTN_*`), `x` ≠ 0 = pressed, `flags` = pad index.
|
||||
GamepadButton = 7,
|
||||
/// `code` = axis id, `x` = axis value.
|
||||
/// `code` = axis id ([`gamepad`] `AXIS_*`), `x` = axis value, `flags` = pad index.
|
||||
/// Sticks are i16 range (−32768..32767) in the XInput/Moonlight convention — **+y =
|
||||
/// up** (unlike mouse coordinates); triggers 0..255.
|
||||
GamepadAxis = 8,
|
||||
}
|
||||
|
||||
/// The gamepad wire contract for [`InputKind::GamepadButton`]/[`InputKind::GamepadAxis`].
|
||||
///
|
||||
/// Everything follows the GameStream/XInput conventions end to end: buttons reuse
|
||||
/// GameStream's `buttonFlags` bit positions, sticks are −32768..32767 with **+y = up**,
|
||||
/// triggers 0..255 (what Moonlight sends and what the host's virtual xpad already
|
||||
/// consumes). One event carries one transition: `code` = the bit below, `x` = 1 pressed /
|
||||
/// 0 released. Axes are sent individually; the host accumulates per-pad state and emits
|
||||
/// one evdev SYN per event.
|
||||
pub mod gamepad {
|
||||
pub const BTN_DPAD_UP: u32 = 0x0001;
|
||||
pub const BTN_DPAD_DOWN: u32 = 0x0002;
|
||||
pub const BTN_DPAD_LEFT: u32 = 0x0004;
|
||||
pub const BTN_DPAD_RIGHT: u32 = 0x0008;
|
||||
pub const BTN_START: u32 = 0x0010;
|
||||
pub const BTN_BACK: u32 = 0x0020;
|
||||
pub const BTN_LS_CLICK: u32 = 0x0040;
|
||||
pub const BTN_RS_CLICK: u32 = 0x0080;
|
||||
pub const BTN_LB: u32 = 0x0100;
|
||||
pub const BTN_RB: u32 = 0x0200;
|
||||
pub const BTN_GUIDE: u32 = 0x0400;
|
||||
pub const BTN_A: u32 = 0x1000;
|
||||
pub const BTN_B: u32 = 0x2000;
|
||||
pub const BTN_X: u32 = 0x4000;
|
||||
pub const BTN_Y: u32 = 0x8000;
|
||||
|
||||
/// Axis ids for `InputKind::GamepadAxis`.
|
||||
pub const AXIS_LS_X: u32 = 0;
|
||||
pub const AXIS_LS_Y: u32 = 1;
|
||||
pub const AXIS_RS_X: u32 = 2;
|
||||
pub const AXIS_RS_Y: u32 = 3;
|
||||
/// Triggers: value range 0..255.
|
||||
pub const AXIS_LT: u32 = 4;
|
||||
pub const AXIS_RT: u32 = 5;
|
||||
}
|
||||
|
||||
impl InputKind {
|
||||
pub fn from_u8(v: u8) -> Option<InputKind> {
|
||||
use InputKind::*;
|
||||
|
||||
+207
-32
@@ -16,9 +16,11 @@
|
||||
//! Leopard, which GameStream can't express), shard sizing, crypto key/salt — so the data
|
||||
//! plane is exactly the hardened M1 `Session`.
|
||||
//!
|
||||
//! Seed-stage transport security: the host presents a self-signed certificate and the client
|
||||
//! accepts any (pairing/pinning lands with the trust model; the data plane's AES-GCM is
|
||||
//! already real). All integers little-endian; every message is `u16 length || payload`.
|
||||
//! Transport security: the host presents a long-lived self-signed certificate
|
||||
//! ([`endpoint::server_with_identity`]) and the client pins its SHA-256 fingerprint
|
||||
//! ([`endpoint::client_pinned`]; no pin = trust-on-first-use, with the observed fingerprint
|
||||
//! reported back for persisting). The data plane adds AES-GCM on top.
|
||||
//! All integers little-endian; every message is `u16 length || payload`.
|
||||
|
||||
use crate::config::{Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||
use crate::error::{LumenError, Result};
|
||||
@@ -183,6 +185,53 @@ pub fn frame(payload: &[u8]) -> Vec<u8> {
|
||||
b
|
||||
}
|
||||
|
||||
/// Datagram wire tags. Video rides UDP; everything low-rate rides QUIC datagrams,
|
||||
/// demultiplexed by the first byte: input = [`crate::input::INPUT_MAGIC`] (0xC8),
|
||||
/// audio = [`AUDIO_MAGIC`], rumble = [`RUMBLE_MAGIC`].
|
||||
pub const AUDIO_MAGIC: u8 = 0xC9;
|
||||
pub const RUMBLE_MAGIC: u8 = 0xCA;
|
||||
|
||||
/// Audio datagram, host → client: `[0xC9][u32 seq LE][u64 pts_ns LE][opus payload]`.
|
||||
/// One Opus frame per datagram (5 ms — well under any MTU); QUIC already encrypts.
|
||||
pub fn encode_audio_datagram(seq: u32, pts_ns: u64, opus: &[u8]) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(13 + opus.len());
|
||||
b.push(AUDIO_MAGIC);
|
||||
b.extend_from_slice(&seq.to_le_bytes());
|
||||
b.extend_from_slice(&pts_ns.to_le_bytes());
|
||||
b.extend_from_slice(opus);
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse an audio datagram → `(seq, pts_ns, opus payload)`. `None` on bad tag/length.
|
||||
pub fn decode_audio_datagram(b: &[u8]) -> Option<(u32, u64, &[u8])> {
|
||||
if b.len() < 13 || b[0] != AUDIO_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let seq = u32::from_le_bytes(b[1..5].try_into().unwrap());
|
||||
let pts_ns = u64::from_le_bytes(b[5..13].try_into().unwrap());
|
||||
Some((seq, pts_ns, &b[13..]))
|
||||
}
|
||||
|
||||
/// Rumble datagram, host → client: `[0xCA][u16 pad LE][u16 low LE][u16 high LE]`.
|
||||
/// Force-feedback state for pad `pad` (0xFFFF amplitudes, 0/0 = stop).
|
||||
pub fn encode_rumble_datagram(pad: u16, low: u16, high: u16) -> [u8; 7] {
|
||||
let mut b = [0u8; 7];
|
||||
b[0] = RUMBLE_MAGIC;
|
||||
b[1..3].copy_from_slice(&pad.to_le_bytes());
|
||||
b[3..5].copy_from_slice(&low.to_le_bytes());
|
||||
b[5..7].copy_from_slice(&high.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// Parse a rumble datagram → `(pad, low, high)`. `None` on bad tag/length.
|
||||
pub fn decode_rumble_datagram(b: &[u8]) -> Option<(u16, u16, u16)> {
|
||||
if b.len() < 7 || b[0] != RUMBLE_MAGIC {
|
||||
return None;
|
||||
}
|
||||
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
|
||||
Some((u16at(1), u16at(3), u16at(5)))
|
||||
}
|
||||
|
||||
/// Async framed-message IO over a quinn stream (`u16 LE length || payload`).
|
||||
pub mod io {
|
||||
/// Read one framed message (bounded at 64 KiB — control messages are tiny).
|
||||
@@ -207,35 +256,99 @@ pub mod io {
|
||||
}
|
||||
}
|
||||
|
||||
/// quinn endpoint constructors (host self-signed; client accepts-any — seed-stage trust).
|
||||
/// quinn endpoint constructors. Host: self-signed identity (fresh, or persisted PEMs via
|
||||
/// [`endpoint::server_with_identity`]). Client: fingerprint pinning / TOFU via
|
||||
/// [`endpoint::client_pinned`] ([`endpoint::client_insecure`] is the no-pin special case).
|
||||
pub mod endpoint {
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// Server endpoint with a fresh self-signed certificate.
|
||||
/// Server endpoint with a fresh self-signed certificate (tests/dev — production hosts
|
||||
/// persist an identity and use [`server_with_identity`] so clients can pin it).
|
||||
pub fn server(addr: std::net::SocketAddr) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
let cert = rcgen::generate_simple_self_signed(vec!["lumen".into()])
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
|
||||
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
|
||||
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
|
||||
let server_config =
|
||||
quinn::ServerConfig::with_single_cert(vec![cert_der], key_der.into())
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||
server_from_der(cert_der, key_der.into(), addr)
|
||||
}
|
||||
|
||||
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
|
||||
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
|
||||
/// across restarts.
|
||||
pub fn server_with_identity(
|
||||
addr: std::net::SocketAddr,
|
||||
cert_pem: &str,
|
||||
key_pem: &str,
|
||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
|
||||
server_from_der(cert_der, key_der, addr)
|
||||
}
|
||||
|
||||
fn server_from_der(
|
||||
cert_der: rustls::pki_types::CertificateDer<'static>,
|
||||
key_der: rustls::pki_types::PrivateKeyDer<'static>,
|
||||
addr: std::net::SocketAddr,
|
||||
) -> anyhow_result::Result<quinn::Endpoint> {
|
||||
let server_config = quinn::ServerConfig::with_single_cert(vec![cert_der], key_der)
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("server config: {e}")))?;
|
||||
Ok(quinn::Endpoint::server(server_config, addr)?)
|
||||
}
|
||||
|
||||
/// Client endpoint that skips certificate verification (seed stage; pinning lands with
|
||||
/// the pairing/trust model).
|
||||
/// SHA-256 of a certificate's DER encoding — the fingerprint clients pin.
|
||||
pub fn cert_fingerprint(cert_der: &[u8]) -> [u8; 32] {
|
||||
use sha2::Digest;
|
||||
sha2::Sha256::digest(cert_der).into()
|
||||
}
|
||||
|
||||
/// Fingerprint of a PEM-encoded certificate (what a host logs/shows for pairing UX —
|
||||
/// must match what the client's verifier computes from the DER on the wire).
|
||||
pub fn fingerprint_of_pem(cert_pem: &str) -> anyhow_result::Result<[u8; 32]> {
|
||||
use rustls::pki_types::pem::PemObject;
|
||||
let der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
|
||||
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
|
||||
Ok(cert_fingerprint(der.as_ref()))
|
||||
}
|
||||
|
||||
/// Client endpoint that skips certificate verification (TOFU bootstrap — read the
|
||||
/// observed fingerprint off the slot and pin it on the next connect).
|
||||
pub fn client_insecure() -> anyhow_result::Result<quinn::Endpoint> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let rustls_cfg = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(SkipVerify))
|
||||
.with_no_client_auth();
|
||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||
.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())?;
|
||||
ep.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_cfg)));
|
||||
Ok(ep)
|
||||
client_pinned(None).0
|
||||
}
|
||||
|
||||
/// What [`client_pinned`] returns: the endpoint plus the slot the verifier writes the
|
||||
/// observed host fingerprint into during the handshake.
|
||||
pub type PinnedClient = (
|
||||
anyhow_result::Result<quinn::Endpoint>,
|
||||
Arc<Mutex<Option<[u8; 32]>>>,
|
||||
);
|
||||
|
||||
/// Client endpoint that verifies the host by certificate fingerprint.
|
||||
///
|
||||
/// `pin = Some(sha256)` rejects any host whose leaf cert doesn't hash to `sha256`;
|
||||
/// `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.
|
||||
pub fn client_pinned(pin: Option<[u8; 32]>) -> PinnedClient {
|
||||
let observed = Arc::new(Mutex::new(None));
|
||||
let ep = (|| {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let rustls_cfg = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(PinVerify {
|
||||
pin,
|
||||
observed: observed.clone(),
|
||||
}))
|
||||
.with_no_client_auth();
|
||||
let quic_cfg = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_cfg)
|
||||
.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())?;
|
||||
ep.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic_cfg)));
|
||||
Ok(ep)
|
||||
})();
|
||||
(ep, observed)
|
||||
}
|
||||
|
||||
/// Minimal error plumbing without pulling anyhow into lumen-core's public API.
|
||||
@@ -261,40 +374,69 @@ pub mod endpoint {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// it saw, so the embedder can persist the fingerprint and pin it from then on.
|
||||
#[derive(Debug)]
|
||||
struct SkipVerify;
|
||||
struct PinVerify {
|
||||
pin: Option<[u8; 32]>,
|
||||
observed: Arc<Mutex<Option<[u8; 32]>>>,
|
||||
}
|
||||
|
||||
impl rustls::client::danger::ServerCertVerifier for SkipVerify {
|
||||
impl rustls::client::danger::ServerCertVerifier for PinVerify {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
_end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
end_entity: &rustls::pki_types::CertificateDer<'_>,
|
||||
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
|
||||
_server_name: &rustls::pki_types::ServerName<'_>,
|
||||
_ocsp: &[u8],
|
||||
_now: rustls::pki_types::UnixTime,
|
||||
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error>
|
||||
{
|
||||
let fp = cert_fingerprint(end_entity.as_ref());
|
||||
*self.observed.lock().unwrap() = Some(fp);
|
||||
if let Some(expected) = self.pin {
|
||||
if fp != expected {
|
||||
return Err(rustls::Error::InvalidCertificate(
|
||||
rustls::CertificateError::ApplicationVerificationFailure,
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||
}
|
||||
|
||||
// The handshake signatures MUST be verified for real even though we pin the cert:
|
||||
// CertificateVerify is what proves the peer *holds the pinned cert's private key* —
|
||||
// skip it and an active MITM can replay the host's (public) certificate, match the
|
||||
// pin, and complete the handshake with its own key.
|
||||
fn verify_tls12_signature(
|
||||
&self,
|
||||
_message: &[u8],
|
||||
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
_dss: &rustls::DigitallySignedStruct,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||
{
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
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,
|
||||
message: &[u8],
|
||||
cert: &rustls::pki_types::CertificateDer<'_>,
|
||||
dss: &rustls::DigitallySignedStruct,
|
||||
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error>
|
||||
{
|
||||
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||
rustls::crypto::verify_tls13_signature(
|
||||
message,
|
||||
cert,
|
||||
dss,
|
||||
&rustls::crypto::ring::default_provider().signature_verification_algorithms,
|
||||
)
|
||||
}
|
||||
|
||||
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||
@@ -349,4 +491,37 @@ mod tests {
|
||||
};
|
||||
assert_eq!(Start::decode(&s.encode()).unwrap(), s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_datagram_roundtrip() {
|
||||
let opus = [0x42u8; 97];
|
||||
let d = encode_audio_datagram(7, 1_000_000_123, &opus);
|
||||
assert_eq!(d[0], AUDIO_MAGIC);
|
||||
let (seq, pts, payload) = decode_audio_datagram(&d).unwrap();
|
||||
assert_eq!((seq, pts), (7, 1_000_000_123));
|
||||
assert_eq!(payload, opus);
|
||||
assert!(decode_audio_datagram(&d[..12]).is_none()); // truncated header
|
||||
assert!(decode_audio_datagram(&[0u8; 13]).is_none()); // bad magic
|
||||
|
||||
// Empty payload is legal (DTX) — header-only datagram.
|
||||
let header_only = encode_audio_datagram(0, 0, &[]);
|
||||
let (_, _, empty) = decode_audio_datagram(&header_only).unwrap();
|
||||
assert!(empty.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rumble_datagram_roundtrip() {
|
||||
let d = encode_rumble_datagram(1, 0x1234, 0xFFFF);
|
||||
assert_eq!(d[0], RUMBLE_MAGIC);
|
||||
assert_eq!(decode_rumble_datagram(&d), Some((1, 0x1234, 0xFFFF)));
|
||||
assert!(decode_rumble_datagram(&d[..6]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_sha256_of_der() {
|
||||
// Stable across calls, distinct for distinct certs.
|
||||
let a = endpoint::cert_fingerprint(b"cert-a");
|
||||
assert_eq!(a, endpoint::cert_fingerprint(b"cert-a"));
|
||||
assert_ne!(a, endpoint::cert_fingerprint(b"cert-b"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user