microplastinet / src /m6_security /keystore.py
naidusai's picture
Initial deploy: MicroPlastiNet Dash dashboard (synthetic data, honest disclosure)
3a5b233 verified
Raw
History Blame Contribute Delete
3.08 kB
"""Per-station shared-secret keystore with rotation support.
In production this would be backed by AWS Secrets Manager / Azure Key Vault /
HashiCorp Vault. For the reference implementation we use a JSON file with
file-system permissions, which is a reasonable on-prem fallback.
"""
from __future__ import annotations
import json
import os
import secrets
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Optional
class KeyStore:
"""Holds the current and previous secret per station to allow rotation
without dropping in-flight messages.
Each station has:
- current_secret : hex string (32 bytes)
- previous_secret: hex string or None
- rotated_at_utc : ISO timestamp
"""
def __init__(self, path: Path):
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self.path.write_text(json.dumps({"stations": {}}, indent=2))
def _load(self) -> dict:
return json.loads(self.path.read_text())
def _save(self, data: dict) -> None:
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
os.replace(tmp, self.path)
def get_or_create_secret(self, station_id: str) -> bytes:
data = self._load()
st = data["stations"].get(station_id)
if st is None:
secret = secrets.token_hex(32)
data["stations"][station_id] = {
"current_secret": secret,
"previous_secret": None,
"rotated_at_utc": datetime.now(timezone.utc).isoformat(),
}
self._save(data)
return bytes.fromhex(secret)
return bytes.fromhex(st["current_secret"])
def rotate(self, station_id: str) -> bytes:
"""Generate a fresh secret for a station while keeping the old one
accepted for a transition period.
"""
data = self._load()
st = data["stations"].setdefault(station_id, {})
st["previous_secret"] = st.get("current_secret")
st["current_secret"] = secrets.token_hex(32)
st["rotated_at_utc"] = datetime.now(timezone.utc).isoformat()
self._save(data)
return bytes.fromhex(st["current_secret"])
def candidate_secrets(self, station_id: str,
grace_minutes: int = 30) -> list[bytes]:
"""Return [current, previous] if previous is still within the grace
window; otherwise [current] only.
"""
data = self._load()
st = data["stations"].get(station_id)
if st is None:
return []
out = [bytes.fromhex(st["current_secret"])]
prev = st.get("previous_secret")
if prev:
try:
rotated = datetime.fromisoformat(st["rotated_at_utc"])
if datetime.now(timezone.utc) - rotated < timedelta(minutes=grace_minutes):
out.append(bytes.fromhex(prev))
except Exception:
pass
return out