diff --git a/BASE_DE_DATOS.md b/BASE_DE_DATOS.md index e768791..1a461fb 100644 --- a/BASE_DE_DATOS.md +++ b/BASE_DE_DATOS.md @@ -40,6 +40,7 @@ | `audit_log` | auditoría central (quién, qué, sobre qué, cuándo) | | `error_logs` | errores de backend (excepciones 500) y frontend (JS/promesas sin catch), panel "Errores del sistema" en Configuración | | `notes` | apuntes rápidos personales, siempre privados de cada usuario, menú propio "Notas" | +| `vault_entries` | repositorio de credenciales privado por usuario (menú "Contraseñas"), contraseña/notas cifradas | ## Relaciones clave @@ -118,6 +119,15 @@ del todo desde el panel ("Limpiar registro"). `severity` (alta/media/baja) nunca se etiqueta a mano: backend y errores de render de React = alta, errores JS sueltos = media, promesas rechazadas sin manejar = baja. +- `vault_entries` (desde v2.7.0): `password_enc`/`notes_enc` guardan el resultado de `encrypt()` + (AES-256-GCM, `backend/src/utils/crypto.js`, mismo mecanismo ya usado para la clave SMTP) — + nunca texto plano. Privado por usuario, sin excepción (ni superadmin ve lo de otro). El acceso + al menú "Contraseñas" además pide un PIN de 4 dígitos (`users.vault_pin_hash`, bcrypt) — el PIN + es un candado de acceso, NO participa del cifrado (sería débil para eso). Cambiar/resetear el + PIN (incluso por correo, `vault_pin_reset_token`) nunca toca `vault_entries` — es un cambio de + candado, no una recuperación de datos. Cada vez que se revela/crea/edita/elimina una entrada, o + se desbloquea el candado, queda en `audit_log`. Compartir una entrada con otro usuario queda + para una fase 2 (no implementado). ## Riesgos o decisiones diff --git a/CHANGELOG.md b/CHANGELOG.md index f5123a3..7a2e1f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog - project.linkedcorp.net +## [2.7.0] - 2026-10-01 +### Added +- Nuevo menú "Contraseñas": repositorio personal de credenciales (nombre, enlace, usuario, + contraseña, notas), siempre privado — nadie más lo ve, ni un superadmin. +- Contraseña y notas cifradas en el servidor (AES-256-GCM, mismo mecanismo ya probado que usa la + clave SMTP) — nunca en texto plano en la base de datos. +- El menú pide un PIN de 4 dígitos para entrar, aparte de la clave de acceso normal al sistema + (que no se toca). El PIN es un candado de acceso, no la clave de cifrado. Se puede cambiar + desde el propio menú, o restablecer por correo si se olvida — restablecerlo nunca borra ni + afecta las contraseñas ya guardadas, solo cambia el candado. +- Generador de contraseñas al crear una entrada nueva. +- Queda registrado en el log de auditoría cada vez que se crea, edita, elimina o **revela** una + contraseña guardada, y cada desbloqueo del candado. +- Compartir una entrada con otro usuario queda para una próxima versión. + ## [2.6.1] - 2026-09-30 ### Fixed - **Bug real en el botón "Actualizar" (auto-actualización)**: el paso final reiniciaba backend y diff --git a/VERSION b/VERSION index 6a6a3d8..24ba9a3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.6.1 +2.7.0 diff --git a/backend/database/migrations/017_vault.sql b/backend/database/migrations/017_vault.sql new file mode 100644 index 0000000..873bb97 --- /dev/null +++ b/backend/database/migrations/017_vault.sql @@ -0,0 +1,24 @@ +-- Repositorio de credenciales privado por usuario ("Contraseñas"). Idempotente. +-- password_enc/notes_enc guardan el resultado de encrypt() (AES-256-GCM, backend/src/utils/crypto.js), +-- nunca texto plano. Privado por usuario -- nadie mas lo ve, ni siquiera un superadmin. +CREATE TABLE IF NOT EXISTS vault_entries ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + title VARCHAR(160) NOT NULL, + url VARCHAR(500) NULL, + username VARCHAR(160) NULL, + password_enc TEXT NULL, + notes_enc TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_vault_entries_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE INDEX IF NOT EXISTS idx_vault_entries_user ON vault_entries(user_id); + +-- PIN de acceso al modulo (candado, NO participa del cifrado -- 4 digitos seria debil para eso). +-- Independiente del password_hash de login: cambiarlo/resetearlo nunca toca vault_entries. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS vault_pin_hash VARCHAR(255) NULL, + ADD COLUMN IF NOT EXISTS vault_pin_reset_token VARCHAR(128) NULL, + ADD COLUMN IF NOT EXISTS vault_pin_reset_token_expires DATETIME NULL; +CREATE INDEX IF NOT EXISTS idx_users_vault_pin_reset_token ON users(vault_pin_reset_token); diff --git a/backend/package.json b/backend/package.json index d72c553..cbed952 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.6.1", + "version": "2.7.0", "private": true, "type": "module", "main": "src/server.js", diff --git a/backend/src/app.js b/backend/src/app.js index a1fee32..feb4076 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -23,6 +23,7 @@ import searchRoutes from './modules/search/routes.js'; import updaterRoutes from './modules/updater/routes.js'; import errorLogsRoutes from './modules/errorLogs/routes.js'; import notesRoutes from './modules/notes/routes.js'; +import vaultRoutes from './modules/vault/routes.js'; export const app = express(); @@ -50,6 +51,7 @@ app.use('/api/search', searchRoutes); app.use('/api/updater', updaterRoutes); app.use('/api/error-logs', errorLogsRoutes); app.use('/api/notes', notesRoutes); +app.use('/api/vault', vaultRoutes); // Mas generico (mount en /api sin sub-path propio) — SIEMPRE al final de los /api/*, para no // interceptar con su requireAuth interno una ruta que en realidad pertenece a un router mas // especifico registrado despues (ver ESTADO.md, deuda tecnica). diff --git a/backend/src/modules/vault/controller.js b/backend/src/modules/vault/controller.js new file mode 100644 index 0000000..ec7e99c --- /dev/null +++ b/backend/src/modules/vault/controller.js @@ -0,0 +1,51 @@ +import { ok, asyncHandler } from '../../utils/http.js'; +import * as service from './service.js'; + +export const getStatus = asyncHandler(async (req, res) => { + ok(res, await service.getStatus(req.currentUser)); +}); + +export const postPin = asyncHandler(async (req, res) => { + await service.createPin(req.body.pin, req.currentUser); + ok(res, { created: true }); +}); + +export const postUnlock = asyncHandler(async (req, res) => { + ok(res, await service.unlock(req.body.pin, req.currentUser)); +}); + +export const postChangePin = asyncHandler(async (req, res) => { + await service.changePin(req.body.currentPin, req.body.newPin, req.currentUser); + ok(res, { changed: true }); +}); + +export const postForgotPin = asyncHandler(async (req, res) => { + await service.forgotPin(req.currentUser); + ok(res, { sent: true }); +}); + +export const postResetPin = asyncHandler(async (req, res) => { + await service.resetPin(req.body.token, req.body.newPin); + ok(res, { reset: true }); +}); + +export const getEntries = asyncHandler(async (req, res) => { + ok(res, await service.listEntries(req.currentUser)); +}); + +export const getEntry = asyncHandler(async (req, res) => { + ok(res, await service.getEntry(Number(req.params.id), req.currentUser)); +}); + +export const postEntry = asyncHandler(async (req, res) => { + ok(res, await service.createEntry(req.body, req.currentUser), 201); +}); + +export const putEntry = asyncHandler(async (req, res) => { + ok(res, await service.updateEntry(Number(req.params.id), req.body, req.currentUser)); +}); + +export const deleteEntry = asyncHandler(async (req, res) => { + await service.removeEntry(Number(req.params.id), req.currentUser); + ok(res, { deleted: true }); +}); diff --git a/backend/src/modules/vault/middleware.js b/backend/src/modules/vault/middleware.js new file mode 100644 index 0000000..27e5051 --- /dev/null +++ b/backend/src/modules/vault/middleware.js @@ -0,0 +1,21 @@ +import jwt from 'jsonwebtoken'; +import { env } from '../../config/env.js'; +import { fail } from '../../utils/http.js'; + +// Candado de acceso a /api/vault/entries*: token corto y propio (scope:'vault'), NUNCA el JWT de +// login -- se pide recien despues de verificar el PIN (ver service.js#unlock). Va en un header +// propio para no mezclarse con el Authorization normal, que sigue intacto. +export function requireVaultUnlock(req, res, next) { + const token = req.headers['x-vault-token']; + if (!token) return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + let payload; + try { + payload = jwt.verify(token, env.jwtSecret); + } catch { + return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + } + if (payload.scope !== 'vault' || payload.sub !== req.currentUser.id) { + return fail(res, 403, 'Repositorio bloqueado -- ingresa tu PIN'); + } + next(); +} diff --git a/backend/src/modules/vault/repository.js b/backend/src/modules/vault/repository.js new file mode 100644 index 0000000..9020cfe --- /dev/null +++ b/backend/src/modules/vault/repository.js @@ -0,0 +1,67 @@ +import { pool } from '../../config/db.js'; + +const LIST_SELECT = 'id, user_id, title, url, username, created_at, updated_at'; +const FULL_SELECT = `${LIST_SELECT}, password_enc, notes_enc`; + +export async function findByUser(userId) { + const [rows] = await pool.query( + `SELECT ${LIST_SELECT} FROM vault_entries WHERE user_id = ? ORDER BY title`, + [userId] + ); + return rows; +} + +export async function findById(id) { + const [rows] = await pool.query(`SELECT ${FULL_SELECT} FROM vault_entries WHERE id = ?`, [id]); + return rows[0] || null; +} + +export async function create(userId, { title, url, username, passwordEnc, notesEnc }) { + const [result] = await pool.query( + `INSERT INTO vault_entries (user_id, title, url, username, password_enc, notes_enc) + VALUES (?, ?, ?, ?, ?, ?)`, + [userId, title, url || null, username || null, passwordEnc || null, notesEnc || null] + ); + return findById(result.insertId); +} + +export async function update(id, { title, url, username, passwordEnc, notesEnc }) { + await pool.query( + `UPDATE vault_entries SET title = ?, url = ?, username = ?, password_enc = ?, notes_enc = ? WHERE id = ?`, + [title, url || null, username || null, passwordEnc || null, notesEnc || null, id] + ); + return findById(id); +} + +export async function remove(id) { + await pool.query('DELETE FROM vault_entries WHERE id = ?', [id]); +} + +// --- PIN de acceso al modulo (en la tabla users, ver migracion 017) --- + +export async function getVaultPinHash(userId) { + const [rows] = await pool.query('SELECT vault_pin_hash FROM users WHERE id = ?', [userId]); + return rows[0]?.vault_pin_hash || null; +} + +export async function setVaultPinHash(userId, hash) { + await pool.query( + 'UPDATE users SET vault_pin_hash = ?, vault_pin_reset_token = NULL, vault_pin_reset_token_expires = NULL WHERE id = ?', + [hash, userId] + ); +} + +export async function setPinResetToken(userId, token) { + await pool.query( + 'UPDATE users SET vault_pin_reset_token = ?, vault_pin_reset_token_expires = DATE_ADD(NOW(), INTERVAL 1 HOUR) WHERE id = ?', + [token, userId] + ); +} + +export async function findByPinResetToken(token) { + const [rows] = await pool.query( + 'SELECT id FROM users WHERE vault_pin_reset_token = ? AND vault_pin_reset_token_expires > NOW()', + [token] + ); + return rows[0] || null; +} diff --git a/backend/src/modules/vault/routes.js b/backend/src/modules/vault/routes.js new file mode 100644 index 0000000..83835e8 --- /dev/null +++ b/backend/src/modules/vault/routes.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import rateLimit from 'express-rate-limit'; +import { requireAuth } from '../../middleware/auth.js'; +import { requireVaultUnlock } from './middleware.js'; +import { + getStatus, postPin, postUnlock, postChangePin, postForgotPin, postResetPin, + getEntries, getEntry, postEntry, putEntry, deleteEntry, +} from './controller.js'; + +const router = Router(); + +// PIN de 4 digitos: 10,000 combinaciones -- sin este limiter seria trivial de forzar. +const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 8, standardHeaders: true, legacyHeaders: false }); + +// Publico, como auth/reset-password: el link del correo puede abrirse sin sesion activa. +router.post('/reset-pin', unlockLimiter, postResetPin); + +router.use(requireAuth); +router.get('/status', getStatus); +router.post('/pin', postPin); +router.post('/unlock', unlockLimiter, postUnlock); +router.post('/change-pin', unlockLimiter, postChangePin); +router.post('/forgot-pin', unlockLimiter, postForgotPin); + +router.get('/entries', requireVaultUnlock, getEntries); +router.get('/entries/:id', requireVaultUnlock, getEntry); +router.post('/entries', requireVaultUnlock, postEntry); +router.put('/entries/:id', requireVaultUnlock, putEntry); +router.delete('/entries/:id', requireVaultUnlock, deleteEntry); + +export default router; diff --git a/backend/src/modules/vault/service.js b/backend/src/modules/vault/service.js new file mode 100644 index 0000000..b180130 --- /dev/null +++ b/backend/src/modules/vault/service.js @@ -0,0 +1,144 @@ +import crypto from 'node:crypto'; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import * as repo from './repository.js'; +import { env } from '../../config/env.js'; +import { encrypt, decrypt } from '../../utils/crypto.js'; +import { HttpError } from '../../utils/http.js'; +import { logAudit } from '../../utils/audit.js'; +import { sendMail } from '../../utils/mailer.js'; + +const PIN_RE = /^\d{4}$/; +const VAULT_TOKEN_EXPIRES = '30m'; + +function newToken() { + return crypto.randomBytes(32).toString('hex'); +} + +async function getOwnEntryOr404(id, currentUser) { + const entry = await repo.findById(id); + if (!entry || entry.user_id !== currentUser.id) throw new HttpError(404, 'No encontrado'); + return entry; +} + +function stripSensitive(entry) { + const { password_enc, notes_enc, ...rest } = entry; + return rest; +} + +function decryptEntry(entry) { + return { + ...stripSensitive(entry), + password: entry.password_enc ? decrypt(entry.password_enc) : '', + notes: entry.notes_enc ? decrypt(entry.notes_enc) : '', + }; +} + +export async function listEntries(currentUser) { + const rows = await repo.findByUser(currentUser.id); + return rows; // sin password/notes -- la lista nunca trae datos sensibles +} + +// "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. +export async function getEntry(id, currentUser) { + const entry = await getOwnEntryOr404(id, currentUser); + await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id }); + return decryptEntry(entry); +} + +export async function createEntry(payload, currentUser) { + if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); + const entry = await repo.create(currentUser.id, { + title: payload.title.trim(), + url: payload.url?.trim(), + username: payload.username?.trim(), + passwordEnc: payload.password ? encrypt(payload.password) : null, + notesEnc: payload.notes ? encrypt(payload.notes) : null, + }); + await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id }); + return stripSensitive(entry); +} + +export async function updateEntry(id, payload, currentUser) { + await getOwnEntryOr404(id, currentUser); + if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); + const updated = await repo.update(id, { + title: payload.title.trim(), + url: payload.url?.trim(), + username: payload.username?.trim(), + passwordEnc: payload.password ? encrypt(payload.password) : null, + notesEnc: payload.notes ? encrypt(payload.notes) : null, + }); + await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_entry', entityId: id }); + return stripSensitive(updated); +} + +export async function removeEntry(id, currentUser) { + await getOwnEntryOr404(id, currentUser); + await repo.remove(id); + await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_entry', entityId: id }); +} + +// --- PIN --- + +export async function getStatus(currentUser) { + const hash = await repo.getVaultPinHash(currentUser.id); + return { hasPinSet: !!hash }; +} + +export async function createPin(pin, currentUser) { + if (!PIN_RE.test(pin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros'); + const existing = await repo.getVaultPinHash(currentUser.id); + if (existing) throw new HttpError(400, 'Ya tenes un PIN creado -- usa "cambiar PIN" en su lugar'); + const hash = await bcrypt.hash(pin, 12); + await repo.setVaultPinHash(currentUser.id, hash); + await logAudit({ userId: currentUser.id, action: 'create_pin', entityType: 'vault_pin', entityId: currentUser.id }); +} + +export async function unlock(pin, currentUser) { + if (!PIN_RE.test(pin)) throw new HttpError(400, 'PIN invalido'); + const hash = await repo.getVaultPinHash(currentUser.id); + if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN'); + const ok = await bcrypt.compare(pin, hash); + if (!ok) throw new HttpError(403, 'PIN incorrecto'); + const vaultToken = jwt.sign({ sub: currentUser.id, scope: 'vault' }, env.jwtSecret, { expiresIn: VAULT_TOKEN_EXPIRES }); + await logAudit({ userId: currentUser.id, action: 'unlock', entityType: 'vault_pin', entityId: currentUser.id }); + return { vaultToken }; +} + +export async function changePin(currentPin, newPin, currentUser) { + if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN nuevo debe ser de 4 numeros'); + const hash = await repo.getVaultPinHash(currentUser.id); + if (!hash) throw new HttpError(400, 'Todavia no creaste un PIN'); + const ok = await bcrypt.compare(currentPin, hash); + if (!ok) throw new HttpError(403, 'PIN actual incorrecto'); + const newHash = await bcrypt.hash(newPin, 12); + await repo.setVaultPinHash(currentUser.id, newHash); + await logAudit({ userId: currentUser.id, action: 'change_pin', entityType: 'vault_pin', entityId: currentUser.id }); +} + +// Solo cambia el PIN -- nunca toca vault_entries. No es "recuperar datos", es resetear el candado. +export async function forgotPin(currentUser) { + const token = newToken(); + await repo.setPinResetToken(currentUser.id, token); + const resetUrl = `${env.corsOrigin}/restablecer-pin?token=${token}`; + await sendMail({ + to: currentUser.email, + subject: 'Restablecer PIN de Contraseñas — project.linkedcorp.net', + html: `
Hola ${currentUser.name},
+Pediste restablecer el PIN de acceso a tu repositorio de Contraseñas. Esto solo cambia + el PIN -- tus contraseñas guardadas no se tocan ni se pierden.
+Elegi un PIN nuevo en este enlace (vence en 1 hora):
+ +Si no pediste esto, ignora el correo.
`, + }); +} + +export async function resetPin(token, newPin) { + if (!PIN_RE.test(newPin)) throw new HttpError(400, 'El PIN debe ser de 4 numeros'); + const user = await repo.findByPinResetToken(token); + if (!user) throw new HttpError(400, 'El enlace para restablecer el PIN es invalido o expiro'); + const hash = await bcrypt.hash(newPin, 12); + await repo.setVaultPinHash(user.id, hash); + await logAudit({ userId: user.id, action: 'reset_pin', entityType: 'vault_pin', entityId: user.id }); +} diff --git a/frontend/package.json b/frontend/package.json index 75d4dfb..a589798 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.6.1", + "version": "2.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 8c13615..91df35f 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -20,6 +20,8 @@ import ProyectoDetallePage from '../pages/Proyectos/ProyectoDetallePage.jsx'; import ConfiguracionPage from '../pages/Configuracion/ConfiguracionPage.jsx'; import RecurringTasksPage from '../pages/RecurringTasks/RecurringTasksPage.jsx'; import NotasPage from '../pages/Notas/NotasPage.jsx'; +import ContrasenasPage from '../pages/Contrasenas/ContrasenasPage.jsx'; +import ResetVaultPinPage from '../pages/Login/ResetVaultPinPage.jsx'; export default function App() { return ( @@ -34,6 +36,7 @@ export default function App() {{t('vault_create_pin_hint')}
} +