fix(host/pairing): close native-pairing DoS findings #9 + #13 (red-team follow-up)

The accepts for #9 (PIN-window burn) and #13 (knock-queue flood) rested on a
circular premise — each cited the other as the safe fallback — and a re-review
showed one LAN attacker could defeat BOTH, denying all onboarding. Close them:

- #13 per-source-IP cap on the pending-knock queue (MAX_PENDING_PER_IP) so one
  host can't fill/evict the 32-slot queue (QUIC validates the source address);
  and eviction now NEVER drops a live *parked* knock (a held-open connection
  awaiting operator approval), so a cert-rotating flood can't evict the genuine
  device being onboarded. This makes the delegated-approval path genuinely
  flood-resistant — restoring the validity of #9's "use delegated approval on
  hostile LANs" fallback.

- #9 fingerprint-bindable PIN window: `NativePairing::arm_for(ttl, Some(fp))`
  binds the window to one operator-selected device; `pin_for_attempt` returns
  `BoundToOther` for any other fingerprint, which the QUIC pair path rejects
  WITHOUT consuming the window — so an unpaired peer can neither pair nor BURN a
  window armed for a specific device (it can't forge the bound fingerprint). The
  mgmt `POST /native/pair/arm` gains an optional `fingerprint` (from a pending
  knock); unbound arming keeps the legacy any-device behavior (trusted-LAN).
  (Web-console "pair this pending device with a PIN" UX is a follow-up; the
  flood-resistant knock path above is the immediate hostile-LAN onboarding path.)

+ regression tests (armed_pin_is_fingerprint_bindable,
  pending_per_ip_cap_and_parked_protection); api/openapi.json regenerated.
110 host tests + clippy + fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 09:02:00 +00:00
parent 6e2e946bc9
commit 2865368771
4 changed files with 245 additions and 32 deletions
+22 -5
View File
@@ -532,10 +532,25 @@ async fn serve_session(
.await
.map_err(|_| anyhow!("first message timeout"))??;
if let Ok(req) = PairRequest::decode(&first) {
// Read the live arming PIN per attempt, so a window that lapsed no longer pairs.
let pin = np
.current_pin()
.context("pairing not armed (arm it in the console, or start with --allow-pairing)")?;
// The client fingerprint (cert possession is proven by the QUIC handshake) is needed to honor
// a fingerprint-bound PIN window (#9): a window the operator armed for a SPECIFIC device must
// not be consumable — or burnable — by any other fingerprint.
let client_fp = endpoint::peer_fingerprint(&conn)
.ok_or_else(|| anyhow!("pairing requires the client to present a certificate"))?;
let client_fp_hex = fingerprint_hex(&client_fp);
// Resolve the live arming PIN per attempt (so a lapsed window no longer pairs), honoring any
// fingerprint binding.
let pin = match np.pin_for_attempt(&client_fp_hex) {
crate::native_pairing::PinAttempt::Pin(pin) => pin,
crate::native_pairing::PinAttempt::Disarmed => anyhow::bail!(
"pairing not armed (arm it in the console, or start with --allow-pairing)"
),
// Armed for a DIFFERENT device — reject without running the ceremony, so this attempt does
// NOT consume (burn) the operator's window for the device they actually selected (#9).
crate::native_pairing::PinAttempt::BoundToOther => anyhow::bail!(
"pairing is armed for a different device — this attempt does not consume the window"
),
};
{
let mut last = last_pairing.lock().unwrap();
if let Some(t) = *last {
@@ -589,7 +604,9 @@ async fn serve_session(
);
tracing::info!(name = %label, fingerprint = %fp_hex,
"unpaired device knocked — parking connection for delegated approval in the console");
np.note_pending(&label, &fp_hex);
// Record the QUIC-validated source IP so the pending queue's per-source cap can stop one
// host from flooding/evicting genuine knocks (#13).
np.note_pending(&label, &fp_hex, Some(peer.ip()));
// Free the session slot while a human decides — a parked knock must not hold an NVENC
// permit (a handful of parked knocks would otherwise block every real session).
drop(permit);