loki-mode 5.35.0 → 5.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +317 -30
- package/autonomy/run.sh +328 -7
- package/autonomy/sandbox.sh +1 -1
- package/autonomy/serve.sh +25 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +9 -5
- package/dashboard/auth.py +189 -22
- package/dashboard/requirements.txt +7 -7
- package/dashboard/secrets.py +152 -0
- package/dashboard/server.py +280 -23
- package/docs/INSTALLATION.md +1 -1
- package/package.json +1 -1
package/dashboard/auth.py
CHANGED
|
@@ -4,13 +4,19 @@ Optional Authentication Module for Loki Mode Dashboard.
|
|
|
4
4
|
Enterprise feature - disabled by default.
|
|
5
5
|
Enable with LOKI_ENTERPRISE_AUTH=true environment variable.
|
|
6
6
|
|
|
7
|
+
OIDC/SSO support (optional) - enable with LOKI_OIDC_ISSUER + LOKI_OIDC_CLIENT_ID.
|
|
8
|
+
Supports enterprise SSO providers (Okta, Azure AD, Google Workspace).
|
|
9
|
+
|
|
7
10
|
Token storage: ~/.loki/dashboard/tokens.json
|
|
8
11
|
"""
|
|
9
12
|
|
|
13
|
+
import base64
|
|
10
14
|
import hashlib
|
|
11
15
|
import json
|
|
12
16
|
import os
|
|
13
17
|
import secrets
|
|
18
|
+
import time
|
|
19
|
+
import urllib.request
|
|
14
20
|
from datetime import datetime, timezone
|
|
15
21
|
from pathlib import Path
|
|
16
22
|
from typing import Optional
|
|
@@ -23,6 +29,24 @@ ENTERPRISE_AUTH_ENABLED = os.environ.get("LOKI_ENTERPRISE_AUTH", "").lower() in
|
|
|
23
29
|
TOKEN_DIR = Path.home() / ".loki" / "dashboard"
|
|
24
30
|
TOKEN_FILE = TOKEN_DIR / "tokens.json"
|
|
25
31
|
|
|
32
|
+
# OIDC Configuration (optional - disabled by default)
|
|
33
|
+
OIDC_ISSUER = os.environ.get("LOKI_OIDC_ISSUER", "") # e.g., https://accounts.google.com
|
|
34
|
+
OIDC_CLIENT_ID = os.environ.get("LOKI_OIDC_CLIENT_ID", "")
|
|
35
|
+
OIDC_AUDIENCE = os.environ.get("LOKI_OIDC_AUDIENCE", "") # Usually same as client_id
|
|
36
|
+
OIDC_ENABLED = bool(OIDC_ISSUER and OIDC_CLIENT_ID)
|
|
37
|
+
|
|
38
|
+
if OIDC_ENABLED:
|
|
39
|
+
import logging as _logging
|
|
40
|
+
_logging.getLogger("loki.auth").warning(
|
|
41
|
+
"OIDC/SSO enabled (EXPERIMENTAL). Claims-based validation only -- "
|
|
42
|
+
"JWT signatures are NOT cryptographically verified. Install PyJWT + "
|
|
43
|
+
"cryptography for production signature verification."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# OIDC JWKS cache (issuer URL -> (keys_dict, fetch_timestamp))
|
|
47
|
+
_oidc_jwks_cache = {} # type: dict[str, tuple[dict, float]]
|
|
48
|
+
_OIDC_CACHE_TTL = 3600 # Cache JWKS for 1 hour
|
|
49
|
+
|
|
26
50
|
# Security scheme (optional)
|
|
27
51
|
security = HTTPBearer(auto_error=False)
|
|
28
52
|
|
|
@@ -53,9 +77,20 @@ def _save_tokens(tokens: dict) -> None:
|
|
|
53
77
|
json.dump(tokens, f, indent=2, default=str)
|
|
54
78
|
|
|
55
79
|
|
|
56
|
-
def _hash_token(token: str) -> str:
|
|
57
|
-
"""Hash a token for storage.
|
|
58
|
-
|
|
80
|
+
def _hash_token(token: str, salt: str = None) -> tuple[str, str]:
|
|
81
|
+
"""Hash a token for storage with a per-token random salt.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
token: The raw token string to hash.
|
|
85
|
+
salt: Optional salt. If None, a new random salt is generated.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (hex_digest, salt).
|
|
89
|
+
"""
|
|
90
|
+
if salt is None:
|
|
91
|
+
salt = secrets.token_hex(16)
|
|
92
|
+
digest = hashlib.sha256((salt + token).encode()).hexdigest()
|
|
93
|
+
return digest, salt
|
|
59
94
|
|
|
60
95
|
|
|
61
96
|
def _constant_time_compare(a: str, b: str) -> bool:
|
|
@@ -94,7 +129,7 @@ def generate_token(
|
|
|
94
129
|
|
|
95
130
|
# Generate secure random token
|
|
96
131
|
raw_token = f"loki_{secrets.token_urlsafe(32)}"
|
|
97
|
-
token_hash = _hash_token(raw_token)
|
|
132
|
+
token_hash, token_salt = _hash_token(raw_token)
|
|
98
133
|
token_id = token_hash[:12]
|
|
99
134
|
|
|
100
135
|
tokens = _load_tokens()
|
|
@@ -114,6 +149,7 @@ def generate_token(
|
|
|
114
149
|
"id": token_id,
|
|
115
150
|
"name": name,
|
|
116
151
|
"hash": token_hash,
|
|
152
|
+
"salt": token_salt,
|
|
117
153
|
"scopes": scopes or ["*"],
|
|
118
154
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
119
155
|
"expires_at": expires_at,
|
|
@@ -229,11 +265,12 @@ def validate_token(raw_token: str) -> Optional[dict]:
|
|
|
229
265
|
if not raw_token or not raw_token.startswith("loki_"):
|
|
230
266
|
return None
|
|
231
267
|
|
|
232
|
-
token_hash = _hash_token(raw_token)
|
|
233
268
|
tokens = _load_tokens()
|
|
234
269
|
|
|
235
270
|
# Find matching token (using constant-time comparison to prevent timing attacks)
|
|
236
271
|
for token in tokens["tokens"].values():
|
|
272
|
+
stored_salt = token.get("salt", "")
|
|
273
|
+
token_hash, _ = _hash_token(raw_token, salt=stored_salt)
|
|
237
274
|
if _constant_time_compare(token["hash"], token_hash):
|
|
238
275
|
# Check if revoked
|
|
239
276
|
if token.get("revoked"):
|
|
@@ -273,6 +310,121 @@ def has_scope(token_info: dict, required_scope: str) -> bool:
|
|
|
273
310
|
return "*" in scopes or required_scope in scopes
|
|
274
311
|
|
|
275
312
|
|
|
313
|
+
# ---------------------------------------------------------------------------
|
|
314
|
+
# OIDC / SSO Support (optional - disabled by default)
|
|
315
|
+
# ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _get_oidc_config() -> dict:
|
|
319
|
+
"""Fetch OIDC discovery document from the issuer.
|
|
320
|
+
|
|
321
|
+
Results are not cached here; callers should use _get_jwks() which
|
|
322
|
+
handles caching internally.
|
|
323
|
+
"""
|
|
324
|
+
if not OIDC_ISSUER:
|
|
325
|
+
return {}
|
|
326
|
+
url = f"{OIDC_ISSUER.rstrip('/')}/.well-known/openid-configuration"
|
|
327
|
+
try:
|
|
328
|
+
with urllib.request.urlopen(url, timeout=10) as resp:
|
|
329
|
+
return json.loads(resp.read())
|
|
330
|
+
except Exception:
|
|
331
|
+
return {}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _get_jwks() -> dict:
|
|
335
|
+
"""Fetch and cache JWKS keys from the OIDC provider.
|
|
336
|
+
|
|
337
|
+
Keys are cached for 1 hour (controlled by _OIDC_CACHE_TTL).
|
|
338
|
+
"""
|
|
339
|
+
global _oidc_jwks_cache
|
|
340
|
+
now = time.time()
|
|
341
|
+
|
|
342
|
+
cached = _oidc_jwks_cache.get(OIDC_ISSUER)
|
|
343
|
+
if cached:
|
|
344
|
+
keys, fetched_at = cached
|
|
345
|
+
if now - fetched_at < _OIDC_CACHE_TTL:
|
|
346
|
+
return keys
|
|
347
|
+
|
|
348
|
+
config = _get_oidc_config()
|
|
349
|
+
jwks_uri = config.get("jwks_uri")
|
|
350
|
+
if not jwks_uri:
|
|
351
|
+
return {"keys": []}
|
|
352
|
+
try:
|
|
353
|
+
with urllib.request.urlopen(jwks_uri, timeout=10) as resp:
|
|
354
|
+
keys = json.loads(resp.read())
|
|
355
|
+
_oidc_jwks_cache[OIDC_ISSUER] = (keys, now)
|
|
356
|
+
return keys
|
|
357
|
+
except Exception:
|
|
358
|
+
return {"keys": []}
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _base64url_decode(data: str) -> bytes:
|
|
362
|
+
"""Decode base64url-encoded data with padding correction."""
|
|
363
|
+
padding = 4 - len(data) % 4
|
|
364
|
+
if padding != 4:
|
|
365
|
+
data += "=" * padding
|
|
366
|
+
return base64.urlsafe_b64decode(data)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def validate_oidc_token(token_str: str) -> Optional[dict]:
|
|
370
|
+
"""Validate an OIDC JWT token.
|
|
371
|
+
|
|
372
|
+
Returns decoded user info dict if valid, None if invalid.
|
|
373
|
+
|
|
374
|
+
This is a claims-based validation that checks:
|
|
375
|
+
- Token structure (3 base64url-encoded parts)
|
|
376
|
+
- Issuer matches OIDC_ISSUER
|
|
377
|
+
- Audience matches OIDC_AUDIENCE or OIDC_CLIENT_ID
|
|
378
|
+
- Token is not expired
|
|
379
|
+
|
|
380
|
+
NOTE: Full cryptographic signature verification requires an RSA
|
|
381
|
+
library (e.g., PyJWT + cryptography). This implementation validates
|
|
382
|
+
claims and relies on HTTPS transport security for the token. For
|
|
383
|
+
production deployments with untrusted networks, consider adding
|
|
384
|
+
PyJWT for full signature verification.
|
|
385
|
+
"""
|
|
386
|
+
if not OIDC_ENABLED:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
parts = token_str.split(".")
|
|
391
|
+
if len(parts) != 3:
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
# Decode payload (claims)
|
|
395
|
+
claims = json.loads(_base64url_decode(parts[1]))
|
|
396
|
+
|
|
397
|
+
# Validate issuer
|
|
398
|
+
if claims.get("iss") != OIDC_ISSUER:
|
|
399
|
+
return None
|
|
400
|
+
|
|
401
|
+
# Validate audience
|
|
402
|
+
aud = claims.get("aud")
|
|
403
|
+
expected_aud = OIDC_AUDIENCE or OIDC_CLIENT_ID
|
|
404
|
+
if isinstance(aud, list):
|
|
405
|
+
if expected_aud not in aud:
|
|
406
|
+
return None
|
|
407
|
+
elif aud != expected_aud:
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
# Validate expiration
|
|
411
|
+
exp = claims.get("exp")
|
|
412
|
+
if exp and datetime.now(timezone.utc).timestamp() > exp:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
# Return user info from claims
|
|
416
|
+
return {
|
|
417
|
+
"id": claims.get("sub", ""),
|
|
418
|
+
"name": claims.get("name", claims.get("email", claims.get("sub", ""))),
|
|
419
|
+
"email": claims.get("email", ""),
|
|
420
|
+
"scopes": ["*"], # OIDC users get full access
|
|
421
|
+
"auth_method": "oidc",
|
|
422
|
+
"issuer": claims.get("iss"),
|
|
423
|
+
}
|
|
424
|
+
except Exception:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
276
428
|
# FastAPI dependency for optional auth
|
|
277
429
|
async def get_current_token(
|
|
278
430
|
request: Request,
|
|
@@ -281,33 +433,43 @@ async def get_current_token(
|
|
|
281
433
|
"""
|
|
282
434
|
FastAPI dependency for optional token authentication.
|
|
283
435
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
436
|
+
Supports two auth methods (tried in order):
|
|
437
|
+
1. OIDC/SSO (when LOKI_OIDC_ISSUER + LOKI_OIDC_CLIENT_ID are set)
|
|
438
|
+
2. Token auth (when LOKI_ENTERPRISE_AUTH=true)
|
|
287
439
|
|
|
288
|
-
When
|
|
440
|
+
When neither is enabled:
|
|
289
441
|
- Returns None (allows anonymous access)
|
|
290
442
|
"""
|
|
291
|
-
if not ENTERPRISE_AUTH_ENABLED:
|
|
292
|
-
#
|
|
443
|
+
if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
|
|
444
|
+
# No auth configured - allow anonymous
|
|
293
445
|
return None
|
|
294
446
|
|
|
295
447
|
if not credentials:
|
|
296
448
|
raise HTTPException(
|
|
297
449
|
status_code=401,
|
|
298
|
-
detail="Authentication required
|
|
450
|
+
detail="Authentication required",
|
|
299
451
|
headers={"WWW-Authenticate": "Bearer"},
|
|
300
452
|
)
|
|
301
453
|
|
|
302
|
-
|
|
303
|
-
if not token_info:
|
|
304
|
-
raise HTTPException(
|
|
305
|
-
status_code=401,
|
|
306
|
-
detail="Invalid, expired, or revoked token",
|
|
307
|
-
headers={"WWW-Authenticate": "Bearer"},
|
|
308
|
-
)
|
|
454
|
+
token_str = credentials.credentials
|
|
309
455
|
|
|
310
|
-
|
|
456
|
+
# Try OIDC first (JWTs are typically longer and don't start with loki_)
|
|
457
|
+
if OIDC_ENABLED and not token_str.startswith("loki_"):
|
|
458
|
+
oidc_result = validate_oidc_token(token_str)
|
|
459
|
+
if oidc_result:
|
|
460
|
+
return oidc_result
|
|
461
|
+
|
|
462
|
+
# Fall back to token auth
|
|
463
|
+
if ENTERPRISE_AUTH_ENABLED:
|
|
464
|
+
token_info = validate_token(token_str)
|
|
465
|
+
if token_info:
|
|
466
|
+
return token_info
|
|
467
|
+
|
|
468
|
+
raise HTTPException(
|
|
469
|
+
status_code=401,
|
|
470
|
+
detail="Invalid, expired, or revoked token",
|
|
471
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
472
|
+
)
|
|
311
473
|
|
|
312
474
|
|
|
313
475
|
def require_scope(scope: str):
|
|
@@ -318,7 +480,7 @@ def require_scope(scope: str):
|
|
|
318
480
|
@app.get("/admin", dependencies=[Depends(require_scope("admin"))])
|
|
319
481
|
"""
|
|
320
482
|
async def check_scope(token_info: Optional[dict] = Security(get_current_token)):
|
|
321
|
-
if not ENTERPRISE_AUTH_ENABLED:
|
|
483
|
+
if not ENTERPRISE_AUTH_ENABLED and not OIDC_ENABLED:
|
|
322
484
|
return # No auth required
|
|
323
485
|
|
|
324
486
|
if not token_info:
|
|
@@ -334,5 +496,10 @@ def require_scope(scope: str):
|
|
|
334
496
|
|
|
335
497
|
|
|
336
498
|
def is_enterprise_mode() -> bool:
|
|
337
|
-
"""Check if enterprise mode is enabled."""
|
|
499
|
+
"""Check if enterprise mode is enabled (token auth or OIDC)."""
|
|
338
500
|
return ENTERPRISE_AUTH_ENABLED
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def is_oidc_mode() -> bool:
|
|
504
|
+
"""Check if OIDC/SSO authentication is enabled."""
|
|
505
|
+
return OIDC_ENABLED
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Loki Mode Dashboard Dependencies
|
|
2
|
-
fastapi
|
|
3
|
-
uvicorn[standard]
|
|
4
|
-
sqlalchemy
|
|
5
|
-
aiosqlite
|
|
6
|
-
greenlet
|
|
7
|
-
pydantic
|
|
8
|
-
websockets
|
|
2
|
+
fastapi==0.115.6
|
|
3
|
+
uvicorn[standard]==0.34.0
|
|
4
|
+
sqlalchemy==2.0.36
|
|
5
|
+
aiosqlite==0.20.0
|
|
6
|
+
greenlet==3.1.1
|
|
7
|
+
pydantic==2.10.4
|
|
8
|
+
websockets==14.1
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secret management utilities for Loki Mode.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- API key validation (format checks, not auth checks)
|
|
6
|
+
- Credential rotation detection
|
|
7
|
+
- Secure environment loading with masking
|
|
8
|
+
- Secret file support (Docker/K8s secret mounts)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
# Known API key patterns for format validation
|
|
19
|
+
_KEY_PATTERNS = {
|
|
20
|
+
"ANTHROPIC_API_KEY": re.compile(r"^sk-ant-[a-zA-Z0-9_-]{90,}$"),
|
|
21
|
+
"OPENAI_API_KEY": re.compile(r"^sk-[a-zA-Z0-9_-]{40,}$"),
|
|
22
|
+
"GOOGLE_API_KEY": re.compile(r"^AI[a-zA-Z0-9_-]{30,}$"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Secret file mount paths (Docker/K8s convention)
|
|
26
|
+
_SECRET_MOUNT_PATHS = [
|
|
27
|
+
"/run/secrets", # Docker secrets
|
|
28
|
+
"/var/run/secrets", # K8s secrets
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_api_key(key_name: str, key_value: str) -> dict:
|
|
33
|
+
"""Validate an API key format (not authentication).
|
|
34
|
+
|
|
35
|
+
Returns dict with: valid (bool), masked (str), warning (str or None)
|
|
36
|
+
"""
|
|
37
|
+
if not key_value:
|
|
38
|
+
return {"valid": False, "masked": "", "warning": "Key is empty"}
|
|
39
|
+
|
|
40
|
+
masked = key_value[:8] + "..." + key_value[-4:] if len(key_value) > 16 else "***"
|
|
41
|
+
|
|
42
|
+
pattern = _KEY_PATTERNS.get(key_name)
|
|
43
|
+
if pattern and not pattern.match(key_value):
|
|
44
|
+
return {
|
|
45
|
+
"valid": False,
|
|
46
|
+
"masked": masked,
|
|
47
|
+
"warning": f"Key format does not match expected pattern for {key_name}",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {"valid": True, "masked": masked, "warning": None}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_secret_from_file(key_name: str) -> Optional[str]:
|
|
54
|
+
"""Load a secret from Docker/K8s secret mount paths.
|
|
55
|
+
|
|
56
|
+
Checks /run/secrets/{key_name} and /var/run/secrets/{key_name}.
|
|
57
|
+
Returns the secret value or None.
|
|
58
|
+
"""
|
|
59
|
+
lower_name = key_name.lower()
|
|
60
|
+
for mount_path in _SECRET_MOUNT_PATHS:
|
|
61
|
+
secret_file = Path(mount_path) / lower_name
|
|
62
|
+
if secret_file.exists():
|
|
63
|
+
try:
|
|
64
|
+
return secret_file.read_text().strip()
|
|
65
|
+
except (PermissionError, IOError):
|
|
66
|
+
pass
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_secrets() -> dict:
|
|
71
|
+
"""Load all API keys with secret file fallback.
|
|
72
|
+
|
|
73
|
+
Priority: Environment variable > Secret file mount > None
|
|
74
|
+
Returns dict of key_name -> {source, set, masked, valid_format, warning}
|
|
75
|
+
"""
|
|
76
|
+
keys = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]
|
|
77
|
+
result = {}
|
|
78
|
+
|
|
79
|
+
for key_name in keys:
|
|
80
|
+
env_value = os.environ.get(key_name, "")
|
|
81
|
+
file_value = load_secret_from_file(key_name)
|
|
82
|
+
|
|
83
|
+
if env_value:
|
|
84
|
+
value = env_value
|
|
85
|
+
source = "environment"
|
|
86
|
+
elif file_value:
|
|
87
|
+
value = file_value
|
|
88
|
+
source = "secret_file"
|
|
89
|
+
# Set in environment for child processes
|
|
90
|
+
os.environ[key_name] = value
|
|
91
|
+
else:
|
|
92
|
+
value = ""
|
|
93
|
+
source = "not_set"
|
|
94
|
+
|
|
95
|
+
validation = validate_api_key(key_name, value)
|
|
96
|
+
result[key_name] = {
|
|
97
|
+
"source": source,
|
|
98
|
+
"set": bool(value),
|
|
99
|
+
"masked": validation["masked"],
|
|
100
|
+
"valid_format": validation["valid"],
|
|
101
|
+
"warning": validation["warning"],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_key_fingerprint(key_value: str) -> str:
|
|
108
|
+
"""Get a stable fingerprint for a key (for rotation detection).
|
|
109
|
+
|
|
110
|
+
Uses first 8 chars of SHA-256 hash. Safe to log/store.
|
|
111
|
+
"""
|
|
112
|
+
if not key_value:
|
|
113
|
+
return ""
|
|
114
|
+
return hashlib.sha256(key_value.encode()).hexdigest()[:8]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def check_rotation(state_file: str = ".loki/state/key-fingerprints.json") -> list:
|
|
118
|
+
"""Check if any API keys have been rotated since last check.
|
|
119
|
+
|
|
120
|
+
Compares current key fingerprints against stored fingerprints.
|
|
121
|
+
Returns list of rotated key names.
|
|
122
|
+
"""
|
|
123
|
+
state_path = Path(state_file)
|
|
124
|
+
|
|
125
|
+
# Get current fingerprints
|
|
126
|
+
current = {}
|
|
127
|
+
for key_name in ["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GOOGLE_API_KEY"]:
|
|
128
|
+
value = os.environ.get(key_name, "")
|
|
129
|
+
if value:
|
|
130
|
+
current[key_name] = get_key_fingerprint(value)
|
|
131
|
+
|
|
132
|
+
# Load previous fingerprints
|
|
133
|
+
previous = {}
|
|
134
|
+
if state_path.exists():
|
|
135
|
+
try:
|
|
136
|
+
previous = json.loads(state_path.read_text())
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
# Detect rotations
|
|
141
|
+
rotated = []
|
|
142
|
+
for key_name, fp in current.items():
|
|
143
|
+
prev_fp = previous.get(key_name)
|
|
144
|
+
if prev_fp and prev_fp != fp:
|
|
145
|
+
rotated.append(key_name)
|
|
146
|
+
|
|
147
|
+
# Save current fingerprints
|
|
148
|
+
if current:
|
|
149
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
150
|
+
state_path.write_text(json.dumps(current, indent=2))
|
|
151
|
+
|
|
152
|
+
return rotated
|