Coverage for agentos/tools/key_rotation.py: 0%
118 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 00:45 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 00:45 +0800
1"""
2KeyRotation — automated secret key rotation with grace periods and scheduled callbacks.
4Supports:
5 - Schedule-based rotation (interval in seconds)
6 - Manual rotation trigger
7 - Grace period (old key still valid for verification)
8 - Current / pending / expired key states
9 - Rotation hooks (pre_rotate, post_rotate)
10 - Thread-safe
11"""
13from __future__ import annotations
15import secrets
16import threading
17import time
18from collections.abc import Callable
19from dataclasses import dataclass
20from enum import Enum
21from typing import Any
23# ============================================================================
24# Key State
25# ============================================================================
28class KeyState(Enum):
29 CURRENT = "current"
30 PENDING = "pending" # newly rotated, still in grace
31 EXPIRED = "expired"
34@dataclass
35class KeyEntry:
36 key: str
37 state: KeyState
38 created_at: float
39 expires_at: float | None = None
42# ============================================================================
43# KeyRotation
44# ============================================================================
47class KeyRotation:
48 """Automated secret key rotation with grace periods.
50 Usage:
51 kr = KeyRotation(rotation_interval=3600, grace_period=300, key_length=32)
52 kr.start()
54 current = kr.current_key # Use this for signing/encryption
55 active_keys = kr.active_keys # All currently valid keys
57 # When a new key is rotated in:
58 # - current becomes the new key
59 # - old key stays in active_keys during grace period (for validation)
60 # - after grace period expires, old key is removed
61 """
63 def __init__(
64 self,
65 rotation_interval: float = 3600.0,
66 grace_period: float = 300.0,
67 key_length: int = 32,
68 ):
69 if rotation_interval <= 0:
70 raise ValueError("rotation_interval must be positive")
71 if grace_period < 0:
72 raise ValueError("grace_period must be non-negative")
73 self._interval = rotation_interval
74 self._grace_period = grace_period
75 self._key_length = key_length
76 self._keys: list[KeyEntry] = []
77 self._lock = threading.RLock()
78 self._timer: threading.Timer | None = None
79 self._running = False
81 # Hooks
82 self._pre_rotate: list[Callable[[], None]] = []
83 self._post_rotate: list[Callable[[str, str], None]] = [] # (old_key, new_key)
85 # Seed with initial key
86 self._rotate_now()
88 # ---------- Lifecycle ----------
90 def start(self) -> None:
91 with self._lock:
92 if self._running:
93 return
94 self._running = True
95 self._schedule_next()
97 def stop(self) -> None:
98 with self._lock:
99 self._running = False
100 if self._timer:
101 self._timer.cancel()
102 self._timer = None
104 def _schedule_next(self) -> None:
105 with self._lock:
106 if not self._running:
107 return
108 self._timer = threading.Timer(self._interval, self._on_timer)
109 self._timer.daemon = True
110 self._timer.start()
112 def _on_timer(self) -> None:
113 self._rotate_now()
114 self._schedule_next()
116 # ---------- Rotation ----------
118 def rotate(self) -> str:
119 """Manually trigger a rotation. Returns the new key."""
120 return self._rotate_now()
122 def _rotate_now(self) -> str:
123 new_key = secrets.token_hex(self._key_length)
124 now = time.time()
126 with self._lock:
127 old_key = self._keys[0].key if self._keys else None
129 # Notify pre-rotation
130 self._notify_pre_rotate()
132 # Move current → pending (if grace > 0), otherwise expired
133 for entry in self._keys:
134 if entry.state == KeyState.CURRENT:
135 if self._grace_period > 0:
136 entry.state = KeyState.PENDING
137 entry.expires_at = now + self._grace_period
138 else:
139 entry.state = KeyState.EXPIRED
141 # Add new current key
142 new_entry = KeyEntry(key=new_key, state=KeyState.CURRENT, created_at=now)
143 self._keys.insert(0, new_entry)
145 # Clean up expired
146 self._keys = [e for e in self._keys if e.state != KeyState.EXPIRED]
148 # Notify post-rotation
149 self._notify_post_rotate(old_key, new_key)
151 return new_key
153 # ---------- Key Access ----------
155 @property
156 def current_key(self) -> str | None:
157 with self._lock:
158 for entry in self._keys:
159 if entry.state == KeyState.CURRENT:
160 return entry.key
161 return None
163 @property
164 def active_keys(self) -> list[str]:
165 """All currently valid keys (CURRENT + PENDING)."""
166 with self._lock:
167 self._cleanup_expired()
168 return [e.key for e in self._keys if e.state in (KeyState.CURRENT, KeyState.PENDING)]
170 @property
171 def pending_keys(self) -> list[str]:
172 """Keys in grace period only."""
173 with self._lock:
174 self._cleanup_expired()
175 return [e.key for e in self._keys if e.state == KeyState.PENDING]
177 def is_valid(self, key: str) -> bool:
178 """Check if a key is currently valid (CURRENT or PENDING)."""
179 return key in self.active_keys
181 def _cleanup_expired(self) -> None:
182 now = time.time()
183 self._keys = [
184 e
185 for e in self._keys
186 if e.state != KeyState.EXPIRED and (e.expires_at is None or e.expires_at > now)
187 ]
189 # ---------- Hooks ----------
191 def on_pre_rotate(self, callback: Callable[[], None]) -> None:
192 self._pre_rotate.append(callback)
194 def on_post_rotate(self, callback: Callable[[str, str], None]) -> None:
195 self._post_rotate.append(callback)
197 def _notify_pre_rotate(self) -> None:
198 for cb in self._pre_rotate:
199 try:
200 cb()
201 except Exception:
202 pass
204 def _notify_post_rotate(self, old_key: str | None, new_key: str) -> None:
205 for cb in self._post_rotate:
206 try:
207 cb(old_key, new_key)
208 except Exception:
209 pass
211 # ---------- Info ----------
213 @property
214 def stats(self) -> dict[str, Any]:
215 with self._lock:
216 self._cleanup_expired()
217 return {
218 "total_keys": len(self._keys),
219 "current": 1 if any(e.state == KeyState.CURRENT for e in self._keys) else 0,
220 "pending": sum(1 for e in self._keys if e.state == KeyState.PENDING),
221 "rotation_interval": self._interval,
222 "grace_period": self._grace_period,
223 "key_length": self._key_length,
224 "running": self._running,
225 }