289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Durable active-session registry used to revoke signed operator sessions."""
|
|
|
|
import hashlib
|
|
import secrets
|
|
import sqlite3
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
class SessionStoreError(RuntimeError):
|
|
"""Raised when session state cannot be read or changed safely."""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ActiveDevice:
|
|
management_id: str
|
|
device_label: str
|
|
created_at: int
|
|
expires_at: int
|
|
current: bool
|
|
|
|
|
|
class SessionStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
clock: Callable[[], float],
|
|
lock_timeout_seconds: float = 0.1,
|
|
) -> None:
|
|
self.path = Path(path)
|
|
self.clock = clock
|
|
self.lock_timeout_seconds = lock_timeout_seconds
|
|
|
|
@staticmethod
|
|
def _digest(session_id: str) -> str:
|
|
return hashlib.sha256(session_id.encode()).hexdigest()
|
|
|
|
def _connect(self, *, initialize: bool = False) -> sqlite3.Connection:
|
|
try:
|
|
if initialize:
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
connection = sqlite3.connect(
|
|
self.path, timeout=self.lock_timeout_seconds
|
|
)
|
|
else:
|
|
connection = sqlite3.connect(
|
|
f"{self.path.resolve().as_uri()}?mode=rw",
|
|
timeout=self.lock_timeout_seconds,
|
|
uri=True,
|
|
)
|
|
if initialize:
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS active_sessions (
|
|
session_hash TEXT PRIMARY KEY,
|
|
expires_at INTEGER NOT NULL,
|
|
management_id TEXT,
|
|
device_label TEXT,
|
|
created_at INTEGER
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS step_up_grants (
|
|
grant_hash TEXT PRIMARY KEY,
|
|
session_hash TEXT NOT NULL,
|
|
action TEXT NOT NULL,
|
|
target TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
)
|
|
"""
|
|
)
|
|
connection.execute(
|
|
"CREATE INDEX IF NOT EXISTS step_up_grants_session_hash "
|
|
"ON step_up_grants(session_hash)"
|
|
)
|
|
columns = {
|
|
row[1] for row in connection.execute("PRAGMA table_info(active_sessions)")
|
|
}
|
|
additions = {
|
|
"management_id": "TEXT",
|
|
"device_label": "TEXT",
|
|
"created_at": "INTEGER",
|
|
}
|
|
for name, column_type in additions.items():
|
|
if name not in columns:
|
|
connection.execute(
|
|
f"ALTER TABLE active_sessions ADD COLUMN {name} {column_type}"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET management_id = lower(hex(randomblob(16))) "
|
|
"WHERE management_id IS NULL"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET device_label = 'Existing device' "
|
|
"WHERE device_label IS NULL"
|
|
)
|
|
connection.execute(
|
|
"UPDATE active_sessions SET created_at = ? WHERE created_at IS NULL",
|
|
(int(self.clock()),),
|
|
)
|
|
connection.execute(
|
|
"CREATE UNIQUE INDEX IF NOT EXISTS active_sessions_management_id "
|
|
"ON active_sessions(management_id)"
|
|
)
|
|
return connection
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def activate(
|
|
self, session_id: str, expires_at: int, *, device_label: str = "This device"
|
|
) -> None:
|
|
label = " ".join(str(device_label).split())[:64] or "This device"
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute(
|
|
"DELETE FROM active_sessions WHERE expires_at <= ?", (now,)
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO active_sessions("
|
|
"session_hash, expires_at, management_id, device_label, created_at"
|
|
") VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
self._digest(session_id),
|
|
expires_at,
|
|
secrets.token_urlsafe(18),
|
|
label,
|
|
now,
|
|
),
|
|
)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def is_active(self, session_id: str, expires_at: int) -> bool:
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT expires_at FROM active_sessions WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
).fetchone()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return row is not None and row[0] == expires_at and expires_at > now
|
|
|
|
def revoke(self, session_id: str) -> None:
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
)
|
|
connection.execute(
|
|
"DELETE FROM active_sessions WHERE session_hash = ?",
|
|
(self._digest(session_id),),
|
|
)
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def list_active(self, current_session_id: str) -> list[ActiveDevice]:
|
|
now = int(self.clock())
|
|
current_hash = self._digest(current_session_id)
|
|
try:
|
|
with self._connect() as connection:
|
|
rows = connection.execute(
|
|
"SELECT management_id, device_label, created_at, expires_at, session_hash "
|
|
"FROM active_sessions WHERE expires_at > ? "
|
|
"ORDER BY expires_at DESC, created_at DESC",
|
|
(now,),
|
|
).fetchall()
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return [
|
|
ActiveDevice(
|
|
management_id=row[0],
|
|
device_label=row[1],
|
|
created_at=row[2],
|
|
expires_at=row[3],
|
|
current=secrets.compare_digest(row[4], current_hash),
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def revoke_managed(self, management_id: str) -> bool:
|
|
try:
|
|
with self._connect() as connection:
|
|
row = connection.execute(
|
|
"SELECT session_hash FROM active_sessions WHERE management_id = ?",
|
|
(management_id,),
|
|
).fetchone()
|
|
if row is None:
|
|
return False
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],)
|
|
)
|
|
cursor = connection.execute(
|
|
"DELETE FROM active_sessions WHERE management_id = ?", (management_id,)
|
|
)
|
|
return cursor.rowcount == 1
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def revoke_all(self) -> None:
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
connection.execute("DELETE FROM step_up_grants")
|
|
connection.execute("DELETE FROM active_sessions")
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
|
|
def mint_step_up(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
action: str,
|
|
target: str,
|
|
ttl_seconds: int,
|
|
) -> str:
|
|
now = int(self.clock())
|
|
grant = secrets.token_urlsafe(32)
|
|
session_hash = self._digest(session_id)
|
|
try:
|
|
with self._connect(initialize=True) as connection:
|
|
active = connection.execute(
|
|
"SELECT 1 FROM active_sessions "
|
|
"WHERE session_hash = ? AND expires_at > ?",
|
|
(session_hash, now),
|
|
).fetchone()
|
|
if active is None:
|
|
raise SessionStoreError("Session is no longer active")
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO step_up_grants("
|
|
"grant_hash, session_hash, action, target, expires_at"
|
|
") VALUES (?, ?, ?, ?, ?)",
|
|
(
|
|
self._digest(grant),
|
|
session_hash,
|
|
action,
|
|
target,
|
|
now + max(1, ttl_seconds),
|
|
),
|
|
)
|
|
except SessionStoreError:
|
|
raise
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
|
return grant
|
|
|
|
def consume_step_up(
|
|
self,
|
|
grant: str,
|
|
session_id: str,
|
|
*,
|
|
action: str,
|
|
target: str,
|
|
) -> bool:
|
|
now = int(self.clock())
|
|
try:
|
|
with self._connect() as connection:
|
|
connection.execute(
|
|
"DELETE FROM step_up_grants WHERE expires_at <= ?", (now,)
|
|
)
|
|
cursor = connection.execute(
|
|
"DELETE FROM step_up_grants WHERE grant_hash = ? "
|
|
"AND session_hash = ? AND action = ? AND target = ? "
|
|
"AND expires_at > ? AND EXISTS ("
|
|
"SELECT 1 FROM active_sessions "
|
|
"WHERE active_sessions.session_hash = step_up_grants.session_hash "
|
|
"AND active_sessions.expires_at > ?)",
|
|
(
|
|
self._digest(grant),
|
|
self._digest(session_id),
|
|
action,
|
|
target,
|
|
now,
|
|
now,
|
|
),
|
|
)
|
|
return cursor.rowcount == 1
|
|
except (OSError, sqlite3.Error) as exc:
|
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|