716875dd09
Break the 1302-line quic/msgs.rs into four flat sibling modules behind the quic facade's glob re-exports, so every crate::quic::X path stays byte-stable: handshake.rs (Hello/Welcome/Start + codecs), caps.rs (video-cap bits, codec & chroma negotiation, ColorInfo), control.rs (typed CTL_MAGIC messages + frame), pairing.rs (SPAKE2 ceremony messages). msgs.rs is deleted; quic/mod.rs gains the four `mod`/`pub use` lines and the `pub use crate::reject::*` hoist (moved up from msgs.rs). Pure move; no wire-format or behavior change. Private helpers (truncate_to, put_bytes, get_bytes) stay with their sole callers; no visibility changes. Verified both platforms from clean HEAD snapshots: Linux clippy (quic + no-default, -D warnings) + full cargo test (157 lib + integration); Windows clippy (both) + test --lib (156). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
174 lines
6.2 KiB
Rust
174 lines
6.2 KiB
Rust
//! The pairing-ceremony control messages (SPAKE2): PairRequest/Challenge/Proof/Result.
|
|
|
|
use super::*;
|
|
use crate::error::{PunktfunkError, Result};
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Pairing ceremony (typed control messages): instead of a session Hello, a client may open
|
|
// the control stream with PairRequest. The host shows a short PIN out-of-band (log/UI); the
|
|
// user types it into the client.
|
|
//
|
|
// Trust is established by **SPAKE2** (a balanced PAKE), NOT a hash of the PIN. SPAKE2 turns
|
|
// the low-entropy PIN into a high-entropy shared key via a Diffie-Hellman exchange; the only
|
|
// thing an active man-in-the-middle who terminates the (TOFU) ceremony learns is whether a
|
|
// single PIN guess was right — there is no transcript value that reveals the PIN to an
|
|
// *offline* dictionary search (the fatal flaw of an HMAC-of-PIN proof over a 4-digit space).
|
|
// Both peers' certificate fingerprints are bound in as the SPAKE2 identities, so the
|
|
// established key — and the key-confirmation MACs derived from it — only agree when both
|
|
// sides saw the same two certificates. After mutual key confirmation the host persists the
|
|
// client's fingerprint and the client pins the host's.
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// Type byte of [`PairRequest`].
|
|
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 (≤64 bytes
|
|
/// UTF-8); `spake_a` is the client's SPAKE2 message (see [`SpakeRole::start`]).
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PairRequest {
|
|
pub name: String,
|
|
pub spake_a: Vec<u8>,
|
|
}
|
|
|
|
/// `host → client`: the host's SPAKE2 message + its key-confirmation MAC. The client
|
|
/// finishes SPAKE2, verifies `confirm` (proving the host derived the same key, i.e. knows
|
|
/// the PIN and saw the same certs), then sends its own confirmation.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct PairChallenge {
|
|
pub spake_b: Vec<u8>,
|
|
pub confirm: [u8; 32],
|
|
}
|
|
|
|
/// `client → host`: the client's key-confirmation MAC (its single proof attempt).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct PairProof {
|
|
pub confirm: [u8; 32],
|
|
}
|
|
|
|
/// `host → client`: ceremony outcome.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct PairResult {
|
|
pub ok: bool,
|
|
}
|
|
|
|
/// A length-prefixed (u16 LE) byte field within a control message.
|
|
fn put_bytes(b: &mut Vec<u8>, x: &[u8]) {
|
|
b.extend_from_slice(&(x.len() as u16).to_le_bytes());
|
|
b.extend_from_slice(x);
|
|
}
|
|
|
|
/// Read a length-prefixed field at `off`, returning the bytes and the next offset.
|
|
fn get_bytes(b: &[u8], off: usize) -> Result<(&[u8], usize)> {
|
|
if off + 2 > b.len() {
|
|
return Err(PunktfunkError::InvalidArg("truncated field"));
|
|
}
|
|
let n = u16::from_le_bytes([b[off], b[off + 1]]) as usize;
|
|
let start = off + 2;
|
|
if start + n > b.len() {
|
|
return Err(PunktfunkError::InvalidArg("field overruns message"));
|
|
}
|
|
Ok((&b[start..start + n], start + n))
|
|
}
|
|
|
|
impl PairRequest {
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let name = self.name.as_bytes();
|
|
let n = name.len().min(64);
|
|
let mut b = Vec::with_capacity(8 + n + self.spake_a.len());
|
|
b.extend_from_slice(CTL_MAGIC);
|
|
b.push(MSG_PAIR_REQUEST);
|
|
b.push(n as u8);
|
|
b.extend_from_slice(&name[..n]);
|
|
put_bytes(&mut b, &self.spake_a);
|
|
b
|
|
}
|
|
|
|
pub fn decode(b: &[u8]) -> Result<PairRequest> {
|
|
if b.len() < 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_REQUEST {
|
|
return Err(PunktfunkError::InvalidArg("bad PairRequest"));
|
|
}
|
|
let n = b[5] as usize;
|
|
if n > 64 || b.len() < 6 + n {
|
|
return Err(PunktfunkError::InvalidArg("bad PairRequest name"));
|
|
}
|
|
let name = String::from_utf8_lossy(&b[6..6 + n]).into_owned();
|
|
let (spake_a, end) = get_bytes(b, 6 + n)?;
|
|
if end != b.len() {
|
|
return Err(PunktfunkError::InvalidArg("trailing bytes"));
|
|
}
|
|
Ok(PairRequest {
|
|
name,
|
|
spake_a: spake_a.to_vec(),
|
|
})
|
|
}
|
|
}
|
|
|
|
impl PairChallenge {
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let mut b = Vec::with_capacity(7 + self.spake_b.len() + 32);
|
|
b.extend_from_slice(CTL_MAGIC);
|
|
b.push(MSG_PAIR_CHALLENGE);
|
|
put_bytes(&mut b, &self.spake_b);
|
|
b.extend_from_slice(&self.confirm);
|
|
b
|
|
}
|
|
|
|
pub fn decode(b: &[u8]) -> Result<PairChallenge> {
|
|
if b.len() < 5 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_CHALLENGE {
|
|
return Err(PunktfunkError::InvalidArg("bad PairChallenge"));
|
|
}
|
|
let (spake_b, end) = get_bytes(b, 5)?;
|
|
if end + 32 != b.len() {
|
|
return Err(PunktfunkError::InvalidArg("bad PairChallenge confirm"));
|
|
}
|
|
let mut confirm = [0u8; 32];
|
|
confirm.copy_from_slice(&b[end..end + 32]);
|
|
Ok(PairChallenge {
|
|
spake_b: spake_b.to_vec(),
|
|
confirm,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl PairProof {
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let mut b = Vec::with_capacity(37);
|
|
b.extend_from_slice(CTL_MAGIC);
|
|
b.push(MSG_PAIR_PROOF);
|
|
b.extend_from_slice(&self.confirm);
|
|
b
|
|
}
|
|
|
|
pub fn decode(b: &[u8]) -> Result<PairProof> {
|
|
if b.len() != 37 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_PROOF {
|
|
return Err(PunktfunkError::InvalidArg("bad PairProof"));
|
|
}
|
|
let mut confirm = [0u8; 32];
|
|
confirm.copy_from_slice(&b[5..37]);
|
|
Ok(PairProof { confirm })
|
|
}
|
|
}
|
|
|
|
impl PairResult {
|
|
pub fn encode(&self) -> Vec<u8> {
|
|
let mut b = Vec::with_capacity(6);
|
|
b.extend_from_slice(CTL_MAGIC);
|
|
b.push(MSG_PAIR_RESULT);
|
|
b.push(self.ok as u8);
|
|
b
|
|
}
|
|
|
|
pub fn decode(b: &[u8]) -> Result<PairResult> {
|
|
if b.len() != 6 || &b[0..4] != CTL_MAGIC || b[4] != MSG_PAIR_RESULT {
|
|
return Err(PunktfunkError::InvalidArg("bad PairResult"));
|
|
}
|
|
Ok(PairResult { ok: b[5] != 0 })
|
|
}
|
|
}
|