434 lines
18 KiB
Python
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
|