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
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>
90 lines
4.5 KiB
Rust
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(())
|
|
}
|