feat: version 2.7.0 -- menu Contraseñas (repositorio de credenciales privado)

Nuevo modulo backend/src/modules/vault/: contraseña y notas cifradas
en el servidor (AES-256-GCM, mismo mecanismo ya usado para la clave
SMTP). Acceso al menu protegido con un PIN de 4 digitos independiente
del login (token corto propio, header X-Vault-Token, nunca el JWT de
sesion). El PIN no participa del cifrado -- cambiarlo o restablecerlo
por correo nunca afecta las contraseñas ya guardadas. Privado por
usuario, sin excepcion. Cada accion sensible (crear/editar/eliminar/
revelar/desbloquear) queda en audit_log. Compartir con otro usuario
queda para una proxima version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Milton Gonzalez
2026-10-01 12:12:54 -05:00
parent f607455668
commit 0be0712ff0
20 changed files with 879 additions and 10 deletions
+144
View File
@@ -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: `<p>Hola ${currentUser.name},</p>
<p>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.</p>
<p>Elegi un PIN nuevo en este enlace (vence en 1 hora):</p>
<p><a href="${resetUrl}">${resetUrl}</a></p>
<p>Si no pediste esto, ignora el correo.</p>`,
});
}
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 });
}