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:
@@ -394,6 +394,12 @@ struct ArmNativePairing {
|
||||
/// Window length in seconds (default 120; clamped to 15–600).
|
||||
#[schema(example = 120)]
|
||||
ttl_secs: Option<u32>,
|
||||
/// Optional: bind the window to ONE device fingerprint (hex SHA-256, e.g. from a pending knock).
|
||||
/// When set, only a pairing attempt from that fingerprint consumes the window — so an unpaired
|
||||
/// LAN peer can neither pair nor burn a window armed for a specific device (security-review #9).
|
||||
/// Omit for an unbound window (any device may use the PIN — trusted-LAN only).
|
||||
#[schema(example = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")]
|
||||
fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
/// A paired native (punktfunk/1) client.
|
||||
@@ -879,8 +885,21 @@ async fn arm_native_pairing(
|
||||
);
|
||||
};
|
||||
let ttl = req.ttl_secs.unwrap_or(120).clamp(15, 600);
|
||||
let _pin = np.arm(std::time::Duration::from_secs(ttl as u64));
|
||||
tracing::info!(ttl_secs = ttl, "management API: native pairing armed");
|
||||
// A bound window (operator selected a specific device) is DoS-proof: only that fingerprint can
|
||||
// consume it (#9). An unbound window (no fingerprint) keeps the legacy any-device behavior.
|
||||
let bound = req
|
||||
.fingerprint
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_ascii_lowercase());
|
||||
let bound_to_device = bound.is_some();
|
||||
let _pin = np.arm_for(std::time::Duration::from_secs(ttl as u64), bound);
|
||||
tracing::info!(
|
||||
ttl_secs = ttl,
|
||||
bound_to_device,
|
||||
"management API: native pairing armed"
|
||||
);
|
||||
Json(native_status(&st)).into_response()
|
||||
}
|
||||
|
||||
@@ -1975,8 +1994,8 @@ mod tests {
|
||||
assert_eq!(b.as_array().unwrap().len(), 0);
|
||||
|
||||
// Two devices knock (what the QUIC gate records); they appear in the list.
|
||||
np.note_pending("Enrico's MacBook", "aa11");
|
||||
np.note_pending("device bb22cc33", "bb22");
|
||||
np.note_pending("Enrico's MacBook", "aa11", None);
|
||||
np.note_pending("device bb22cc33", "bb22", None);
|
||||
let (_, b) = send(&app, get_req("/api/v1/native/pending")).await;
|
||||
assert_eq!(b.as_array().unwrap().len(), 2);
|
||||
assert_eq!(b[0]["name"], "Enrico's MacBook");
|
||||
|
||||
Reference in New Issue
Block a user