Recuperación asistida de acceso
Mecanismo para recuperar el acceso administrativo cuando el cliente perdió la contraseña y también el correo registrado. El requisito de diseño es no depender del correo del cliente ni de una contraseña maestra fija (que, si se filtrara, comprometería todas las instalaciones).
:::info Decisión central La recuperación se habilita con una autorización única firmada por el desarrollador: sirve para una sola solicitud, en una sola instalación, expira pronto, es de un solo uso, no revela la contraseña anterior y queda auditada. :::
Modelo de confianza
Criptografía asimétrica Ed25519 (JWS EdDSA):
- La app embebe la llave pública (
server/lib/recovery/keys.ts,RECOVERY_PUBLIC_KEY_SPKI). - El desarrollador guarda la llave privada fuera del repo
(
~/.flowfit/recovery_ed25519.pem,.gitignorela excluye). - La autorización es un JWS firmado con la privada y verificado con la pública dentro de FlowFit. Mientras la pública sea el placeholder, la verificación falla cerrada (fail-closed): ninguna autorización valida.
Flujo
- En el login, el cliente genera una solicitud (
POST /api/recovery/request). - La app crea un
RecoveryRequest(status="pending", expira a los 30 min) y devuelve:- un
requestCodelegible (FLF-XXXX-XXXX) para verificar identidad por llamada/WhatsApp, y - un
requestBlobautocontenido (base64url(JSON)) coninstallationId,gymName, huella de máquina, y la lista de gerentes con correos enmascarados.
- un
- El cliente envía esos datos al desarrollador (cualquier canal; funciona sin internet en la máquina).
- El desarrollador verifica la identidad por un canal externo y firma una
autorización con
scripts/sign-recovery.ts. - El cliente pega la autorización; la app la valida (
POST /api/recovery/apply) y permite fijar nuevo correo y contraseña del gerente, reactivándolo si estaba inactivo.
La contraseña nueva nunca sale de la máquina del cliente ni entra en la autorización: el desarrollador solo autoriza la acción, nunca conoce la clave.
Verificación (frontera de seguridad)
verifyAuthorization (server/lib/recovery/authorization.ts) es una función
pura y fail-closed. En orden:
jwtVerifyconalgorithms: ["EdDSA"]yrequiredClaims: ["exp"](rechaza firma inválida, algoritmo distinto y tokens sin expiración).- Valida el payload con Zod y pina la versión de esquema (
v). installationIddel token debe coincidir con el de la base de datos (no con el del token).- La solicitud debe existir, con
status="pending"y no estar vencida (expiresAt).
El endpoint añade, además: coincidencia de targetUserId (firmado vs. enviado),
nonce de un solo uso (authNonce @unique), y la mutación dentro de un
$transaction (actualiza usuario + marca la solicitud used).
Datos
Modelo Prisma RecoveryRequest: requestCode (único), installationId,
machineFingerprint, gymName, requestedAt, expiresAt, usedAt,
targetUserId, authNonce (único), status, supportNote. El
installationId vive en AppSettings (singleton) y, al viajar con el
respaldo del SQLite, es la identidad estable del gimnasio. La huella de
máquina (hostname+OS+arch) es informativa: se guarda y audita, pero no
invalida una autorización (resiste restauración de respaldo o reinstalación).
Auditoría
Eventos escritos a mano (el hook genérico no cubre estas rutas):
recovery.request.created, recovery.apply.attempt,
recovery.apply.failed (con motivo: expired, request_expired, already_used,
wrong_installation, unknown_request, nonce_reused, target_mismatch…),
recovery.apply.success.
Herramienta del desarrollador
# Generar el par de llaves una vez (la pública se pega en keys.ts)
npx tsx scripts/gen-recovery-keys.ts ~/.flowfit/recovery_ed25519.pem
# Firmar una autorización (sin --target lista los gerentes del blob)
npx tsx scripts/sign-recovery.ts --request '<blob>' \
--target <userId> --key ~/.flowfit/recovery_ed25519.pem [--ttl 1800]
El procedimiento operativo completo (cliente, desarrollador, sin internet,
rotación de llaves) está en docs/recuperacion-soporte.md del repositorio.
Garantías
- No hay contraseña maestra.
- Autorización: un solo uso, expira en 30 min, atada a la instalación.
- La solicitud también expira a los 30 min.
- La contraseña anterior nunca se revela; la nueva nunca viaja al desarrollador.
- Toda recuperación queda en la bitácora.