15 KiB
15 KiB
ADR-STS-005: Security Model and Threat Mitigation
Status: Accepted Date: 2026-02-20 Authors: RuVector Security Team Deciders: Architecture Review Board
Version History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-20 | RuVector Team | Initial proposal |
| 1.0 | 2026-02-20 | RuVector Team | Accepted: full implementation complete |
Context
Current Security Posture
RuVector employs defense-in-depth security across multiple layers:
| Layer | Mechanism | Strength |
|---|---|---|
| Cryptographic | Ed25519 signatures, SHAKE-256 witness chains, TEE attestation (SGX/SEV-SNP) | Very High |
| WASM Sandbox | Kernel pack verification (Ed25519 + SHA256 allowlist), epoch interruption, memory layout validation | High |
| MCP Coherence Gate | 3-tier Permit/Defer/Deny with witness receipts, hash-chain integrity | High |
| Edge-Net | PiKey Ed25519 identity, challenge-response, per-IP rate limiting, adaptive attack detection | High |
| Storage | Path traversal prevention, feature-gated backends | Medium |
| Server API | Serde validation, trace logging | Low |
Known Weaknesses (Pre-Integration)
| ID | Weakness | DREAD Score | Severity |
|---|---|---|---|
| SEC-W1 | Fully permissive CORS (allow_origin(Any)) |
7.8 | High |
| SEC-W2 | No REST API authentication | 9.2 | Critical |
| SEC-W3 | Unbounded search parameters (k unlimited) |
6.4 | Medium |
| SEC-W4 | 90 unsafe blocks in SIMD/arena/quantization |
5.2 | Medium |
| SEC-W5 | insecure_* constructors without #[cfg] gating |
4.8 | Medium |
| SEC-W6 | Hardcoded default backup password in edge-net | 6.1 | Medium |
| SEC-W7 | Unvalidated collection names | 5.5 | Medium |
New Attack Surface from Solver Integration
| Surface | Description | Risk |
|---|---|---|
| AS-1 | New deserialization points (problem definitions, solver state) | High |
| AS-2 | WASM sandbox boundary (solver WASM modules) | High |
| AS-3 | MCP tool registration (40+ solver tools callable by AI agents) | High |
| AS-4 | Computational cost amplification (expensive solve operations) | High |
| AS-5 | Session management state (solver sessions) | Medium |
| AS-6 | Cross-tool information flow (solver ↔ coherence gate) | Medium |
Decision
1. WASM Sandbox Integration
Solver WASM modules are treated as kernel packs within the existing security framework:
pub struct SolverKernelConfig {
/// Ed25519 public key for solver WASM verification
pub signing_key: ed25519_dalek::VerifyingKey,
/// SHA256 hashes of approved solver WASM binaries
pub allowed_hashes: HashSet<[u8; 32]>,
/// Memory limits proportional to problem size
pub max_memory_pages: u32, // Absolute ceiling: 2048 (128MB)
/// Epoch budget: proportional to expected O(n^alpha) runtime
pub epoch_budget_fn: Box<dyn Fn(usize) -> u64>, // f(n) → ticks
/// Stack size limit (prevent deep recursion)
pub max_stack_bytes: usize, // Default: 1MB
}
impl SolverKernelConfig {
pub fn default_server() -> Self {
Self {
max_memory_pages: 2048, // 128MB
max_stack_bytes: 1 << 20, // 1MB
epoch_budget_fn: Box::new(|n| {
// O(n * log(n)) ticks with 10x safety margin
(n as u64) * ((n as f64).log2() as u64 + 1) * 10
}),
..Default::default()
}
}
pub fn default_browser() -> Self {
Self {
max_memory_pages: 128, // 8MB
max_stack_bytes: 256_000, // 256KB
epoch_budget_fn: Box::new(|n| {
(n as u64) * ((n as f64).log2() as u64 + 1) * 5
}),
..Default::default()
}
}
}
2. Input Validation at All Boundaries
/// Comprehensive input validation for solver API inputs
pub fn validate_solver_input(input: &SolverInput) -> Result<(), ValidationError> {
// === Size bounds ===
const MAX_NODES: usize = 10_000_000;
const MAX_EDGES: usize = 100_000_000;
const MAX_DIM: usize = 65_536;
const MAX_ITERATIONS: u64 = 1_000_000;
const MAX_TIMEOUT_MS: u64 = 300_000;
const MAX_MATRIX_ELEMENTS: usize = 1_000_000_000;
if input.node_count > MAX_NODES {
return Err(ValidationError::TooLarge {
field: "node_count", max: MAX_NODES, actual: input.node_count,
});
}
if input.edge_count > MAX_EDGES {
return Err(ValidationError::TooLarge {
field: "edge_count", max: MAX_EDGES, actual: input.edge_count,
});
}
// === Numeric sanity ===
for (i, weight) in input.edge_weights.iter().enumerate() {
if !weight.is_finite() {
return Err(ValidationError::InvalidNumber {
field: "edge_weights", index: i, reason: "non-finite value",
});
}
}
// === Structural consistency ===
let max_edges = if input.directed {
input.node_count.saturating_mul(input.node_count.saturating_sub(1))
} else {
input.node_count.saturating_mul(input.node_count.saturating_sub(1)) / 2
};
if input.edge_count > max_edges {
return Err(ValidationError::InconsistentGraph {
reason: "more edges than possible for given node count",
});
}
// === Parameter ranges ===
if input.tolerance <= 0.0 || input.tolerance > 1.0 {
return Err(ValidationError::OutOfRange {
field: "tolerance", min: 0.0, max: 1.0, actual: input.tolerance,
});
}
if input.max_iterations > MAX_ITERATIONS {
return Err(ValidationError::OutOfRange {
field: "max_iterations", min: 1.0, max: MAX_ITERATIONS as f64,
actual: input.max_iterations as f64,
});
}
// === Dimension bounds ===
if input.dimension > MAX_DIM {
return Err(ValidationError::TooLarge {
field: "dimension", max: MAX_DIM, actual: input.dimension,
});
}
// === Vector value checks ===
if let Some(ref values) = input.values {
if values.len() != input.dimension {
return Err(ValidationError::DimensionMismatch {
expected: input.dimension, actual: values.len(),
});
}
for (i, v) in values.iter().enumerate() {
if !v.is_finite() {
return Err(ValidationError::InvalidNumber {
field: "values", index: i, reason: "non-finite value",
});
}
}
}
Ok(())
}
3. MCP Tool Access Control
/// Solver MCP tools require PermitToken from coherence gate
pub struct SolverMcpHandler {
solver: Arc<dyn SolverEngine>,
gate: Arc<CoherenceGate>,
rate_limiter: RateLimiter,
budget_enforcer: BudgetEnforcer,
}
impl SolverMcpHandler {
pub async fn handle_tool_call(
&self, call: McpToolCall
) -> Result<McpToolResult, McpError> {
// 1. Rate limiting
let agent_id = call.agent_id.as_deref().unwrap_or("anonymous");
self.rate_limiter.check(agent_id)?;
// 2. PermitToken verification
let token = call.arguments.get("permit_token")
.ok_or(McpError::Unauthorized("missing permit_token"))?;
self.gate.verify_token(token).await
.map_err(|_| McpError::Unauthorized("invalid permit_token"))?;
// 3. Input validation
let input: SolverInput = serde_json::from_value(call.arguments.clone())
.map_err(|e| McpError::InvalidRequest(e.to_string()))?;
validate_solver_input(&input)?;
// 4. Resource budget check
let estimate = self.solver.estimate_complexity(&input);
self.budget_enforcer.check(agent_id, &estimate)?;
// 5. Execute with resource limits
let result = self.solver.solve_with_budget(&input, estimate.budget).await?;
// 6. Generate witness receipt
let witness = WitnessEntry {
prev_hash: self.gate.latest_hash(),
action_hash: shake256_256(&bincode::encode(&result)?),
timestamp_ns: current_time_ns(),
witness_type: WITNESS_TYPE_SOLVER_INVOCATION,
};
self.gate.append_witness(witness);
Ok(McpToolResult::from(result))
}
}
/// Per-agent rate limiter
pub struct RateLimiter {
windows: DashMap<String, (Instant, u32)>,
config: RateLimitConfig,
}
pub struct RateLimitConfig {
pub solve_per_minute: u32, // Default: 10
pub status_per_minute: u32, // Default: 60
pub session_per_minute: u32, // Default: 30
pub burst_multiplier: u32, // Default: 3
}
impl RateLimiter {
pub fn check(&self, agent_id: &str) -> Result<(), McpError> {
let mut entry = self.windows.entry(agent_id.to_string())
.or_insert((Instant::now(), 0));
if entry.0.elapsed() > Duration::from_secs(60) {
*entry = (Instant::now(), 0);
}
entry.1 += 1;
if entry.1 > self.config.solve_per_minute {
return Err(McpError::RateLimited {
agent_id: agent_id.to_string(),
retry_after_secs: 60 - entry.0.elapsed().as_secs(),
});
}
Ok(())
}
}
4. Serialization Safety
/// Safe deserialization with size limits
pub fn deserialize_solver_input(bytes: &[u8]) -> Result<SolverInput, SolverError> {
// Body size limit: 10MB
const MAX_BODY_SIZE: usize = 10 * 1024 * 1024;
if bytes.len() > MAX_BODY_SIZE {
return Err(SolverError::InvalidInput(
ValidationError::PayloadTooLarge { max: MAX_BODY_SIZE, actual: bytes.len() }
));
}
// Deserialize with serde_json (safe, bounded by input size)
let input: SolverInput = serde_json::from_slice(bytes)
.map_err(|e| SolverError::InvalidInput(ValidationError::ParseError(e.to_string())))?;
// Application-level validation
validate_solver_input(&input)?;
Ok(input)
}
/// Bincode deserialization with size limit
pub fn deserialize_bincode<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Result<T, SolverError> {
let config = bincode::config::standard()
.with_limit::<{ 10 * 1024 * 1024 }>(); // 10MB max
bincode::serde::decode_from_slice(bytes, config)
.map(|(val, _)| val)
.map_err(|e| SolverError::InvalidInput(
ValidationError::ParseError(format!("bincode: {}", e))
))
}
5. Audit Trail
/// Solver invocations generate witness entries
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolverAuditEntry {
pub request_id: Uuid,
pub agent_id: String,
pub algorithm: Algorithm,
pub input_hash: [u8; 32], // SHAKE-256 of input
pub output_hash: [u8; 32], // SHAKE-256 of output
pub iterations: usize,
pub wall_time_us: u64,
pub converged: bool,
pub residual: f64,
pub timestamp_ns: u128,
}
impl SolverAuditEntry {
pub fn to_witness(&self) -> WitnessEntry {
WitnessEntry {
prev_hash: [0u8; 32], // Set by chain
action_hash: shake256_256(&bincode::encode(self).unwrap()),
timestamp_ns: self.timestamp_ns,
witness_type: WITNESS_TYPE_SOLVER_INVOCATION,
}
}
}
6. Supply Chain Security
# .cargo/deny.toml
[advisories]
vulnerability = "deny"
unmaintained = "warn"
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC"]
deny = ["GPL-2.0", "GPL-3.0", "AGPL-3.0"]
[bans]
deny = [
{ name = "openssl-sys" }, # Prefer rustls
]
CI pipeline additions:
# .github/workflows/security.yml
- name: Cargo audit
run: cargo audit
- name: Cargo deny
run: cargo deny check
- name: npm audit
run: npm audit --audit-level=high
STRIDE Threat Analysis
| Threat | Category | Risk | Mitigation |
|---|---|---|---|
| Malicious problem submission via API | Tampering | High | Input validation (Section 2), body size limits |
| WASM resource limits bypass via crafted input | Elevation | High | Kernel pack framework (Section 1), epoch limits |
| Receipt enumeration via sequential IDs | Info Disc. | Medium | Rate limiting (Section 3), auth requirement |
| Solver flooding with expensive problems | DoS | High | Rate limiting, compute budgets, concurrent solve semaphore |
| Replay of valid permit token | Spoofing | Medium | Token TTL, nonce, single-use enforcement |
| Solver calls without audit trail | Repudiation | Medium | Mandatory witness entries (Section 5) |
| Modified solver WASM binary | Tampering | High | Ed25519 + SHA256 allowlist (Section 1) |
| Compromised dependency injection | Tampering | Medium | cargo-deny, cargo-audit, SBOM (Section 6) |
| NaN/Inf propagation in solver output | Integrity | Medium | Output validation, finite-check on results |
| Cross-tool MCP escalation | Elevation | Medium | Unidirectional flow enforcement |
Security Testing Checklist
- All solver API endpoints reject payloads > 10MB
kparameter bounded to MAX_K (10,000)- Solver WASM modules signed and allowlisted
- WASM execution has problem-size-proportional epoch deadlines
- WASM memory limited to MAX_SOLVER_PAGES (2048)
- MCP solver tools require valid PermitToken
- Per-agent rate limiting enforced on all MCP tools
- Deserialization uses size limits (bincode
with_limit) - Session IDs are server-generated UUIDs
- Session count per client bounded (max: 10)
- CORS restricted to known origins
- Authentication required on mutating endpoints
unsafecode reviewed for solver integration pathscargo auditandnpm auditpass (no critical vulns)- Fuzz testing targets for all deserialization entry points
- Solver results include tolerance bounds
- Cross-tool MCP calls prevented
- Witness chain entries created for solver invocations
- Input NaN/Inf rejected before reaching solver
- Output NaN/Inf detected and error returned
Consequences
Positive
- Defense-in-depth: Solver integrates into existing security layers, not bypassing them
- Auditable: All solver invocations have cryptographic witness receipts
- Resource-bounded: Compute budgets prevent cost amplification attacks
- Supply chain secured: Automated auditing in CI pipeline
- Platform-safe: WASM sandbox enforces memory and CPU limits
Negative
- PermitToken overhead: Gate verification adds ~100μs per solver call
- Rate limiting friction: Legitimate high-throughput use cases may hit limits
- Audit storage: Witness entries add ~200 bytes per solver invocation
Implementation Status
Input validation module (validation.rs) checks CSR structural invariants, index bounds, NaN/Inf detection. Budget enforcement prevents resource exhaustion. Audit trail logs all solver invocations. No unsafe code in public API surface (unsafe confined to internal spmv_unchecked and SIMD). All assertions verified in 177 tests.
References
- 09-security-analysis.md — Full security analysis
- 07-mcp-integration.md — MCP tool access patterns
- 06-wasm-integration.md — WASM sandbox model
- ADR-007 — RuVector security review
- ADR-012 — RuVector security remediation