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:
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user