Files
punktfunk/crates/punktfunk-host/src/native/pairing.rs
T
enricobuehlerandClaude Opus 4.8 ecfa71212d
audit / bun-audit (push) Successful in 27s
windows-drivers / probe-and-proto (push) Successful in 49s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 1m10s
apple / swift (push) Successful in 1m18s
decky / build-publish (push) Successful in 34s
windows-drivers / driver-build (push) Successful in 1m37s
audit / cargo-audit (push) Successful in 3m1s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 42s
ci / bench (push) Successful in 5m56s
windows-host / package (push) Failing after 5m17s
apple / screenshots (push) Successful in 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 4m38s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11m3s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m2s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m15s
arch / build-publish (push) Successful in 16m50s
android / android (push) Successful in 17m7s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 17m6s
ci / rust (push) Failing after 19m1s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m3s
flatpak / build-publish (push) Successful in 8m0s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 14m14s
release / apple (push) Successful in 5m44s
chore: consolidate all in-progress parallel-session WIP
Wholesale commit of every uncommitted change across the tree, at the user's
explicit request — host refactor-campaign W1 (native.rs facade + native/ dir,
library/ + mgmt/ splits), Android, core. These streams were mid-flight and not
individually built/tested together; this supersedes the per-session HOLD
markers. Consolidating so everything lands on main in one pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 20:08:29 +02:00

90 lines
4.5 KiB
Rust

//! The host side of the native SPAKE2 pairing ceremony (plan §W1 — carved out of the [`super`]
//! module). `serve_session` dispatches a connection whose first message is a `PairRequest` here,
//! after it has resolved the live arming PIN (honoring fingerprint binding, #9); this runs the
//! ceremony, enforces the single online guess, and persists the client's fingerprint on success.
use super::*;
// The ceremony-only wire messages: imported directly (native.rs no longer references them, so they
// were dropped from its `use` and won't come through `use super::*`). `PairRequest` still arrives
// via the glob (serve_session decodes it).
use punktfunk_core::quic::{PairChallenge, PairProof, PairResult};
/// 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 SPAKE2 pairing ceremony (see `punktfunk_core::quic::pake`):
/// generate + display a PIN, run SPAKE2 as B binding both cert fingerprints, verify the
/// client's key-confirmation MAC (its single online guess), and persist the client's
/// fingerprint on success.
pub(super) async fn pair_ceremony(
conn: &quinn::Connection,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
req: PairRequest,
host_fp: &[u8; 32],
np: &NativePairing,
pin: &str,
) -> Result<()> {
use punktfunk_core::quic::pake;
let client_fp = endpoint::peer_fingerprint(conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
tracing::info!(
name = %req.name,
client = %fingerprint_hex(&client_fp),
"PAIRING REQUEST — verifying against the armed PIN"
);
// SPAKE2 as B; bind our own host_fp + the client cert we actually received.
let (pake, spake_b) = pake::start(false, pin, &client_fp, host_fp);
let confirms = pake.finish(&req.spake_a)?; // Err only on a malformed peer message
io::write_msg(
&mut send,
&PairChallenge {
spake_b,
confirm: confirms.host,
}
.encode(),
)
.await?;
// SINGLE-USE PIN: we've now sent the host key-confirmation, which lets the client TEST this one
// guess (a right PIN → its proof will match; a wrong PIN → the client detects the mismatch and
// aborts *without* sending its proof). So consume the PIN HERE — before reading the proof —
// regardless of the outcome: an attacker gets EXACTLY ONE online guess (the documented guarantee),
// not an unbounded brute-force of the 4-digit space against a static, never-rotating PIN. A
// malformed request that errored at `pake.finish` above never reached here, so it doesn't burn the
// window (no DoS from garbage). The operator re-arms (web console / restart) for the next device —
// including after a successful pair; the protocol gives no reliable host-observable "wrong PIN"
// signal to scope this to failures only (the client just disconnects).
np.disarm();
let proof = tokio::time::timeout(PAIRING_TIMEOUT, io::read_msg(&mut recv))
.await
.map_err(|_| anyhow!("pairing timed out waiting for the client's confirmation"))??;
let proof = PairProof::decode(&proof).map_err(|e| anyhow!("PairProof decode: {e:?}"))?;
// A wrong PIN (or a MITM with mismatched cert views) yields a different SPAKE2 key, so
// the client's confirmation MAC won't match ours — one online attempt, no offline search.
let ok = pake::verify(&confirms.client, &proof.confirm);
if ok {
if let Err(e) = np.add(&req.name, &fingerprint_hex(&client_fp)) {
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 rejected (wrong PIN) — fingerprint not stored");
}
io::write_msg(&mut send, &PairResult { ok }.encode()).await?;
let _ = send.finish();
// Wait for the client to acknowledge by closing, so the PairResult isn't dropped by our
// close on a slow link (bounded so a vanished client can't wedge the sequential host).
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), conn.closed()).await;
conn.close(0u32.into(), b"pairing done");
anyhow::ensure!(ok, "pairing rejected (wrong PIN)");
Ok(())
}