feat(server): v2 secure-session-core Task 4 - rate limit + single-use codes
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 6m12s
Build and Test / Build Agent (Windows) (push) Successful in 6m43s
Build and Test / Security Audit (push) Successful in 4m23s
Build and Test / Build Summary (push) Has been skipped

SPEC-002 Phase 1 Task 4 (the final keystone task), code-reviewed APPROVED.
Closes the audit's reusable-code HIGH and rate-limiting-disabled HIGH.

- Rebuilt rate limiting as a self-contained in-memory per-IP limiter (replaces
  the non-compiling tower_governor; removed that dep). Fixed-window caps wired
  to login (8/min), change-password (5/min), code-validate (15/min) -> 429;
  per-IP lockout after 10 consecutive failed code validations (15-min cooldown).
- Single-use support codes: atomic consume on first agent bind (in-memory
  Pending->Connected under write lock + DB conditional UPDATE), rejecting a
  second presenter; validate/preview does not consume.
- Widened code format: XXX-XXX-XXX, 31-char unambiguous alphabet (no 0/O/1/I/L),
  CSPRNG + rejection sampling, ~44.6 bits (replaces 6-digit numeric); migration
  006 widens the code columns to TEXT.

Completes the keystone (Tasks 1-4): every audit CRITICAL + HIGH in the secure
auth/session core is now addressed. Known follow-up todos (not blocking): (1)
trusted-proxy client-IP extraction (NPM-on-loopback collapses clients to
127.0.0.1); (2) multi-instance fail-closed DB single-use gate. Not
cargo-check-verified locally - build-host/CI verification follows this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 21:04:54 -07:00
parent 8a0193577b
commit bfcdbb5379
9 changed files with 1026 additions and 130 deletions

View File

@@ -1,16 +1,74 @@
//! Support session codes management
//!
//! Handles generation and validation of 6-digit support codes
//! for one-time remote support sessions.
//! Handles generation and validation of high-entropy, human-readable support
//! codes for one-time remote support sessions.
//!
//! ## Code format (Task 4)
//!
//! v1 used a 6-digit numeric code (~20 bits, trivially brute-forceable). v2 uses
//! a grouped base32-style code drawn from an UNAMBIGUOUS alphabet (no `0`/`O`,
//! `1`/`I`/`L`) so a human reading it aloud cannot mistranscribe it:
//!
//! ```text
//! XXX-XXX-XXX e.g. K7P-3MQ-Z9F
//! ```
//!
//! 9 symbols over a 31-character alphabet ≈ **44.6 bits** of entropy, generated
//! with a CSPRNG ([`OsRng`]). Combined with the per-IP rate limiting + lockout on
//! the validate route (Task 4) and single-use consumption on bind, the code space
//! is no longer practically brute-forceable.
use chrono::{DateTime, Utc};
use rand::Rng;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use uuid::Uuid;
/// Unambiguous code alphabet: digits 2-9 and A-Z, EXCLUDING the visually
/// confusable `0`/`O`, `1`/`I`/`L`. 31 distinct symbols (≈4.954 bits each).
const CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
/// Number of alphabet symbols in a generated code (excluding group separators).
/// 9 symbols × log2(31) ≈ 44.6 bits — comfortably above the 40-bit target.
const CODE_SYMBOLS: usize = 9;
/// Symbols per visual group (the code is rendered as hyphen-separated groups).
const CODE_GROUP_SIZE: usize = 3;
/// Draw a single uniformly-distributed symbol from [`CODE_ALPHABET`] using a
/// CSPRNG, via rejection sampling so every symbol is equally likely (no modulo
/// bias). 31 is not a power of two, so we reject draws in the biased tail.
fn random_symbol() -> u8 {
let n = CODE_ALPHABET.len() as u32; // 31
// Largest multiple of n that fits in a u8 draw space (256); reject above it.
let limit = (256 / n) * n; // 248
let mut rng = OsRng;
loop {
let mut buf = [0u8; 1];
rng.fill_bytes(&mut buf);
let v = buf[0] as u32;
if v < limit {
return CODE_ALPHABET[(v % n) as usize];
}
// else: biased tail — draw again.
}
}
/// Generate a fresh grouped support code, e.g. `K7P-3MQ-Z9F`. CSPRNG-backed.
fn generate_code_string() -> String {
let mut out = String::with_capacity(CODE_SYMBOLS + CODE_SYMBOLS / CODE_GROUP_SIZE);
for i in 0..CODE_SYMBOLS {
if i > 0 && i % CODE_GROUP_SIZE == 0 {
out.push('-');
}
out.push(random_symbol() as char);
}
out
}
/// A support session code
#[derive(Debug, Clone, Serialize)]
pub struct SupportCode {
@@ -67,15 +125,16 @@ impl SupportCodeManager {
}
}
/// Generate a unique 6-digit code
/// Generate a unique high-entropy support code (see module docs).
///
/// Draws CSPRNG-backed grouped codes (`XXX-XXX-XXX`, ≈44.6 bits) until one is
/// not already live in the in-memory map. With a 31^9 code space the collision
/// probability is negligible; the loop only guards against the (astronomically
/// unlikely) duplicate.
async fn generate_unique_code(&self) -> String {
let codes = self.codes.read().await;
let mut rng = rand::thread_rng();
loop {
let code: u32 = rng.gen_range(100000..999999);
let code_str = code.to_string();
let code_str = generate_code_string();
if !codes.contains_key(&code_str) {
return code_str;
}
@@ -142,7 +201,12 @@ impl SupportCodeManager {
}
}
/// Mark a code as connected
/// Mark a code as connected.
///
/// Superseded by [`SupportCodeManager::consume_for_bind`] for the single-use
/// bind path (Task 4). Retained for non-bind callers; not used on the agent
/// bind path any longer.
#[allow(dead_code)]
pub async fn mark_connected(
&self,
code: &str,
@@ -158,6 +222,40 @@ impl SupportCodeManager {
}
}
/// Atomically CONSUME a code for a first-time agent bind (single-use, Task 4).
///
/// This is the single-use gate for the in-memory layer. Under the write lock,
/// it accepts the code ONLY if it is currently `Pending` (never used), flips
/// it to `Connected`, and records the binding client. Any other state
/// (`Connected` — already bound, `Completed`, `Cancelled`, or a nonexistent
/// code) is rejected. Because the transition happens while holding the write
/// lock, two concurrent presenters of the same code race for the single
/// `Pending → Connected` transition: exactly one wins, the loser is rejected.
///
/// Returns `true` if the caller consumed the code (and may proceed to bind),
/// `false` if the code was not available for consumption.
///
/// NOTE: the preview route (`validate_code`) deliberately does NOT call this —
/// previewing a code must never consume it. Only the agent bind path does.
pub async fn consume_for_bind(
&self,
code: &str,
client_name: Option<String>,
client_machine: Option<String>,
) -> bool {
let mut codes = self.codes.write().await;
match codes.get_mut(code) {
Some(support_code) if support_code.status == CodeStatus::Pending => {
support_code.status = CodeStatus::Connected;
support_code.client_name = client_name;
support_code.client_machine = client_machine;
support_code.connected_at = Some(Utc::now());
true
}
_ => false,
}
}
/// Link a support code to an actual WebSocket session
pub async fn link_session(&self, code: &str, real_session_id: Uuid) {
let mut codes = self.codes.write().await;
@@ -248,7 +346,11 @@ impl SupportCodeManager {
codes.get(code).cloned()
}
/// Get the status of a code as a string (for auth checks)
/// Get the status of a code as a string (for auth checks).
///
/// No longer used on the agent bind path (replaced by the atomic
/// `consume_for_bind` single-use gate, Task 4); retained for diagnostics.
#[allow(dead_code)]
pub async fn get_status(&self, code: &str) -> Option<String> {
let codes = self.codes.read().await;
codes.get(code).map(|c| match c.status {
@@ -265,3 +367,113 @@ impl Default for SupportCodeManager {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generated_code_has_expected_shape() {
// XXX-XXX-XXX: 9 symbols + 2 hyphens = 11 chars.
let code = generate_code_string();
assert_eq!(code.len(), CODE_SYMBOLS + 2);
let parts: Vec<&str> = code.split('-').collect();
assert_eq!(parts.len(), CODE_SYMBOLS / CODE_GROUP_SIZE);
for p in parts {
assert_eq!(p.len(), CODE_GROUP_SIZE);
}
}
#[test]
fn generated_code_uses_only_unambiguous_alphabet() {
// No 0/O/1/I/L; every non-hyphen char is in CODE_ALPHABET.
for _ in 0..2_000 {
let code = generate_code_string();
for c in code.chars().filter(|c| *c != '-') {
assert!(
CODE_ALPHABET.contains(&(c as u8)),
"char {:?} not in unambiguous alphabet",
c
);
assert!(
!matches!(c, '0' | 'O' | '1' | 'I' | 'L'),
"ambiguous char {:?} leaked into a code",
c
);
}
}
}
#[test]
fn generated_codes_are_distinct_in_practice() {
// With ~44 bits of entropy, 1000 draws should be unique.
use std::collections::HashSet;
let mut seen = HashSet::new();
for _ in 0..1_000 {
assert!(seen.insert(generate_code_string()), "unexpected collision");
}
}
#[tokio::test]
async fn consume_for_bind_is_single_use() {
let mgr = SupportCodeManager::new();
let code = mgr
.create_code(CreateCodeRequest {
technician_id: None,
technician_name: Some("tech".to_string()),
})
.await
.code;
// First bind consumes the code.
assert!(
mgr.consume_for_bind(&code, Some("agent".into()), Some("a1".into()))
.await
);
// Second presenter is rejected — single use.
assert!(
!mgr.consume_for_bind(&code, Some("agent2".into()), Some("a2".into()))
.await
);
}
#[tokio::test]
async fn consume_for_bind_rejects_unknown_code() {
let mgr = SupportCodeManager::new();
assert!(!mgr.consume_for_bind("NOP-E00-000", None, None).await);
}
#[tokio::test]
async fn consume_for_bind_rejects_cancelled_code() {
let mgr = SupportCodeManager::new();
let code = mgr
.create_code(CreateCodeRequest {
technician_id: None,
technician_name: Some("tech".to_string()),
})
.await
.code;
assert!(mgr.cancel_code(&code).await);
// A cancelled code is not Pending → cannot be consumed.
assert!(!mgr.consume_for_bind(&code, None, None).await);
}
#[tokio::test]
async fn preview_validate_does_not_consume() {
let mgr = SupportCodeManager::new();
let code = mgr
.create_code(CreateCodeRequest {
technician_id: None,
technician_name: Some("tech".to_string()),
})
.await
.code;
// Previewing the code many times must not consume it...
for _ in 0..5 {
assert!(mgr.validate_code(&code).await.valid);
}
// ...so a first real bind still succeeds.
assert!(mgr.consume_for_bind(&code, None, None).await);
}
}