609 lines
17 KiB
Rust
609 lines
17 KiB
Rust
//! Comprehensive tests for permit token signing and verification
|
|
//!
|
|
//! Tests cover:
|
|
//! - Token creation and signing
|
|
//! - Signature verification
|
|
//! - TTL validation
|
|
//! - Security tests (invalid signatures, replay attacks, tamper detection)
|
|
|
|
use cognitum_gate_tilezero::permit::{
|
|
PermitState, PermitToken, TokenDecodeError, Verifier, VerifyError,
|
|
};
|
|
use cognitum_gate_tilezero::GateDecision;
|
|
|
|
fn create_test_token(action_id: &str, sequence: u64) -> PermitToken {
|
|
PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: action_id.to_string(),
|
|
timestamp: std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos() as u64,
|
|
ttl_ns: 60_000_000_000, // 60 seconds
|
|
witness_hash: [0u8; 32],
|
|
sequence,
|
|
signature: [0u8; 64],
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod token_creation {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_token_fields() {
|
|
let token = create_test_token("test-action", 42);
|
|
|
|
assert_eq!(token.action_id, "test-action");
|
|
assert_eq!(token.sequence, 42);
|
|
assert_eq!(token.decision, GateDecision::Permit);
|
|
assert!(token.timestamp > 0);
|
|
assert_eq!(token.ttl_ns, 60_000_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_with_different_decisions() {
|
|
let permit_token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let defer_token = PermitToken {
|
|
decision: GateDecision::Defer,
|
|
..permit_token.clone()
|
|
};
|
|
|
|
let deny_token = PermitToken {
|
|
decision: GateDecision::Deny,
|
|
..permit_token.clone()
|
|
};
|
|
|
|
assert_eq!(permit_token.decision, GateDecision::Permit);
|
|
assert_eq!(defer_token.decision, GateDecision::Defer);
|
|
assert_eq!(deny_token.decision, GateDecision::Deny);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ttl_validation {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_token_valid_within_ttl() {
|
|
let now_ns = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos() as u64;
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: now_ns,
|
|
ttl_ns: 60_000_000_000, // 60 seconds
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
// Check immediately - should be valid
|
|
assert!(token.is_valid_time(now_ns));
|
|
|
|
// Check 30 seconds later - still valid
|
|
assert!(token.is_valid_time(now_ns + 30_000_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_invalid_after_ttl() {
|
|
let timestamp = 1000000000u64;
|
|
let ttl = 60_000_000_000u64; // 60 seconds
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp,
|
|
ttl_ns: ttl,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
// After TTL expires
|
|
let after_expiry = timestamp + ttl + 1;
|
|
assert!(!token.is_valid_time(after_expiry));
|
|
}
|
|
|
|
#[test]
|
|
fn test_token_valid_at_exactly_expiry() {
|
|
let timestamp = 1000000000u64;
|
|
let ttl = 60_000_000_000u64;
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp,
|
|
ttl_ns: ttl,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
// Exactly at expiry boundary
|
|
let at_expiry = timestamp + ttl;
|
|
assert!(token.is_valid_time(at_expiry));
|
|
}
|
|
|
|
#[test]
|
|
fn test_zero_ttl() {
|
|
let timestamp = 1000000000u64;
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp,
|
|
ttl_ns: 0, // Immediate expiry
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
// Valid at exact timestamp
|
|
assert!(token.is_valid_time(timestamp));
|
|
|
|
// Invalid one nanosecond later
|
|
assert!(!token.is_valid_time(timestamp + 1));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod signing {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_permit_state_creation() {
|
|
let state = PermitState::new();
|
|
// Should be able to get a verifier
|
|
let _verifier = state.verifier();
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_token() {
|
|
let state = PermitState::new();
|
|
let token = create_test_token("test-action", 0);
|
|
|
|
let signed = state.sign_token(token);
|
|
|
|
// MAC should be set (non-zero)
|
|
assert_ne!(signed.signature, [0u8; 64]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_different_tokens_different_macs() {
|
|
let state = PermitState::new();
|
|
|
|
let token1 = create_test_token("action-1", 0);
|
|
let token2 = create_test_token("action-2", 1);
|
|
|
|
let signed1 = state.sign_token(token1);
|
|
let signed2 = state.sign_token(token2);
|
|
|
|
assert_ne!(signed1.signature, signed2.signature);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sign_deterministic() {
|
|
let state = PermitState::new();
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000000000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let signed1 = state.sign_token(token.clone());
|
|
let signed2 = state.sign_token(token);
|
|
|
|
// Same input, same key, same output
|
|
assert_eq!(signed1.signature, signed2.signature);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sequence_incrementing() {
|
|
let state = PermitState::new();
|
|
|
|
let seq1 = state.next_sequence();
|
|
let seq2 = state.next_sequence();
|
|
let seq3 = state.next_sequence();
|
|
|
|
assert_eq!(seq1, 0);
|
|
assert_eq!(seq2, 1);
|
|
assert_eq!(seq3, 2);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod verification {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_verify_signed_token() {
|
|
let state = PermitState::new();
|
|
let verifier = state.verifier();
|
|
|
|
let token = create_test_token("test-action", 0);
|
|
let signed = state.sign_token(token);
|
|
|
|
assert!(verifier.verify(&signed).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_unsigned_token_fails() {
|
|
let state = PermitState::new();
|
|
let verifier = state.verifier();
|
|
|
|
let token = create_test_token("test-action", 0);
|
|
// Token is not signed (signature is zero)
|
|
|
|
// Verification of unsigned token should FAIL
|
|
let result = verifier.verify(&token);
|
|
assert!(result.is_err(), "Unsigned token should fail verification");
|
|
}
|
|
|
|
#[test]
|
|
fn test_verify_full_checks_ttl() {
|
|
let state = PermitState::new();
|
|
let verifier = state.verifier();
|
|
|
|
// Create an already-expired token
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1, // Very old
|
|
ttl_ns: 1, // Very short
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let signed = state.sign_token(token);
|
|
|
|
// Full verification should fail due to expiry
|
|
let result = verifier.verify_full(&signed);
|
|
assert!(matches!(result, Err(VerifyError::Expired)));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod signable_content {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_signable_content_deterministic() {
|
|
let token = create_test_token("test", 42);
|
|
|
|
let content1 = token.signable_content();
|
|
let content2 = token.signable_content();
|
|
|
|
assert_eq!(content1, content2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_signable_content_changes_with_fields() {
|
|
let token1 = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let mut token2 = token1.clone();
|
|
token2.sequence = 1;
|
|
|
|
assert_ne!(token1.signable_content(), token2.signable_content());
|
|
}
|
|
|
|
#[test]
|
|
fn test_signable_content_excludes_mac() {
|
|
let mut token1 = create_test_token("test", 0);
|
|
let mut token2 = token1.clone();
|
|
|
|
token1.signature = [1u8; 64];
|
|
token2.signature = [2u8; 64];
|
|
|
|
// Different MACs but same signable content
|
|
assert_eq!(token1.signable_content(), token2.signable_content());
|
|
}
|
|
|
|
#[test]
|
|
fn test_signable_content_includes_decision() {
|
|
let token_permit = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let token_deny = PermitToken {
|
|
decision: GateDecision::Deny,
|
|
..token_permit.clone()
|
|
};
|
|
|
|
assert_ne!(
|
|
token_permit.signable_content(),
|
|
token_deny.signable_content()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod base64_encoding {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_encode_decode_roundtrip() {
|
|
let token = create_test_token("test-action", 42);
|
|
|
|
let encoded = token.encode_base64();
|
|
let decoded = PermitToken::decode_base64(&encoded).unwrap();
|
|
|
|
assert_eq!(token.action_id, decoded.action_id);
|
|
assert_eq!(token.sequence, decoded.sequence);
|
|
assert_eq!(token.decision, decoded.decision);
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_invalid_base64() {
|
|
let result = PermitToken::decode_base64("not valid base64!!!");
|
|
assert!(matches!(result, Err(TokenDecodeError::InvalidBase64)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_decode_invalid_json() {
|
|
// Valid base64 but not JSON
|
|
let encoded =
|
|
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, b"not json");
|
|
let result = PermitToken::decode_base64(&encoded);
|
|
assert!(matches!(result, Err(TokenDecodeError::InvalidJson)));
|
|
}
|
|
|
|
#[test]
|
|
fn test_signed_token_encode_decode() {
|
|
let state = PermitState::new();
|
|
let token = create_test_token("test", 0);
|
|
let signed = state.sign_token(token);
|
|
|
|
let encoded = signed.encode_base64();
|
|
let decoded = PermitToken::decode_base64(&encoded).unwrap();
|
|
|
|
// MAC should be preserved
|
|
assert_eq!(signed.signature, decoded.signature);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod security_tests {
|
|
use super::*;
|
|
|
|
/// Test that different keys produce different signatures
|
|
#[test]
|
|
fn test_different_keys_different_signatures() {
|
|
let state1 = PermitState::new();
|
|
let state2 = PermitState::new();
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000000000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let signed1 = state1.sign_token(token.clone());
|
|
let signed2 = state2.sign_token(token);
|
|
|
|
assert_ne!(signed1.signature, signed2.signature);
|
|
}
|
|
|
|
/// Test cross-key verification fails
|
|
#[test]
|
|
fn test_cross_key_verification_fails() {
|
|
let state1 = PermitState::new();
|
|
let state2 = PermitState::new();
|
|
let verifier2 = state2.verifier();
|
|
|
|
let token = create_test_token("test", 0);
|
|
let signed = state1.sign_token(token);
|
|
|
|
// Verification with wrong key should FAIL
|
|
let result = verifier2.verify(&signed);
|
|
assert!(result.is_err(), "Cross-key verification should fail");
|
|
}
|
|
|
|
/// Test token tampering detection
|
|
#[test]
|
|
fn test_tamper_detection() {
|
|
let state = PermitState::new();
|
|
let verifier = state.verifier();
|
|
|
|
let token = create_test_token("test", 0);
|
|
let mut signed = state.sign_token(token);
|
|
|
|
// Verify original is valid
|
|
assert!(verifier.verify(&signed).is_ok(), "Original should verify");
|
|
|
|
// Tamper with the action_id
|
|
signed.action_id = "tampered".to_string();
|
|
|
|
// Verification should now FAIL because signature doesn't match
|
|
let result = verifier.verify(&signed);
|
|
assert!(result.is_err(), "Tampered token should fail verification");
|
|
}
|
|
|
|
/// Test replay attack scenario
|
|
#[test]
|
|
fn test_sequence_prevents_replay() {
|
|
let state = PermitState::new();
|
|
|
|
let token1 = create_test_token("test", state.next_sequence());
|
|
let token2 = create_test_token("test", state.next_sequence());
|
|
|
|
let signed1 = state.sign_token(token1);
|
|
let signed2 = state.sign_token(token2);
|
|
|
|
// Different sequences even for same action
|
|
assert_ne!(signed1.sequence, signed2.sequence);
|
|
assert_ne!(signed1.signature, signed2.signature);
|
|
}
|
|
|
|
/// Test witness hash binding
|
|
#[test]
|
|
fn test_witness_hash_binding() {
|
|
let state = PermitState::new();
|
|
|
|
let mut token1 = create_test_token("test", 0);
|
|
token1.witness_hash = [1u8; 32];
|
|
|
|
let mut token2 = create_test_token("test", 0);
|
|
token2.witness_hash = [2u8; 32];
|
|
|
|
let signed1 = state.sign_token(token1);
|
|
let signed2 = state.sign_token(token2);
|
|
|
|
// Different witness hashes produce different signatures
|
|
assert_ne!(signed1.signature, signed2.signature);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod custom_key {
|
|
use super::*;
|
|
use ed25519_dalek::SigningKey;
|
|
use rand::rngs::OsRng;
|
|
|
|
#[test]
|
|
fn test_with_custom_key() {
|
|
let custom_key = SigningKey::generate(&mut OsRng);
|
|
let state = PermitState::with_key(custom_key);
|
|
|
|
let token = create_test_token("test", 0);
|
|
let signed = state.sign_token(token);
|
|
|
|
let verifier = state.verifier();
|
|
assert!(verifier.verify(&signed).is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_same_key_same_signatures() {
|
|
let key_bytes: [u8; 32] = [42u8; 32];
|
|
let key1 = SigningKey::from_bytes(&key_bytes);
|
|
let key2 = SigningKey::from_bytes(&key_bytes);
|
|
|
|
let state1 = PermitState::with_key(key1);
|
|
let state2 = PermitState::with_key(key2);
|
|
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp: 1000000000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let signed1 = state1.sign_token(token.clone());
|
|
let signed2 = state2.sign_token(token);
|
|
|
|
assert_eq!(signed1.signature, signed2.signature);
|
|
}
|
|
}
|
|
|
|
// Property-based tests
|
|
#[cfg(test)]
|
|
mod property_tests {
|
|
use super::*;
|
|
use proptest::prelude::*;
|
|
|
|
proptest! {
|
|
#[test]
|
|
fn prop_encode_decode_roundtrip(
|
|
action_id in "[a-z]{1,20}",
|
|
sequence in 0u64..1000,
|
|
ttl in 1u64..1000000000
|
|
) {
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id,
|
|
timestamp: 1000000000,
|
|
ttl_ns: ttl,
|
|
witness_hash: [0u8; 32],
|
|
sequence,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let encoded = token.encode_base64();
|
|
let decoded = PermitToken::decode_base64(&encoded).unwrap();
|
|
|
|
assert_eq!(token.action_id, decoded.action_id);
|
|
assert_eq!(token.sequence, decoded.sequence);
|
|
}
|
|
|
|
#[test]
|
|
fn prop_ttl_validity(timestamp in 1u64..1000000000000u64, ttl in 1u64..1000000000000u64) {
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id: "test".to_string(),
|
|
timestamp,
|
|
ttl_ns: ttl,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
// Valid at start
|
|
assert!(token.is_valid_time(timestamp));
|
|
|
|
// Valid just before expiry
|
|
if ttl > 1 {
|
|
assert!(token.is_valid_time(timestamp + ttl - 1));
|
|
}
|
|
|
|
// Invalid after expiry
|
|
assert!(!token.is_valid_time(timestamp + ttl + 1));
|
|
}
|
|
|
|
#[test]
|
|
fn prop_signing_adds_mac(action_id in "[a-z]{1,10}") {
|
|
let state = PermitState::new();
|
|
let token = PermitToken {
|
|
decision: GateDecision::Permit,
|
|
action_id,
|
|
timestamp: 1000000000,
|
|
ttl_ns: 60000,
|
|
witness_hash: [0u8; 32],
|
|
sequence: 0,
|
|
signature: [0u8; 64],
|
|
};
|
|
|
|
let signed = state.sign_token(token);
|
|
assert_ne!(signed.signature, [0u8; 64]);
|
|
}
|
|
}
|
|
}
|