Files

434 lines
18 KiB
Python

"""Almacenamiento de identidades, tokens (solo hashes SHA-256) y ajustes por
tarjeta. SQLite de biblioteca estándar, sin ORM.
Reglas:
- Nunca se guardan tokens en claro: solo SHA-256 del token aleatorio.
- Máximo un token actual + un token anterior con caducidad explícita.
- Los cambios de ajustes se escriben en transacción antes de confirmarse.
"""
import hashlib
import hmac
import json
import math
import os
import secrets
import sqlite3
import threading
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from app.config import (
DATABASE_PATH, MAX_DEVICES, PCB_VERSIONS, DEFAULT_PCB_VERSION,
PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, TOKEN_GRACE_HOURS, is_valid_device_id,
)
_TS_FMT = "%Y-%m-%d %H:%M:%S"
class StoreError(Exception):
"""Error de estado/validación del almacén (sin detalle de rutas)."""
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(dt: datetime) -> str:
return dt.strftime(_TS_FMT)
def _parse_ts(s: str) -> datetime:
return datetime.strptime(s, _TS_FMT).replace(tzinfo=timezone.utc)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _gen_token() -> str:
"""Token aleatorio criptográfico de 256 bits (64 hex)."""
return secrets.token_hex(32)
class DeviceStore:
def __init__(self, path: str = DATABASE_PATH):
self.path = path
self._lock = threading.RLock()
self.last_write_error = None
parent = os.path.dirname(os.path.abspath(path))
if parent:
os.makedirs(parent, mode=0o700, exist_ok=True)
self._conn = sqlite3.connect(path, check_same_thread=False, timeout=10)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
@contextmanager
def _transaction(self):
with self._lock:
try:
self._conn.execute("BEGIN IMMEDIATE")
yield
self._conn.commit()
self.last_write_error = None
except BaseException as error:
self.last_write_error = type(error).__name__
self._conn.rollback()
raise
def _init_schema(self):
with self._lock:
self._conn.executescript(
"""
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
alias TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tokens (
device_id TEXT PRIMARY KEY REFERENCES devices(device_id),
current_hash TEXT,
current_version INTEGER NOT NULL DEFAULT 1,
previous_hash TEXT,
previous_expires_at TEXT,
rotated_at TEXT
);
CREATE TABLE IF NOT EXISTS device_settings (
device_id TEXT PRIMARY KEY REFERENCES devices(device_id),
pcb_version TEXT NOT NULL,
pyrano REAL NOT NULL,
az_dev REAL NOT NULL,
ota_auto INTEGER NOT NULL DEFAULT 0,
config_revision INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS migration_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
"""
)
self._conn.commit()
def close(self):
with self._lock:
try:
self._conn.close()
except Exception:
pass
# ----------------------------- devices -----------------------------
def list_devices(self):
with self._lock:
rows = self._conn.execute(
"""SELECT d.device_id, d.alias, d.enabled, d.created_at,
t.current_version, t.previous_expires_at
FROM devices d
LEFT JOIN tokens t ON t.device_id = d.device_id
ORDER BY d.created_at, d.device_id"""
).fetchall()
out = []
for r in rows:
out.append({
"device_id": r["device_id"],
"alias": r["alias"],
"enabled": bool(r["enabled"]),
"created_at": r["created_at"],
"token_version": r["current_version"],
"previous_expires_at": r["previous_expires_at"],
})
return out
def get_device(self, device_id: str):
with self._lock:
row = self._conn.execute(
"SELECT device_id, alias, enabled, created_at FROM devices WHERE device_id = ?",
(device_id,),
).fetchone()
if row is None:
return None
return {"device_id": row["device_id"], "alias": row["alias"], "enabled": bool(row["enabled"])}
def add_device(self, device_id: str, alias: str = "") -> str:
"""Alta de tarjeta. Devuelve el token EXACTAMENTE UNA VEZ (el llamador
lo entrega por el canal local seguro). No en claro en la BD."""
if not is_valid_device_id(device_id):
raise StoreError("device_id inválido")
alias = (alias or "").strip()[:64]
token = _gen_token()
with self._transaction():
exists = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if exists:
raise StoreError("device_id ya registrado")
count = self._conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
if count >= MAX_DEVICES:
raise StoreError("máximo de tarjetas alcanzado")
now = _iso(_now())
self._conn.execute(
"INSERT INTO devices (device_id, alias, enabled, created_at, updated_at) "
"VALUES (?, ?, 1, ?, ?)", (device_id, alias, now, now)
)
self._conn.execute(
"INSERT INTO tokens (device_id, current_hash, current_version) VALUES (?, ?, 1)",
(device_id, _hash_token(token)),
)
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, 0, 0, ?)",
(device_id, DEFAULT_PCB_VERSION, PYRANO_MV_PER_WM2, AZ_DEV_DEFAULT, now),
)
return token
def set_alias(self, device_id: str, alias: str):
alias = (alias or "").strip()[:64]
with self._transaction():
cur = self._conn.execute(
"UPDATE devices SET alias = ?, updated_at = ? WHERE device_id = ?",
(alias, _iso(_now()), device_id),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
def set_enabled(self, device_id: str, enabled: bool):
with self._transaction():
cur = self._conn.execute(
"UPDATE devices SET enabled = ?, updated_at = ? WHERE device_id = ?",
(1 if enabled else 0, _iso(_now()), device_id),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
# ----------------------------- tokens ------------------------------
def _get_token_row(self, device_id: str):
return self._conn.execute(
"SELECT * FROM tokens WHERE device_id = ?", (device_id,)
).fetchone()
def verify_token(self, device_id: str, token: str) -> bool:
"""Verificación constante en tiempo. Token válido solo para su ID."""
if not is_valid_device_id(device_id) or not token:
return False
return self.credential_active(device_id, _hash_token(token))
def credential_active(self, device_id: str, digest: str) -> bool:
"""Validate the credential used by this session, not just any card key."""
dev = self.get_device(device_id)
if dev is None or not dev["enabled"]:
return False
with self._lock:
row = self._get_token_row(device_id)
if row is None:
return False
if row["current_hash"] and hmac.compare_digest(digest, row["current_hash"]):
return True
if row["previous_hash"] and row["previous_expires_at"]:
return (_parse_ts(row["previous_expires_at"]) > _now()
and hmac.compare_digest(digest, row["previous_hash"]))
return False
def rotate_token(self, device_id: str, grace_hours: int = None) -> str:
"""Crea el token reemplazo. El anterior queda como 'previous' con
caducidad explícita. Devuelve el nuevo token UNA sola vez."""
grace = TOKEN_GRACE_HOURS if grace_hours is None else int(grace_hours)
if grace <= 0:
raise StoreError("grace_hours debe ser positivo")
token = _gen_token()
now = _now()
with self._transaction():
row = self._get_token_row(device_id)
if row is None:
raise StoreError("device_id no registrado")
self._conn.execute(
"""UPDATE tokens SET
current_hash = ?, current_version = current_version + 1,
previous_hash = current_hash,
previous_expires_at = ?, rotated_at = ?
WHERE device_id = ?""",
(_hash_token(token), _iso(now + timedelta(hours=grace)), _iso(now), device_id),
)
return token
def get_token_version(self, device_id: str):
with self._lock:
row = self._get_token_row(device_id)
if row is None:
return None
return row["current_version"]
def retire_previous(self, device_id: str):
"""Revoca el token anterior (tras verificar la nueva conexión)."""
with self._transaction():
cur = self._conn.execute(
"UPDATE tokens SET previous_hash = NULL, previous_expires_at = NULL WHERE device_id = ?",
(device_id,),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
def revoke_tokens(self, device_id: str):
"""Revocación urgente: elimina la validez de ambos tokens."""
with self._transaction():
cur = self._conn.execute(
"UPDATE tokens SET current_hash = NULL, previous_hash = NULL, "
"previous_expires_at = NULL WHERE device_id = ?",
(device_id,),
)
if cur.rowcount == 0:
raise StoreError("device_id no registrado")
# ---------------------------- settings -----------------------------
def get_settings(self, device_id: str):
with self._lock:
row = self._conn.execute(
"SELECT pcb_version, pyrano, az_dev, ota_auto, config_revision "
"FROM device_settings WHERE device_id = ?", (device_id,)
).fetchone()
if row is None:
return None
return {
"pcb_version": row["pcb_version"],
"pyrano": row["pyrano"],
"az_dev": row["az_dev"],
"ota_auto": bool(row["ota_auto"]),
"config_revision": row["config_revision"],
}
def save_settings(self, device_id: str, pcb_version: str = None,
pyrano: float = None, az_dev: float = None,
ota_auto: bool = None):
"""Persiste en transacción. Lanza StoreError si el valor es inválido;
el llamador debe conservar el estado previo en ese caso."""
if pcb_version is not None and (not isinstance(pcb_version, str) or pcb_version not in PCB_VERSIONS):
raise StoreError("pcb_version inválida")
if pyrano is not None:
p = float(pyrano)
if not math.isfinite(p) or p <= 0:
raise StoreError("pyrano debe ser finita y positiva")
if az_dev is not None:
a = float(az_dev)
if not math.isfinite(a):
raise StoreError("az_dev debe ser finita")
with self._transaction():
dev = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (device_id,)
).fetchone()
if dev is None:
raise StoreError("device_id no registrado")
existing = self._conn.execute(
"SELECT pcb_version, pyrano, az_dev, ota_auto FROM device_settings "
"WHERE device_id = ?", (device_id,)
).fetchone()
now = _iso(_now())
if existing is None:
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, ?, 0, ?)",
(device_id,
pcb_version if pcb_version is not None else DEFAULT_PCB_VERSION,
float(pyrano) if pyrano is not None else PYRANO_MV_PER_WM2,
float(az_dev) if az_dev is not None else AZ_DEV_DEFAULT,
1 if ota_auto else 0, now),
)
else:
self._conn.execute(
"""UPDATE device_settings SET
pcb_version = COALESCE(?, pcb_version),
pyrano = COALESCE(?, pyrano),
az_dev = COALESCE(?, az_dev),
ota_auto = COALESCE(?, ota_auto),
config_revision = config_revision + 1,
updated_at = ?
WHERE device_id = ?""",
(pcb_version,
float(pyrano) if pyrano is not None else None,
float(az_dev) if az_dev is not None else None,
(1 if ota_auto else 0) if ota_auto is not None else None,
now, device_id),
)
return self.get_settings(device_id)
# ---------------------------- migration ----------------------------
def get_migration(self, key: str):
with self._lock:
row = self._conn.execute(
"SELECT value FROM migration_state WHERE key = ?", (key,)
).fetchone()
if row is None:
return None
try:
return json.loads(row["value"])
except Exception:
return None
def set_migration(self, key: str, value):
with self._transaction():
self._conn.execute(
"INSERT INTO migration_state (key, value, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
(key, json.dumps(value, ensure_ascii=False), _iso(_now())),
)
def migrate_legacy(self, primary_id: str, pcb_version: str,
pyrano: float, az_dev: float):
"""Importación idempotente del estado legacy a UNA identidad primaria
explícita. Devuelve (importado: bool, token_nuevo: str|None).
Si la primaria no existe se crea (con su token de provisionamiento)."""
marker_key = "legacy_migrated"
if not is_valid_device_id(primary_id):
raise StoreError("device_id inválido")
if pcb_version not in PCB_VERSIONS:
raise StoreError("pcb_version inválida")
if not math.isfinite(pyrano) or pyrano <= 0 or not math.isfinite(az_dev):
raise StoreError("calibración inválida")
token = None
with self._transaction():
if self.get_migration(marker_key) is not None:
return False, None
exists = self._conn.execute(
"SELECT 1 FROM devices WHERE device_id = ?", (primary_id,)
).fetchone()
now = _iso(_now())
if exists:
self._conn.execute(
"UPDATE device_settings SET pcb_version = ?, pyrano = ?, az_dev = ?, "
"config_revision = config_revision + 1, updated_at = ? WHERE device_id = ?",
(pcb_version, float(pyrano), float(az_dev), now, primary_id),
)
else:
count = self._conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0]
if count >= MAX_DEVICES:
raise StoreError("máximo de tarjetas alcanzado")
token = _gen_token()
self._conn.execute(
"INSERT INTO devices (device_id, alias, enabled, created_at, updated_at) "
"VALUES (?, 'Primario', 1, ?, ?)", (primary_id, now, now)
)
self._conn.execute(
"INSERT INTO tokens (device_id, current_hash, current_version) VALUES (?, ?, 1)",
(primary_id, _hash_token(token)),
)
self._conn.execute(
"INSERT INTO device_settings (device_id, pcb_version, pyrano, az_dev, ota_auto, config_revision, updated_at) "
"VALUES (?, ?, ?, ?, 0, 0, ?)",
(primary_id, pcb_version, float(pyrano), float(az_dev), now),
)
self._conn.execute(
"INSERT INTO migration_state (key, value, updated_at) VALUES (?, ?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at",
(marker_key, json.dumps({
"primary_id": primary_id, "pcb_version": pcb_version,
"pyrano": float(pyrano), "az_dev": float(az_dev),
}, ensure_ascii=False), now),
)
return True, token