fix(punktfunk/1): adversarial-review fixes — SPAKE2 pairing, renegotiation hardening, +more
ci / rust (push) Has been cancelled
ci / rust (push) Has been cancelled
Triaged the multi-agent review of the renegotiation + pairing + Sway + AV1/surround batch
(1 critical, 11 major/minor confirmed). Fixes:
CRITICAL — PIN pairing was offline-brute-forceable. The HMAC-of-PIN proof let an active
MITM who terminates the TOFU ceremony recover the 4-digit PIN by offline dictionary search
(all other inputs observable) and forge a correctly-bound proof. Replaced with **SPAKE2**
(balanced PAKE, `spake2` crate) + key-confirmation MACs, binding both cert fingerprints as
the SPAKE2 identities: an attacker gets exactly ONE online guess, no offline search, and
mismatched cert views (a real MITM) never reach a shared key. Also reworked the UX to an
"arming PIN" — one PIN per arming window shown at host startup (the SPAKE2 client needs the
PIN to build its first message, so it can't be minted per-connection). Validated live:
wrong PIN rejected in 0.1s, right PIN pairs + persists + the paired identity streams.
Pairing hardening: `--allow-pairing`/`--require-pairing` must arm pairing (default rejects
unsolicited ceremonies); per-host cooldown bounds online guessing; the client flushes its
CONNECTION_CLOSE so a refused ceremony can't wedge the sequential host for the full timeout;
atomic (temp+rename) paired-store writes.
Protocol: control/pairing messages use a distinct CTL_MAGIC (PKFc) — fully disjoint from
the positional Hello namespace (a future abi_version can't be misparsed as a control
message); all typed decodes are length-exact. ABI_VERSION → 2 (punktfunk_connect signature
gained the identity params; header regenerated).
Renegotiation: drain the reconfig channel to the NEWEST mode (one rebuild, not one per
stale step); validate refresh_hz; build the new pipeline BEFORE dropping the old so a
rebuild failure keeps the session on its current mode instead of killing it.
GameStream: packetDuration snaps to {5,10} (an in-between value isn't a legal Opus frame
size and would kill audio). Sway: chooser file moved to $XDG_RUNTIME_DIR (was a fixed
world-writable /tmp path — DoS / capture-misdirection by another local user).
Swift: fixed two compile breakers in the new pairing/identity APIs (Int32 status .rawValue,
UInt cap cast). New SPAKE2 + namespace-disjointness + pairing-roundtrip unit tests; the
in-process pairing test now also exercises the arming PIN + cooldown. 114 tests green,
clippy -D warnings clean (both feature sets), fmt, C-ABI harness.
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", "dep:rustls-pki-types", "dep:sha2", "dep:hmac"]
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
@@ -42,6 +42,7 @@ rcgen = { version = "0.13", optional = true, default-features = false, features
|
||||
rustls-pki-types = { version = "1", optional = true }
|
||||
sha2 = { version = "0.10", optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
spake2 = { version = "0.4", optional = true }
|
||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -162,7 +162,7 @@ impl NativeClient {
|
||||
name: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<[u8; 32]> {
|
||||
use crate::quic::{PairChallenge, PairRequest, PairResult};
|
||||
use crate::quic::{pake, PairChallenge, PairProof, PairRequest, PairResult};
|
||||
|
||||
let client_fp = endpoint::fingerprint_of_pem(identity.0)
|
||||
.map_err(|_| PunktfunkError::InvalidArg("client cert pem"))?;
|
||||
@@ -180,6 +180,40 @@ impl NativeClient {
|
||||
// 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())))?;
|
||||
|
||||
// The SPAKE2 exchange over an already-open bi-stream; never closes the conn (the
|
||||
// caller does, then flushes), so any early exit still lets the host see the close.
|
||||
let exchange = |conn: quinn::Connection, host_fp: [u8; 32]| async move {
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|e| PunktfunkError::Io(std::io::Error::other(e.to_string())))?;
|
||||
// SPAKE2 as A, binding our fingerprint + the host cert we observed (TOFU).
|
||||
let (pake, spake_a) = pake::start(true, &pin, &client_fp, &host_fp);
|
||||
io::write_msg(&mut send, &PairRequest { name, spake_a }.encode()).await?;
|
||||
let challenge = PairChallenge::decode(&io::read_msg(&mut recv).await?)?;
|
||||
let confirms = pake.finish(&challenge.spake_b)?;
|
||||
// The host's confirmation proves it reached the same key (right PIN, same
|
||||
// certs) — only then do we pin it and send our own confirmation.
|
||||
if !pake::verify(&confirms.host, &challenge.confirm) {
|
||||
return Err(PunktfunkError::Crypto); // wrong PIN or MITM
|
||||
}
|
||||
io::write_msg(
|
||||
&mut send,
|
||||
&PairProof {
|
||||
confirm: confirms.client,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
.await?;
|
||||
let result = PairResult::decode(&io::read_msg(&mut recv).await?)?;
|
||||
if result.ok {
|
||||
Ok(host_fp)
|
||||
} else {
|
||||
Err(PunktfunkError::Crypto) // host rejected post-confirm
|
||||
}
|
||||
};
|
||||
|
||||
let ceremony = async {
|
||||
let conn = ep
|
||||
.connect(remote, "punktfunk")
|
||||
@@ -187,26 +221,20 @@ impl NativeClient {
|
||||
.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)
|
||||
}
|
||||
let outcome = exchange(conn.clone(), host_fp).await;
|
||||
// Always tell the host we're done so it never blocks at its read — code 0 on
|
||||
// success, 1 on a refused/aborted ceremony.
|
||||
let code: u32 = if outcome.is_ok() { 0 } else { 1 };
|
||||
conn.close(code.into(), b"pair done");
|
||||
outcome
|
||||
};
|
||||
tokio::time::timeout(timeout, ceremony)
|
||||
let outcome = tokio::time::timeout(timeout, ceremony)
|
||||
.await
|
||||
.map_err(|_| PunktfunkError::Timeout)?
|
||||
.map_err(|_| PunktfunkError::Timeout)?;
|
||||
// Flush the CONNECTION_CLOSE before the runtime is dropped — otherwise the host
|
||||
// may never see it and would block at its read for the full pairing timeout.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(2), ep.wait_idle()).await;
|
||||
outcome
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -46,4 +46,7 @@ pub use stats::Stats;
|
||||
|
||||
/// Bump on any breaking change to the [C ABI](crate::abi). Mirrors
|
||||
/// `punktfunk_abi_version()` and is checked by clients before use.
|
||||
pub const ABI_VERSION: u32 = 1;
|
||||
///
|
||||
/// v2: `punktfunk_connect` gained `client_cert_pem`/`client_key_pem` (pairing identities);
|
||||
/// added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`.
|
||||
pub const ABI_VERSION: u32 = 2;
|
||||
|
||||
@@ -25,9 +25,15 @@
|
||||
use crate::config::{Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role};
|
||||
use crate::error::{PunktfunkError, Result};
|
||||
|
||||
/// Protocol magic + version, first bytes of every message payload.
|
||||
/// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start).
|
||||
pub const MAGIC: &[u8; 4] = b"PKF1";
|
||||
|
||||
/// Magic for typed post-handshake / pairing control messages. A distinct magic keeps the
|
||||
/// typed namespace disjoint from the positional handshake: a `Hello` (whose abi_version
|
||||
/// byte sits where a type byte would) can never be misparsed as a control message, and
|
||||
/// vice-versa, regardless of field values.
|
||||
pub const CTL_MAGIC: &[u8; 4] = b"PKFc";
|
||||
|
||||
/// `client → host`: open the session, requesting a display mode (the host creates its
|
||||
/// virtual output at exactly this size/refresh — native resolution end to end).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -86,12 +92,19 @@ pub const MSG_RECONFIGURE: u8 = 0x01;
|
||||
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.
|
||||
// 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`].
|
||||
@@ -103,24 +116,27 @@ 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.
|
||||
/// `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`: 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)]
|
||||
/// `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 salt: [u8; 16],
|
||||
pub spake_b: Vec<u8>,
|
||||
pub confirm: [u8; 32],
|
||||
}
|
||||
|
||||
/// `client → host`: the proof, see [`pair_proof`].
|
||||
/// `client → host`: the client's key-confirmation MAC (its single proof attempt).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct PairProof {
|
||||
pub hmac: [u8; 32],
|
||||
pub confirm: [u8; 32],
|
||||
}
|
||||
|
||||
/// `host → client`: ceremony outcome.
|
||||
@@ -129,106 +145,207 @@ 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(6 + n);
|
||||
b.extend_from_slice(MAGIC);
|
||||
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] != MAGIC || b[4] != MSG_PAIR_REQUEST {
|
||||
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: String::from_utf8_lossy(&b[6..6 + n]).into_owned(),
|
||||
name,
|
||||
spake_a: spake_a.to_vec(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PairChallenge {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut b = Vec::with_capacity(21);
|
||||
b.extend_from_slice(MAGIC);
|
||||
let mut b = Vec::with_capacity(7 + self.spake_b.len() + 32);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_PAIR_CHALLENGE);
|
||||
b.extend_from_slice(&self.salt);
|
||||
put_bytes(&mut b, &self.spake_b);
|
||||
b.extend_from_slice(&self.confirm);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<PairChallenge> {
|
||||
if b.len() < 21 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_CHALLENGE {
|
||||
if b.len() < 5 || &b[0..4] != CTL_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 })
|
||||
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(MAGIC);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_PAIR_PROOF);
|
||||
b.extend_from_slice(&self.hmac);
|
||||
b.extend_from_slice(&self.confirm);
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<PairProof> {
|
||||
if b.len() < 37 || &b[0..4] != MAGIC || b[4] != MSG_PAIR_PROOF {
|
||||
if b.len() != 37 || &b[0..4] != CTL_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 })
|
||||
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(MAGIC);
|
||||
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] != MAGIC || b[4] != MSG_PAIR_RESULT {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] {
|
||||
/// SPAKE2 over Ed25519 for the pairing ceremony. The two roles use the asymmetric flow so
|
||||
/// the identities are ordered; each side binds **both** certificate fingerprints as the
|
||||
/// SPAKE2 identities, so the derived key only matches when client and host agree on the PIN
|
||||
/// *and* saw the same two certificates (a MITM, presenting different certs to each leg,
|
||||
/// cannot reach a shared key).
|
||||
pub mod pake {
|
||||
use super::*;
|
||||
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()
|
||||
use spake2::{Ed25519Group, Identity, Password, Spake2};
|
||||
|
||||
/// In-progress SPAKE2 state plus the identity transcript for key confirmation.
|
||||
pub struct PairingPake {
|
||||
state: Spake2<Ed25519Group>,
|
||||
transcript: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Start the exchange. `client_fp`/`host_fp` are the two certificate fingerprints (the
|
||||
/// client passes what it observed via TOFU; the host passes its own + the client's
|
||||
/// presented cert). Returns the state and this side's outbound SPAKE2 message.
|
||||
pub fn start(
|
||||
is_client: bool,
|
||||
pin: &str,
|
||||
client_fp: &[u8; 32],
|
||||
host_fp: &[u8; 32],
|
||||
) -> (PairingPake, Vec<u8>) {
|
||||
let pw = Password::new(pin.as_bytes());
|
||||
let id_client = Identity::new(client_fp);
|
||||
let id_host = Identity::new(host_fp);
|
||||
let (state, msg) = if is_client {
|
||||
Spake2::<Ed25519Group>::start_a(&pw, &id_client, &id_host)
|
||||
} else {
|
||||
Spake2::<Ed25519Group>::start_b(&pw, &id_client, &id_host)
|
||||
};
|
||||
let mut transcript = Vec::with_capacity(64);
|
||||
transcript.extend_from_slice(client_fp);
|
||||
transcript.extend_from_slice(host_fp);
|
||||
(PairingPake { state, transcript }, msg)
|
||||
}
|
||||
|
||||
/// Key confirmation MAC for one direction (`label` distinguishes host vs client), keyed
|
||||
/// by the SPAKE2 shared key and bound to the fingerprint transcript.
|
||||
fn confirm(key: &[u8], label: &[u8], transcript: &[u8]) -> [u8; 32] {
|
||||
let mut mac =
|
||||
<Hmac<sha2::Sha256> as Mac>::new_from_slice(key).expect("hmac takes any key length");
|
||||
mac.update(label);
|
||||
mac.update(transcript);
|
||||
mac.finalize().into_bytes().into()
|
||||
}
|
||||
|
||||
/// `Hmac` verification is constant-time via `ct_eq` in the underlying crate; we compare
|
||||
/// our recomputed tag the same way.
|
||||
fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
|
||||
a.iter()
|
||||
.zip(b.iter())
|
||||
.fold(0u8, |acc, (x, y)| acc | (x ^ y))
|
||||
== 0
|
||||
}
|
||||
|
||||
/// Confirmation tags both sides expect, given the agreed SPAKE2 key.
|
||||
pub struct Confirmations {
|
||||
/// MAC the host sends (client verifies).
|
||||
pub host: [u8; 32],
|
||||
/// MAC the client sends (host verifies).
|
||||
pub client: [u8; 32],
|
||||
}
|
||||
|
||||
impl PairingPake {
|
||||
/// Finish SPAKE2 with the peer's message → the pair of confirmation tags. `Err` if
|
||||
/// the peer's message is malformed (a wrong PIN does NOT error here — it yields a
|
||||
/// *different* key, so the confirmation MACs simply won't match).
|
||||
pub fn finish(self, peer_msg: &[u8]) -> Result<Confirmations> {
|
||||
let key = self
|
||||
.state
|
||||
.finish(peer_msg)
|
||||
.map_err(|_| PunktfunkError::Crypto)?;
|
||||
Ok(Confirmations {
|
||||
host: confirm(&key, b"punktfunk-pair-host", &self.transcript),
|
||||
client: confirm(&key, b"punktfunk-pair-client", &self.transcript),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time tag comparison for the confirmation step.
|
||||
pub fn verify(expected: &[u8; 32], got: &[u8; 32]) -> bool {
|
||||
ct_eq(expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
impl Hello {
|
||||
@@ -354,7 +471,7 @@ 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.extend_from_slice(CTL_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());
|
||||
@@ -363,7 +480,7 @@ impl Reconfigure {
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<Reconfigure> {
|
||||
if b.len() < 17 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURE {
|
||||
if b.len() != 17 || &b[0..4] != CTL_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]]);
|
||||
@@ -381,7 +498,7 @@ 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.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_RECONFIGURED);
|
||||
b.push(self.accepted as u8);
|
||||
b.extend_from_slice(&self.mode.width.to_le_bytes());
|
||||
@@ -391,7 +508,7 @@ impl Reconfigured {
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<Reconfigured> {
|
||||
if b.len() < 18 || &b[0..4] != MAGIC || b[4] != MSG_RECONFIGURED {
|
||||
if b.len() != 18 || &b[0..4] != CTL_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]]);
|
||||
@@ -863,6 +980,86 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_messages_disjoint_from_hello() {
|
||||
// A Hello uses MAGIC (PKF1); control messages use CTL_MAGIC (PKFc). No Hello — at
|
||||
// any abi_version — can be misparsed as a control message, and vice-versa.
|
||||
for abi in [1u32, 2, 16, 0x10, 0x0113, 0x1410] {
|
||||
let h = Hello {
|
||||
abi_version: abi,
|
||||
mode: Mode {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
refresh_hz: 60,
|
||||
},
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
assert!(Reconfigure::decode(&h).is_err());
|
||||
}
|
||||
// And a PairRequest never parses as a Hello.
|
||||
let pr = PairRequest {
|
||||
name: "x".into(),
|
||||
spake_a: vec![0u8; 33],
|
||||
}
|
||||
.encode();
|
||||
assert!(Hello::decode(&pr).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_messages_roundtrip() {
|
||||
let pr = PairRequest {
|
||||
name: "Enrico's Mac".into(),
|
||||
spake_a: vec![1, 2, 3, 4, 5],
|
||||
};
|
||||
assert_eq!(PairRequest::decode(&pr.encode()).unwrap(), pr);
|
||||
let pc = PairChallenge {
|
||||
spake_b: vec![9; 33],
|
||||
confirm: [7u8; 32],
|
||||
};
|
||||
assert_eq!(PairChallenge::decode(&pc.encode()).unwrap(), pc);
|
||||
let pp = PairProof { confirm: [3u8; 32] };
|
||||
assert_eq!(PairProof::decode(&pp.encode()).unwrap(), pp);
|
||||
for ok in [true, false] {
|
||||
assert_eq!(
|
||||
PairResult::decode(&PairResult { ok }.encode()).unwrap().ok,
|
||||
ok
|
||||
);
|
||||
}
|
||||
// Length-exact: a truncated/padded PairProof is rejected.
|
||||
let mut bad = pp.encode();
|
||||
bad.push(0);
|
||||
assert!(PairProof::decode(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spake2_pairing_agrees_only_on_matching_pin_and_certs() {
|
||||
let cfp = [0x11u8; 32];
|
||||
let hfp = [0x22u8; 32];
|
||||
|
||||
// Right PIN, same fingerprint views on both sides → both confirmations agree.
|
||||
let (ca, ma) = pake::start(true, "4321", &cfp, &hfp);
|
||||
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||
let a = ca.finish(&mb).unwrap();
|
||||
let b = cb.finish(&ma).unwrap();
|
||||
assert!(pake::verify(&a.host, &b.host) && pake::verify(&a.client, &b.client));
|
||||
|
||||
// Wrong PIN → different keys → confirmations DON'T match (one online guess wasted).
|
||||
let (ca, ma) = pake::start(true, "0000", &cfp, &hfp);
|
||||
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||
let a = ca.finish(&mb).unwrap();
|
||||
let b = cb.finish(&ma).unwrap();
|
||||
assert!(!pake::verify(&a.client, &b.client));
|
||||
|
||||
// MITM: the two legs saw different host certs → no agreement even with the right PIN.
|
||||
let attacker_hfp = [0x33u8; 32];
|
||||
let (ca, ma) = pake::start(true, "4321", &cfp, &attacker_hfp);
|
||||
let (cb, mb) = pake::start(false, "4321", &cfp, &hfp);
|
||||
let a = ca.finish(&mb).unwrap();
|
||||
let b = cb.finish(&ma).unwrap();
|
||||
assert!(!pake::verify(&a.client, &b.client));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_datagram_roundtrip() {
|
||||
let opus = [0x42u8; 97];
|
||||
|
||||
Reference in New Issue
Block a user