swactor/crates/datastore/src/kani_auth.rs

382 lines
12 KiB
Rust
Raw Normal View History

//! Kani proof harnesses for AuthzEngine properties.
//!
//! Provides a bounded mirror of [`crate::auth::AuthzEngine`] that replaces
//! hash-based collections with fixed-size arrays and stubs out ed25519
//! crypto. This makes the logic tractable for Kani's symbolic execution
//! while preserving identical control-flow branches.
//!
//! Properties proven:
//! - **S1**: `check_node(n) = Allowed` ⟹ `n == owner ∨ n ∈ authorized_keys`
//! - **S2/S3/S4**: 4-step signed-request check rejects in the correct order
//! - **S5**: non-owner cannot mutate the ACL
//! - **S6**: owner access survives any sequence of grant/revoke operations
//! - **GC**: owner survives GC; expired nonces are freed
use crate::auth::{AuthzResult, DeniedReason};
// ─── Bounded types ──────────────────────────────────────────────────────────
const MAX_KEYS: usize = 3;
const MAX_NONCES: usize = 2;
/// Narrowed NodeId — 2 bytes (65 536 values) is plenty for proving
/// control-flow properties. Equality semantics identical to `[u8; 32]`.
#[derive(Clone, Copy, PartialEq, Eq)]
struct KaniNodeId([u8; 2]);
/// Bounded mirror of `AuthzEngine`. Array-backed collections replace
/// `HashSet`/`HashMap` so Kani avoids the SipHash symbolic explosion.
struct KaniAuthzEngine {
owner: KaniNodeId,
authorized_keys: [Option<KaniNodeId>; MAX_KEYS],
key_count: usize,
seen_nonces: [Option<([u8; 2], u64)>; MAX_NONCES],
nonce_count: usize,
timestamp_window: u64,
}
impl KaniAuthzEngine {
fn new(owner: KaniNodeId) -> Self {
Self {
owner,
authorized_keys: [None; MAX_KEYS],
key_count: 0,
seen_nonces: [None; MAX_NONCES],
nonce_count: 0,
timestamp_window: 300,
}
}
// ── Bounded-set helpers: authorized_keys ─────────────────────────────
fn keys_contains(&self, id: &KaniNodeId) -> bool {
let mut i = 0;
while i < self.key_count {
if let Some(k) = self.authorized_keys[i] {
if k == *id {
return true;
}
}
i += 1;
}
false
}
fn keys_insert(&mut self, id: KaniNodeId) {
if self.keys_contains(&id) {
return;
}
if self.key_count < MAX_KEYS {
self.authorized_keys[self.key_count] = Some(id);
self.key_count += 1;
}
}
fn keys_remove(&mut self, id: &KaniNodeId) {
let mut i = 0;
while i < self.key_count {
if let Some(k) = self.authorized_keys[i] {
if k == *id {
self.authorized_keys[i] = self.authorized_keys[self.key_count - 1];
self.authorized_keys[self.key_count - 1] = None;
self.key_count -= 1;
return;
}
}
i += 1;
}
}
// ── Bounded-set helpers: seen_nonces ─────────────────────────────────
fn nonces_contains(&self, nonce: &[u8; 2]) -> bool {
let mut i = 0;
while i < self.nonce_count {
if let Some((n, _)) = self.seen_nonces[i] {
if n == *nonce {
return true;
}
}
i += 1;
}
false
}
fn nonces_insert(&mut self, nonce: [u8; 2], ts: u64) {
if self.nonce_count < MAX_NONCES {
self.seen_nonces[self.nonce_count] = Some((nonce, ts));
self.nonce_count += 1;
}
}
// ── Mirror methods (identical control flow to auth.rs) ───────────────
/// Mirrors `auth.rs` lines 211-217.
fn check_node(&self, node_id: &KaniNodeId) -> AuthzResult {
if *node_id == self.owner || self.keys_contains(node_id) {
AuthzResult::Allowed
} else {
AuthzResult::Denied(DeniedReason::NotAuthorized)
}
}
/// Mirrors `auth.rs` lines 277-290 (label omitted — irrelevant to auth logic).
fn grant(
&mut self,
requester: &KaniNodeId,
key: KaniNodeId,
) -> Result<(), DeniedReason> {
if *requester != self.owner {
return Err(DeniedReason::NotAuthorized);
}
if key == self.owner {
return Ok(());
}
self.keys_insert(key);
Ok(())
}
/// Mirrors `auth.rs` lines 294-305.
fn revoke(
&mut self,
requester: &KaniNodeId,
key: KaniNodeId,
) -> Result<(), DeniedReason> {
if *requester != self.owner {
return Err(DeniedReason::NotAuthorized);
}
if key != self.owner {
self.keys_remove(&key);
}
Ok(())
}
/// Mirrors `auth.rs` lines 252-273.
/// `sig_valid` replaces the `verify_signed_request` call (crypto stub).
fn check_signed_request(
&mut self,
sig_valid: bool,
public_key: &KaniNodeId,
timestamp: u64,
nonce: [u8; 2],
now: u64,
) -> AuthzResult {
// 1. Signature
if !sig_valid {
return AuthzResult::Denied(DeniedReason::InvalidSignature);
}
// 2. Timestamp freshness
let diff = if now >= timestamp {
now - timestamp
} else {
timestamp - now
};
if diff > self.timestamp_window {
return AuthzResult::Denied(DeniedReason::RequestExpired);
}
// 3. Nonce uniqueness
if self.nonces_contains(&nonce) {
return AuthzResult::Denied(DeniedReason::ReplayDetected);
}
self.nonces_insert(nonce, timestamp);
// 4. ACL check
self.check_node(public_key)
}
/// Mirrors `auth.rs` lines 321-326.
fn gc_nonces(&mut self, now: u64) {
let mut write = 0;
let mut read = 0;
while read < self.nonce_count {
if let Some((nonce, ts)) = self.seen_nonces[read] {
let diff = if now >= ts { now - ts } else { ts - now };
if diff <= self.timestamp_window {
self.seen_nonces[write] = Some((nonce, ts));
write += 1;
}
}
read += 1;
}
let mut clear = write;
while clear < self.nonce_count {
self.seen_nonces[clear] = None;
clear += 1;
}
self.nonce_count = write;
}
}
// ─── Proof harnesses ────────────────────────────────────────────────────────
/// **S5**: If `requester != owner`, both `grant()` and `revoke()` return
/// `Err(NotAuthorized)`. No iteration — simplest harness.
#[kani::proof]
fn proof_s5_non_owner_cannot_mutate_acl() {
let owner = KaniNodeId(kani::any());
let requester = KaniNodeId(kani::any());
let target = KaniNodeId(kani::any());
kani::assume(requester != owner);
let mut engine = KaniAuthzEngine::new(owner);
assert!(engine.grant(&requester, target) == Err(DeniedReason::NotAuthorized));
assert!(engine.revoke(&requester, target) == Err(DeniedReason::NotAuthorized));
}
/// **S1**: `check_node(n) = Allowed` implies `n == owner` or `n` was granted.
#[kani::proof]
#[kani::unwind(5)]
fn proof_s1_check_node() {
let owner = KaniNodeId(kani::any());
let mut engine = KaniAuthzEngine::new(owner);
// Grant 0..MAX_KEYS symbolic keys
let num_keys: usize = kani::any();
kani::assume(num_keys <= MAX_KEYS);
let mut granted = [KaniNodeId([0; 2]); MAX_KEYS];
let mut i = 0;
while i < num_keys {
granted[i] = KaniNodeId(kani::any());
engine.keys_insert(granted[i]);
i += 1;
}
// Query with a symbolic node
let query = KaniNodeId(kani::any());
let result = engine.check_node(&query);
if result == AuthzResult::Allowed {
let mut is_authorized = query == owner;
let mut j = 0;
while j < num_keys {
if query == granted[j] {
is_authorized = true;
}
j += 1;
}
assert!(is_authorized);
}
}
/// **S6**: After any sequence of grant/revoke operations (by any requester),
/// `check_node(owner)` always returns `Allowed`.
#[kani::proof]
#[kani::unwind(7)]
fn proof_s6_owner_irremovable() {
let owner = KaniNodeId(kani::any());
let mut engine = KaniAuthzEngine::new(owner);
const MAX_OPS: usize = 5;
let mut i = 0;
while i < MAX_OPS {
let requester = KaniNodeId(kani::any());
let target = KaniNodeId(kani::any());
let is_grant: bool = kani::any();
if is_grant {
let _ = engine.grant(&requester, target);
} else {
let _ = engine.revoke(&requester, target);
}
i += 1;
}
assert!(engine.check_node(&owner) == AuthzResult::Allowed);
}
/// **S2/S3/S4**: The 4-step signed-request check rejects in strict order.
/// Each denial reason implies the correct preconditions.
#[kani::proof]
#[kani::unwind(4)]
fn proof_signed_request_4step_ordering() {
let owner = KaniNodeId(kani::any());
let mut engine = KaniAuthzEngine::new(owner);
// Optionally grant one key
let has_granted: bool = kani::any();
let granted_key = KaniNodeId(kani::any());
if has_granted {
engine.keys_insert(granted_key);
}
// Optionally pre-insert a nonce (to test replay detection)
let pre_nonce: bool = kani::any();
let nonce: [u8; 2] = kani::any();
if pre_nonce {
let old_ts: u64 = kani::any();
engine.nonces_insert(nonce, old_ts);
}
let sig_valid: bool = kani::any();
let public_key = KaniNodeId(kani::any());
let timestamp: u64 = kani::any();
let now: u64 = kani::any();
let diff = if now >= timestamp {
now - timestamp
} else {
timestamp - now
};
let result = engine.check_signed_request(sig_valid, &public_key, timestamp, nonce, now);
match result {
AuthzResult::Denied(DeniedReason::InvalidSignature) => {
// Step 1 rejected: signature was invalid
assert!(!sig_valid);
}
AuthzResult::Denied(DeniedReason::RequestExpired) => {
// Step 2 rejected: sig valid, but timestamp outside window
assert!(sig_valid);
assert!(diff > 300);
}
AuthzResult::Denied(DeniedReason::ReplayDetected) => {
// Step 3 rejected: sig valid, timestamp fresh, but nonce replayed
assert!(sig_valid);
assert!(diff <= 300);
assert!(pre_nonce);
}
AuthzResult::Denied(DeniedReason::NotAuthorized) => {
// Step 4 rejected: sig valid, timestamp fresh, nonce fresh, not in ACL
assert!(sig_valid);
assert!(diff <= 300);
assert!(public_key != owner);
}
AuthzResult::Allowed => {
// All 4 steps passed
assert!(sig_valid);
assert!(diff <= 300);
assert!(public_key == owner || engine.keys_contains(&public_key));
}
}
}
/// **GC correctness**: Owner access survives GC; expired nonces are freed.
#[kani::proof]
#[kani::unwind(4)]
fn proof_gc_preserves_owner_and_frees_expired() {
let owner = KaniNodeId(kani::any());
let mut engine = KaniAuthzEngine::new(owner);
let nonce: [u8; 2] = kani::any();
let t1: u64 = kani::any();
let t2: u64 = kani::any();
kani::assume(t2 >= t1);
engine.nonces_insert(nonce, t1);
engine.gc_nonces(t2);
// Owner always survives GC
assert!(engine.check_node(&owner) == AuthzResult::Allowed);
// Expired nonces must be freed
if t2 - t1 > 300 {
assert!(!engine.nonces_contains(&nonce));
}
}