swactor/crates/datastore/src/kani_auth.rs
Zachery Aaron Shores-Chmielewski a9bc6ab41f feat: begin formal verification pipeline (#49)
Initial experiments in 'formal verification' of authorization tooling. Current state is not to be trusted, but we are not in a security critical situation, so that is fine.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-23 04:53:04 +00:00

381 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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));
}
}