- Remove gitlinks (160000 mode) for chainfire, flaredb, iam - Add workspace contents as regular tracked files - Update flake.nix to use simple paths instead of builtins.fetchGit This resolves the nix build failure where submodule directories appeared empty in the nix store. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
856 lines
25 KiB
Rust
856 lines
25 KiB
Rust
//! Internal token service
|
|
//!
|
|
//! Issues and verifies internal tokens for service-to-service authentication.
|
|
//! Can optionally use TSO (Timestamp Oracle) for consistent timestamps.
|
|
//!
|
|
//! ## Key Rotation
|
|
//!
|
|
//! The service supports multiple signing keys for seamless rotation:
|
|
//! - New tokens are always signed with the "active" key
|
|
//! - Old tokens can be verified with "deprecated" keys during grace period
|
|
//! - "Retired" keys are no longer used for verification
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::RwLock;
|
|
|
|
use iam_types::{
|
|
AuthMethod, Error, IamError, InternalTokenClaims, Principal, Result, Scope,
|
|
};
|
|
|
|
/// Key status for rotation management
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum KeyStatus {
|
|
/// Key is actively used for signing new tokens
|
|
Active,
|
|
/// Key is deprecated but still valid for verification (grace period)
|
|
Deprecated,
|
|
/// Key is retired and no longer valid
|
|
Retired,
|
|
}
|
|
|
|
/// Managed signing key with status and timestamps
|
|
#[derive(Clone)]
|
|
pub struct ManagedKey {
|
|
/// The underlying signing key
|
|
pub key: SigningKey,
|
|
/// Key status
|
|
pub status: KeyStatus,
|
|
/// When the key was created
|
|
pub created_at: u64,
|
|
/// When the key was deprecated (if applicable)
|
|
pub deprecated_at: Option<u64>,
|
|
/// When the key was retired (if applicable)
|
|
pub retired_at: Option<u64>,
|
|
}
|
|
|
|
/// Token signing key
|
|
#[derive(Clone)]
|
|
pub struct SigningKey {
|
|
/// Key ID
|
|
pub kid: String,
|
|
/// Secret key bytes
|
|
secret: Vec<u8>,
|
|
}
|
|
|
|
impl SigningKey {
|
|
/// Create a new signing key
|
|
pub fn new(kid: impl Into<String>, secret: impl Into<Vec<u8>>) -> Self {
|
|
Self {
|
|
kid: kid.into(),
|
|
secret: secret.into(),
|
|
}
|
|
}
|
|
|
|
/// Generate a random signing key
|
|
pub fn generate(kid: impl Into<String>) -> Self {
|
|
use rand::RngCore;
|
|
let mut secret = vec![0u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut secret);
|
|
Self::new(kid, secret)
|
|
}
|
|
|
|
/// Sign data using HMAC-SHA256
|
|
pub fn sign(&self, data: &[u8]) -> Vec<u8> {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
let mut mac =
|
|
HmacSha256::new_from_slice(&self.secret).expect("HMAC can take key of any size");
|
|
mac.update(data);
|
|
mac.finalize().into_bytes().to_vec()
|
|
}
|
|
|
|
/// Verify a signature
|
|
pub fn verify(&self, data: &[u8], signature: &[u8]) -> bool {
|
|
use hmac::{Hmac, Mac};
|
|
use sha2::Sha256;
|
|
|
|
type HmacSha256 = Hmac<Sha256>;
|
|
|
|
let mut mac =
|
|
HmacSha256::new_from_slice(&self.secret).expect("HMAC can take key of any size");
|
|
mac.update(data);
|
|
|
|
mac.verify_slice(signature).is_ok()
|
|
}
|
|
}
|
|
|
|
impl ManagedKey {
|
|
/// Create a new active managed key
|
|
pub fn new_active(key: SigningKey, created_at: u64) -> Self {
|
|
Self {
|
|
key,
|
|
status: KeyStatus::Active,
|
|
created_at,
|
|
deprecated_at: None,
|
|
retired_at: None,
|
|
}
|
|
}
|
|
|
|
/// Deprecate this key
|
|
pub fn deprecate(&mut self, at: u64) {
|
|
if self.status == KeyStatus::Active {
|
|
self.status = KeyStatus::Deprecated;
|
|
self.deprecated_at = Some(at);
|
|
}
|
|
}
|
|
|
|
/// Retire this key
|
|
pub fn retire(&mut self, at: u64) {
|
|
self.status = KeyStatus::Retired;
|
|
self.retired_at = Some(at);
|
|
}
|
|
|
|
/// Check if key can be used for signing (must be Active)
|
|
pub fn can_sign(&self) -> bool {
|
|
self.status == KeyStatus::Active
|
|
}
|
|
|
|
/// Check if key can be used for verification (Active or Deprecated)
|
|
pub fn can_verify(&self) -> bool {
|
|
matches!(self.status, KeyStatus::Active | KeyStatus::Deprecated)
|
|
}
|
|
}
|
|
|
|
/// Configuration for key rotation
|
|
#[derive(Debug, Clone)]
|
|
pub struct KeyRotationConfig {
|
|
/// How long to keep deprecated keys valid for verification
|
|
pub grace_period: Duration,
|
|
/// How often to check for keys to retire
|
|
pub cleanup_interval: Duration,
|
|
/// Prefix for generated key IDs
|
|
pub key_id_prefix: String,
|
|
}
|
|
|
|
impl Default for KeyRotationConfig {
|
|
fn default() -> Self {
|
|
Self {
|
|
grace_period: Duration::from_secs(86400 * 7), // 7 days
|
|
cleanup_interval: Duration::from_secs(3600), // 1 hour
|
|
key_id_prefix: "key".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Key rotation manager
|
|
///
|
|
/// Manages the lifecycle of signing keys:
|
|
/// 1. Generate new keys
|
|
/// 2. Deprecate old keys (enter grace period)
|
|
/// 3. Retire deprecated keys after grace period
|
|
pub struct KeyRotationManager {
|
|
config: KeyRotationConfig,
|
|
/// All managed keys, keyed by kid
|
|
keys: RwLock<HashMap<String, ManagedKey>>,
|
|
/// ID of the currently active key
|
|
active_key_id: RwLock<Option<String>>,
|
|
}
|
|
|
|
impl KeyRotationManager {
|
|
/// Create a new key rotation manager
|
|
pub fn new(config: KeyRotationConfig) -> Self {
|
|
Self {
|
|
config,
|
|
keys: RwLock::new(HashMap::new()),
|
|
active_key_id: RwLock::new(None),
|
|
}
|
|
}
|
|
|
|
/// Create with default config
|
|
pub fn with_defaults() -> Self {
|
|
Self::new(KeyRotationConfig::default())
|
|
}
|
|
|
|
/// Initialize with an existing key
|
|
pub async fn init_with_key(&self, key: SigningKey, created_at: u64) {
|
|
let kid = key.kid.clone();
|
|
let managed = ManagedKey::new_active(key, created_at);
|
|
|
|
let mut keys = self.keys.write().await;
|
|
keys.insert(kid.clone(), managed);
|
|
|
|
let mut active_id = self.active_key_id.write().await;
|
|
*active_id = Some(kid);
|
|
}
|
|
|
|
/// Generate and activate a new key, deprecating the current active key
|
|
pub async fn rotate(&self) -> Result<String> {
|
|
let now = current_timestamp();
|
|
let new_kid = self.generate_key_id();
|
|
let new_key = SigningKey::generate(&new_kid);
|
|
let new_managed = ManagedKey::new_active(new_key, now);
|
|
|
|
let mut keys = self.keys.write().await;
|
|
let mut active_id = self.active_key_id.write().await;
|
|
|
|
// Deprecate the current active key
|
|
if let Some(old_kid) = active_id.as_ref() {
|
|
if let Some(old_key) = keys.get_mut(old_kid) {
|
|
old_key.deprecate(now);
|
|
}
|
|
}
|
|
|
|
// Insert and activate new key
|
|
keys.insert(new_kid.clone(), new_managed);
|
|
*active_id = Some(new_kid.clone());
|
|
|
|
Ok(new_kid)
|
|
}
|
|
|
|
/// Get the active signing key
|
|
pub async fn get_active_key(&self) -> Option<SigningKey> {
|
|
let active_id = self.active_key_id.read().await;
|
|
let keys = self.keys.read().await;
|
|
|
|
active_id
|
|
.as_ref()
|
|
.and_then(|kid| keys.get(kid))
|
|
.filter(|k| k.can_sign())
|
|
.map(|k| k.key.clone())
|
|
}
|
|
|
|
/// Get a key by ID for verification (if it can verify)
|
|
pub async fn get_key_for_verify(&self, kid: &str) -> Option<SigningKey> {
|
|
let keys = self.keys.read().await;
|
|
keys.get(kid)
|
|
.filter(|k| k.can_verify())
|
|
.map(|k| k.key.clone())
|
|
}
|
|
|
|
/// Get all keys that can be used for verification
|
|
pub async fn get_verification_keys(&self) -> Vec<SigningKey> {
|
|
let keys = self.keys.read().await;
|
|
keys.values()
|
|
.filter(|k| k.can_verify())
|
|
.map(|k| k.key.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// Retire keys that have exceeded the grace period
|
|
pub async fn cleanup_expired(&self) -> usize {
|
|
let now = current_timestamp();
|
|
let grace_secs = self.config.grace_period.as_secs();
|
|
let mut retired = 0;
|
|
|
|
let mut keys = self.keys.write().await;
|
|
for managed in keys.values_mut() {
|
|
if managed.status == KeyStatus::Deprecated {
|
|
if let Some(deprecated_at) = managed.deprecated_at {
|
|
// Use >= to allow immediate expiry when grace_period is 0
|
|
if now >= deprecated_at + grace_secs {
|
|
managed.retire(now);
|
|
retired += 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
retired
|
|
}
|
|
|
|
/// Remove retired keys from memory
|
|
pub async fn purge_retired(&self) -> usize {
|
|
let mut keys = self.keys.write().await;
|
|
let before = keys.len();
|
|
keys.retain(|_, v| v.status != KeyStatus::Retired);
|
|
before - keys.len()
|
|
}
|
|
|
|
/// Get key statistics
|
|
pub async fn stats(&self) -> KeyRotationStats {
|
|
let keys = self.keys.read().await;
|
|
let mut active = 0;
|
|
let mut deprecated = 0;
|
|
let mut retired = 0;
|
|
|
|
for key in keys.values() {
|
|
match key.status {
|
|
KeyStatus::Active => active += 1,
|
|
KeyStatus::Deprecated => deprecated += 1,
|
|
KeyStatus::Retired => retired += 1,
|
|
}
|
|
}
|
|
|
|
KeyRotationStats {
|
|
active,
|
|
deprecated,
|
|
retired,
|
|
total: keys.len(),
|
|
}
|
|
}
|
|
|
|
/// Generate a unique key ID
|
|
fn generate_key_id(&self) -> String {
|
|
let timestamp = current_timestamp();
|
|
let mut random = [0u8; 4];
|
|
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut random);
|
|
format!(
|
|
"{}-{}-{}",
|
|
self.config.key_id_prefix,
|
|
timestamp,
|
|
URL_SAFE_NO_PAD.encode(random)
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Key rotation statistics
|
|
#[derive(Debug, Clone)]
|
|
pub struct KeyRotationStats {
|
|
pub active: usize,
|
|
pub deprecated: usize,
|
|
pub retired: usize,
|
|
pub total: usize,
|
|
}
|
|
|
|
/// Get current Unix timestamp
|
|
fn current_timestamp() -> u64 {
|
|
SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()
|
|
}
|
|
|
|
/// Configuration for internal token service
|
|
#[derive(Clone)]
|
|
pub struct InternalTokenConfig {
|
|
/// Signing keys (for rotation support)
|
|
pub signing_keys: Vec<SigningKey>,
|
|
/// Default token TTL
|
|
pub default_ttl: Duration,
|
|
/// Maximum token TTL
|
|
pub max_ttl: Duration,
|
|
/// Token issuer identifier
|
|
pub issuer: String,
|
|
}
|
|
|
|
impl InternalTokenConfig {
|
|
/// Create a new config with a single key
|
|
pub fn new(signing_key: SigningKey, issuer: impl Into<String>) -> Self {
|
|
Self {
|
|
signing_keys: vec![signing_key],
|
|
default_ttl: Duration::from_secs(3600), // 1 hour
|
|
max_ttl: Duration::from_secs(86400 * 7), // 7 days
|
|
issuer: issuer.into(),
|
|
}
|
|
}
|
|
|
|
/// Add a signing key (for rotation)
|
|
pub fn add_key(mut self, key: SigningKey) -> Self {
|
|
self.signing_keys.push(key);
|
|
self
|
|
}
|
|
|
|
/// Set default TTL
|
|
pub fn with_default_ttl(mut self, ttl: Duration) -> Self {
|
|
self.default_ttl = ttl;
|
|
self
|
|
}
|
|
|
|
/// Set maximum TTL
|
|
pub fn with_max_ttl(mut self, ttl: Duration) -> Self {
|
|
self.max_ttl = ttl;
|
|
self
|
|
}
|
|
}
|
|
|
|
/// TSO client trait for timestamp generation
|
|
#[async_trait::async_trait]
|
|
pub trait TsoClient: Send + Sync {
|
|
/// Get current timestamp
|
|
async fn get_timestamp(&self) -> Result<u64>;
|
|
}
|
|
|
|
/// Internal token service
|
|
pub struct InternalTokenService {
|
|
config: InternalTokenConfig,
|
|
tso_client: Option<Arc<dyn TsoClient>>,
|
|
}
|
|
|
|
impl InternalTokenService {
|
|
/// Create a new internal token service
|
|
pub fn new(config: InternalTokenConfig) -> Self {
|
|
Self {
|
|
config,
|
|
tso_client: None,
|
|
}
|
|
}
|
|
|
|
/// Create with TSO client for consistent timestamps
|
|
pub fn with_tso(mut self, client: Arc<dyn TsoClient>) -> Self {
|
|
self.tso_client = Some(client);
|
|
self
|
|
}
|
|
|
|
/// Issue a new internal token
|
|
pub async fn issue(
|
|
&self,
|
|
principal: &Principal,
|
|
roles: Vec<String>,
|
|
scope: Scope,
|
|
ttl: Option<Duration>,
|
|
) -> Result<IssuedToken> {
|
|
let ttl = ttl.unwrap_or(self.config.default_ttl);
|
|
if ttl > self.config.max_ttl {
|
|
return Err(Error::Iam(IamError::InvalidToken(format!(
|
|
"TTL exceeds maximum: {:?} > {:?}",
|
|
ttl, self.config.max_ttl
|
|
))));
|
|
}
|
|
|
|
let now = self.get_timestamp().await?;
|
|
let exp = now + ttl.as_secs();
|
|
|
|
let session_id = generate_session_id();
|
|
|
|
let claims = InternalTokenClaims::new(
|
|
&principal.id,
|
|
principal.kind.clone(),
|
|
&principal.name,
|
|
scope,
|
|
&session_id,
|
|
)
|
|
.with_roles(roles)
|
|
.with_timestamps(now, exp)
|
|
.with_auth_method(AuthMethod::Internal);
|
|
|
|
// Add optional fields
|
|
let claims = match &principal.org_id {
|
|
Some(org) => claims.with_org_id(org),
|
|
None => claims,
|
|
};
|
|
|
|
let claims = match &principal.project_id {
|
|
Some(proj) => claims.with_project_id(proj),
|
|
None => claims,
|
|
};
|
|
|
|
let claims = match &principal.node_id {
|
|
Some(node) => claims.with_node_id(node),
|
|
None => claims,
|
|
};
|
|
|
|
let token = self.encode_token(&claims)?;
|
|
|
|
Ok(IssuedToken {
|
|
token,
|
|
claims,
|
|
expires_at: exp,
|
|
})
|
|
}
|
|
|
|
/// Verify an internal token
|
|
pub async fn verify(&self, token: &str) -> Result<InternalTokenClaims> {
|
|
let claims = self.decode_token(token)?;
|
|
|
|
// Check expiration
|
|
let now = self.get_timestamp().await?;
|
|
if claims.is_expired(now) {
|
|
return Err(Error::Iam(IamError::TokenExpired));
|
|
}
|
|
|
|
Ok(claims)
|
|
}
|
|
|
|
/// Get current timestamp (from TSO or system time)
|
|
async fn get_timestamp(&self) -> Result<u64> {
|
|
match &self.tso_client {
|
|
Some(tso) => tso.get_timestamp().await,
|
|
None => Ok(SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_secs()),
|
|
}
|
|
}
|
|
|
|
/// Encode claims into a signed token
|
|
fn encode_token(&self, claims: &InternalTokenClaims) -> Result<String> {
|
|
let signing_key = self
|
|
.config
|
|
.signing_keys
|
|
.first()
|
|
.ok_or_else(|| Error::Internal("No signing key configured".into()))?;
|
|
|
|
// Serialize claims
|
|
let claims_json =
|
|
serde_json::to_vec(claims).map_err(|e| Error::Serialization(e.to_string()))?;
|
|
|
|
// Create header
|
|
let header = TokenHeader {
|
|
alg: "HS256".into(),
|
|
kid: signing_key.kid.clone(),
|
|
iss: self.config.issuer.clone(),
|
|
};
|
|
let header_json =
|
|
serde_json::to_vec(&header).map_err(|e| Error::Serialization(e.to_string()))?;
|
|
|
|
// Encode parts
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(&header_json);
|
|
let claims_b64 = URL_SAFE_NO_PAD.encode(&claims_json);
|
|
|
|
// Sign
|
|
let signing_input = format!("{}.{}", header_b64, claims_b64);
|
|
let signature = signing_key.sign(signing_input.as_bytes());
|
|
let signature_b64 = URL_SAFE_NO_PAD.encode(&signature);
|
|
|
|
Ok(format!("{}.{}.{}", header_b64, claims_b64, signature_b64))
|
|
}
|
|
|
|
/// Decode and verify a token
|
|
fn decode_token(&self, token: &str) -> Result<InternalTokenClaims> {
|
|
let parts: Vec<&str> = token.split('.').collect();
|
|
if parts.len() != 3 {
|
|
return Err(Error::Iam(IamError::InvalidToken(
|
|
"Invalid token format".into(),
|
|
)));
|
|
}
|
|
|
|
let header_json = URL_SAFE_NO_PAD
|
|
.decode(parts[0])
|
|
.map_err(|e| Error::Iam(IamError::InvalidToken(e.to_string())))?;
|
|
|
|
let header: TokenHeader = serde_json::from_slice(&header_json)
|
|
.map_err(|e| Error::Iam(IamError::InvalidToken(e.to_string())))?;
|
|
|
|
// Find signing key
|
|
let signing_key = self
|
|
.config
|
|
.signing_keys
|
|
.iter()
|
|
.find(|k| k.kid == header.kid)
|
|
.ok_or_else(|| {
|
|
Error::Iam(IamError::InvalidToken(format!(
|
|
"Unknown key ID: {}",
|
|
header.kid
|
|
)))
|
|
})?;
|
|
|
|
// Verify signature
|
|
let signature = URL_SAFE_NO_PAD
|
|
.decode(parts[2])
|
|
.map_err(|e| Error::Iam(IamError::InvalidToken(e.to_string())))?;
|
|
|
|
let signing_input = format!("{}.{}", parts[0], parts[1]);
|
|
if !signing_key.verify(signing_input.as_bytes(), &signature) {
|
|
return Err(Error::Iam(IamError::InvalidToken(
|
|
"Invalid signature".into(),
|
|
)));
|
|
}
|
|
|
|
// Decode claims
|
|
let claims_json = URL_SAFE_NO_PAD
|
|
.decode(parts[1])
|
|
.map_err(|e| Error::Iam(IamError::InvalidToken(e.to_string())))?;
|
|
|
|
let claims: InternalTokenClaims = serde_json::from_slice(&claims_json)
|
|
.map_err(|e| Error::Iam(IamError::InvalidToken(e.to_string())))?;
|
|
|
|
Ok(claims)
|
|
}
|
|
}
|
|
|
|
/// Token header
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct TokenHeader {
|
|
alg: String,
|
|
kid: String,
|
|
iss: String,
|
|
}
|
|
|
|
/// Result of issuing a token
|
|
#[derive(Debug, Clone)]
|
|
pub struct IssuedToken {
|
|
/// The token string
|
|
pub token: String,
|
|
/// The token claims
|
|
pub claims: InternalTokenClaims,
|
|
/// Expiration timestamp (Unix seconds)
|
|
pub expires_at: u64,
|
|
}
|
|
|
|
/// Generate a random session ID
|
|
fn generate_session_id() -> String {
|
|
use rand::RngCore;
|
|
let mut bytes = [0u8; 16];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
URL_SAFE_NO_PAD.encode(bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn test_config() -> InternalTokenConfig {
|
|
let key = SigningKey::generate("test-key-1");
|
|
InternalTokenConfig::new(key, "iam-test")
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_issue_and_verify() {
|
|
let service = InternalTokenService::new(test_config());
|
|
|
|
let principal = Principal::new_user("alice", "Alice Smith");
|
|
let roles = vec!["roles/ProjectAdmin".into()];
|
|
let scope = Scope::project("proj-1", "org-1");
|
|
|
|
let issued = service.issue(&principal, roles, scope, None).await.unwrap();
|
|
|
|
assert!(!issued.token.is_empty());
|
|
assert_eq!(issued.claims.principal_id, "alice");
|
|
|
|
// Verify
|
|
let verified = service.verify(&issued.token).await.unwrap();
|
|
assert_eq!(verified.principal_id, "alice");
|
|
assert_eq!(verified.roles.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_invalid_signature() {
|
|
let service = InternalTokenService::new(test_config());
|
|
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let issued = service
|
|
.issue(&principal, vec![], Scope::System, None)
|
|
.await
|
|
.unwrap();
|
|
|
|
// Tamper with token
|
|
let parts: Vec<&str> = issued.token.split('.').collect();
|
|
let tampered = format!("{}.{}.invalid", parts[0], parts[1]);
|
|
|
|
let result = service.verify(&tampered).await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_ttl_limit() {
|
|
let config = test_config().with_max_ttl(Duration::from_secs(3600));
|
|
let service = InternalTokenService::new(config);
|
|
|
|
let principal = Principal::new_user("alice", "Alice");
|
|
let result = service
|
|
.issue(
|
|
&principal,
|
|
vec![],
|
|
Scope::System,
|
|
Some(Duration::from_secs(86400)), // 24 hours - exceeds max
|
|
)
|
|
.await;
|
|
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_signing_key() {
|
|
let key = SigningKey::generate("test");
|
|
let data = b"hello world";
|
|
|
|
let signature = key.sign(data);
|
|
assert!(key.verify(data, &signature));
|
|
assert!(!key.verify(b"tampered", &signature));
|
|
}
|
|
|
|
// Key rotation tests
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_init() {
|
|
let manager = KeyRotationManager::with_defaults();
|
|
let key = SigningKey::generate("initial-key");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key.clone(), now).await;
|
|
|
|
let active = manager.get_active_key().await;
|
|
assert!(active.is_some());
|
|
assert_eq!(active.unwrap().kid, "initial-key");
|
|
|
|
let stats = manager.stats().await;
|
|
assert_eq!(stats.active, 1);
|
|
assert_eq!(stats.deprecated, 0);
|
|
assert_eq!(stats.total, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_rotate() {
|
|
let manager = KeyRotationManager::with_defaults();
|
|
let key = SigningKey::generate("initial-key");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key.clone(), now).await;
|
|
|
|
// Rotate to new key
|
|
let new_kid = manager.rotate().await.unwrap();
|
|
assert!(new_kid.starts_with("key-"));
|
|
|
|
// Active key should be the new one
|
|
let active = manager.get_active_key().await;
|
|
assert!(active.is_some());
|
|
assert_eq!(active.unwrap().kid, new_kid);
|
|
|
|
// Old key should still be available for verification
|
|
let old_key = manager.get_key_for_verify("initial-key").await;
|
|
assert!(old_key.is_some());
|
|
|
|
let stats = manager.stats().await;
|
|
assert_eq!(stats.active, 1);
|
|
assert_eq!(stats.deprecated, 1);
|
|
assert_eq!(stats.total, 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_verify_with_old_key() {
|
|
let manager = KeyRotationManager::with_defaults();
|
|
let key = SigningKey::generate("initial-key");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key.clone(), now).await;
|
|
|
|
// Sign some data with the active key
|
|
let data = b"test data";
|
|
let signature = manager.get_active_key().await.unwrap().sign(data);
|
|
|
|
// Rotate to new key
|
|
manager.rotate().await.unwrap();
|
|
|
|
// Old key should still be able to verify
|
|
let old_key = manager.get_key_for_verify("initial-key").await.unwrap();
|
|
assert!(old_key.verify(data, &signature));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_multiple_rotations() {
|
|
let manager = KeyRotationManager::with_defaults();
|
|
let key = SigningKey::generate("key-0");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key, now).await;
|
|
|
|
// Rotate 3 times
|
|
for _ in 0..3 {
|
|
manager.rotate().await.unwrap();
|
|
}
|
|
|
|
let stats = manager.stats().await;
|
|
assert_eq!(stats.active, 1);
|
|
assert_eq!(stats.deprecated, 3); // key-0, key-1, key-2 are deprecated
|
|
assert_eq!(stats.total, 4);
|
|
|
|
// All deprecated keys should still be verifiable
|
|
assert!(manager.get_key_for_verify("key-0").await.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_status_transitions() {
|
|
let key = SigningKey::generate("test-key");
|
|
let now = current_timestamp();
|
|
let mut managed = ManagedKey::new_active(key, now);
|
|
|
|
// Initially active
|
|
assert!(managed.can_sign());
|
|
assert!(managed.can_verify());
|
|
|
|
// Deprecate
|
|
managed.deprecate(now);
|
|
assert!(!managed.can_sign());
|
|
assert!(managed.can_verify());
|
|
assert_eq!(managed.status, KeyStatus::Deprecated);
|
|
|
|
// Retire
|
|
managed.retire(now);
|
|
assert!(!managed.can_sign());
|
|
assert!(!managed.can_verify());
|
|
assert_eq!(managed.status, KeyStatus::Retired);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_cleanup_expired() {
|
|
// Use very short grace period for testing
|
|
let config = KeyRotationConfig {
|
|
grace_period: Duration::from_secs(0), // immediate expiry
|
|
cleanup_interval: Duration::from_secs(60),
|
|
key_id_prefix: "test".into(),
|
|
};
|
|
let manager = KeyRotationManager::new(config);
|
|
let key = SigningKey::generate("initial");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key, now).await;
|
|
manager.rotate().await.unwrap();
|
|
|
|
// Wait a moment for grace period to elapse
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
|
|
let retired = manager.cleanup_expired().await;
|
|
assert_eq!(retired, 1);
|
|
|
|
let stats = manager.stats().await;
|
|
assert_eq!(stats.deprecated, 0);
|
|
assert_eq!(stats.retired, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_key_rotation_purge_retired() {
|
|
let config = KeyRotationConfig {
|
|
grace_period: Duration::from_secs(0),
|
|
cleanup_interval: Duration::from_secs(60),
|
|
key_id_prefix: "test".into(),
|
|
};
|
|
let manager = KeyRotationManager::new(config);
|
|
let key = SigningKey::generate("initial");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key, now).await;
|
|
manager.rotate().await.unwrap();
|
|
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
manager.cleanup_expired().await;
|
|
|
|
let purged = manager.purge_retired().await;
|
|
assert_eq!(purged, 1);
|
|
|
|
let stats = manager.stats().await;
|
|
assert_eq!(stats.total, 1);
|
|
assert_eq!(stats.active, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_get_verification_keys() {
|
|
let manager = KeyRotationManager::with_defaults();
|
|
let key = SigningKey::generate("initial");
|
|
let now = current_timestamp();
|
|
|
|
manager.init_with_key(key, now).await;
|
|
manager.rotate().await.unwrap();
|
|
manager.rotate().await.unwrap();
|
|
|
|
let verification_keys = manager.get_verification_keys().await;
|
|
assert_eq!(verification_keys.len(), 3);
|
|
}
|
|
}
|