import crypto from 'node:crypto'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import * as repo from './repository.js'; import * as usersRepo from '../users/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'; // Catalogo fijo (debe coincidir con el del frontend: ContrasenasPage.jsx#ICONS y // frontend/src/data/brandIcons.js#BRAND_ICONS) -- nunca se acepta un valor arbitrario, para no // guardar basura ni abrir una via de inyeccion rara. const ICON_KEYS = new Set([ 'mail', 'globe', 'server', 'database', 'cloud', 'shield', 'lock', 'wifi', 'card', 'building', 'printer', 'phone', 'key', 'disk', 'router', 'user', 'b_google', 'b_gmail', 'b_googledrive', 'b_googlecloud', 'b_protonmail', 'b_zoho', 'b_github', 'b_gitlab', 'b_digitalocean', 'b_cloudflare', 'b_namecheap', 'b_godaddy', 'b_ovh', 'b_vultr', 'b_netlify', 'b_vercel', 'b_cpanel', 'b_plesk', 'b_webmin', 'b_nginx', 'b_apache', 'b_mysql', 'b_mariadb', 'b_postgresql', 'b_docker', 'b_cisco', 'b_fortinet', 'b_mikrotik', 'b_ubiquiti', 'b_tplink', 'b_openvpn', 'b_wireguard', 'b_pfsense', 'b_opnsense', 'b_qnap', 'b_synology', 'b_nordvpn', 'b_bitdefender', 'b_trendmicro', 'b_avast', 'b_kaspersky', 'b_whatsapp', 'b_telegram', 'b_zoom', 'b_discord', 'b_jitsi', 'b_facebook', 'b_instagram', 'b_x', 'b_paypal', 'b_stripe', 'b_teamviewer', 'b_anydesk', 'b_dropbox', 'b_backblaze', 'b_mega', 'b_veeam', 'b_nextcloud', 'b_bitwarden', 'b_lastpass', 'b_linux', 'b_ubuntu', 'b_apple', 'b_raspberrypi', 'b_wordpress', 'b_mailchimp', 'b_firebase', ]); function sanitizeIconKey(key) { return ICON_KEYS.has(key) ? key : null; } 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; } // Dueño o receptor de un share (solo lectura: nunca para editar/borrar/compartir/mover de // grupo, eso sigue siendo getOwnEntryOr404). 404 tambien si no esta compartida -- nunca 403. async function getReadableEntryOr404(id, currentUser) { const entry = await repo.findById(id); if (!entry) throw new HttpError(404, 'No encontrado'); if (entry.user_id === currentUser.id) return entry; if (await repo.isSharedWith(id, currentUser.id)) return entry; throw new HttpError(404, 'No encontrado'); } async function getOwnGroupOr404(id, currentUser) { const group = await repo.findGroupById(id); if (!group || group.user_id !== currentUser.id) throw new HttpError(404, 'Grupo no encontrado'); return group; } 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) : '', }; } // Buscar por nombre/usuario/enlace es trivial (van en texto plano). Las notas SI estan cifradas // -- se descifran aca mismo, en el servidor, solo para comparar contra la busqueda; el contenido // nunca sale hacia el cliente (se saca con stripSensitive antes de devolver, igual que siempre). // No se audita como "view": nunca se le muestra el texto de la nota al usuario, solo si matchea. export async function listEntries(currentUser, query) { const q = query?.trim().toLowerCase(); if (!q) return repo.findVisibleToUser(currentUser.id); const rows = await repo.findByUserForSearch(currentUser.id); return rows .filter((row) => { if ([row.title, row.url, row.username].some((v) => v && v.toLowerCase().includes(q))) return true; if (!row.notes_enc) return false; try { return decrypt(row.notes_enc).toLowerCase().includes(q); } catch { return false; } }) .map(stripSensitive); } // "Revelar" una entrada: unica operacion que descifra la contraseña -- se audita. Dueño o // receptor de un share pueden revelar (acordado con el usuario: compartir da acceso real a la // credencial, no solo a los metadatos) -- nunca a quien no tiene ninguna de las dos relaciones. export async function getEntry(id, currentUser) { const entry = await getReadableEntryOr404(id, currentUser); await logAudit({ userId: currentUser.id, action: 'view', entityType: 'vault_entry', entityId: id }); return decryptEntry(entry); } async function resolveGroupId(groupId, currentUser) { if (!groupId) return null; await getOwnGroupOr404(groupId, currentUser); return groupId; } export async function createEntry(payload, currentUser) { if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); const groupId = await resolveGroupId(payload.groupId, currentUser); 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, iconKey: sanitizeIconKey(payload.iconKey), groupId, }); await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_entry', entityId: entry.id }); return stripSensitive(entry); } export async function updateEntry(id, payload, currentUser) { const entry = await getOwnEntryOr404(id, currentUser); if (!payload.title?.trim()) throw new HttpError(400, 'El nombre es obligatorio'); const groupId = await resolveGroupId(payload.groupId, currentUser); // Si no viene password/notes nuevo (ej. se edito solo el nombre sin pasar por "Revelar" // primero), se conserva lo que ya estaba guardado -- nunca se borra por venir vacio. const updated = await repo.update(id, { title: payload.title.trim(), url: payload.url?.trim(), username: payload.username?.trim(), passwordEnc: payload.password ? encrypt(payload.password) : entry.password_enc, notesEnc: payload.notes !== undefined && payload.notes !== '' ? encrypt(payload.notes) : entry.notes_enc, iconKey: sanitizeIconKey(payload.iconKey), groupId, }); 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 }); } // --- Grupos (carpetas de un solo nivel) --- export const listGroups = (currentUser) => repo.findGroupsByUser(currentUser.id); export async function createGroup(payload, currentUser) { const name = payload.name?.trim(); if (!name) throw new HttpError(400, 'El nombre es obligatorio'); const group = await repo.createGroup(currentUser.id, name, sanitizeIconKey(payload.iconKey)); await logAudit({ userId: currentUser.id, action: 'create', entityType: 'vault_group', entityId: group.id }); return group; } export async function updateGroup(id, payload, currentUser) { await getOwnGroupOr404(id, currentUser); const name = payload.name?.trim(); if (!name) throw new HttpError(400, 'El nombre es obligatorio'); const group = await repo.updateGroup(id, name, sanitizeIconKey(payload.iconKey)); await logAudit({ userId: currentUser.id, action: 'update', entityType: 'vault_group', entityId: id }); return group; } // cascade=false (default): borrado "suave", las entradas quedan sin grupo (como siempre). // cascade=true: ademas destruye TODAS las entradas del grupo -- irreversible, ver confirmacion // fuerte en el frontend (ContrasenasPage.jsx#confirmDeleteGroup). export async function removeGroup(id, currentUser, cascade = false) { await getOwnGroupOr404(id, currentUser); if (cascade) await repo.removeEntriesInGroup(id); await repo.removeGroup(id); await logAudit({ userId: currentUser.id, action: 'delete', entityType: 'vault_group', entityId: id, detail: { cascade } }); } // --- Compartir una entrada (solo lectura) --- export async function listEntryShares(id, currentUser) { await getOwnEntryOr404(id, currentUser); return repo.listShares(id); } export async function shareEntry(id, targetUserId, currentUser) { await getOwnEntryOr404(id, currentUser); if (!targetUserId) throw new HttpError(400, 'Indica un usuario'); if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir una entrada contigo mismo'); const targetUser = await usersRepo.findById(targetUserId); if (!targetUser) throw new HttpError(404, 'Usuario no encontrado'); const shares = await repo.addShare(id, targetUserId); await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } }); return shares; } export async function unshareEntry(id, targetUserId, currentUser) { await getOwnEntryOr404(id, currentUser); const shares = await repo.removeShare(id, targetUserId); await logAudit({ userId: currentUser.id, action: 'unshare', entityType: 'vault_entry', entityId: id, detail: { userId: targetUserId } }); return shares; } // Compartir TODAS las entradas de un grupo de una vez (conveniencia desde el menu del grupo) -- // el grupo sigue sin ser una unidad de permiso: esto solo aplica el mismo share de siempre a // cada entrada que tiene hoy ese group_id, una por una. Entradas que se agreguen al grupo // despues NO heredan el share retroactivamente. export async function shareGroup(groupId, targetUserId, currentUser) { await getOwnGroupOr404(groupId, currentUser); if (!targetUserId) throw new HttpError(400, 'Indica un usuario'); if (targetUserId === currentUser.id) throw new HttpError(400, 'No puedes compartir contigo mismo'); const targetUser = await usersRepo.findById(targetUserId); if (!targetUser) throw new HttpError(404, 'Usuario no encontrado'); const sharedCount = await repo.shareGroupEntries(groupId, targetUserId); await logAudit({ userId: currentUser.id, action: 'share', entityType: 'vault_group', entityId: groupId, detail: { userId: targetUserId, entriesCount: sharedCount } }); return { sharedCount }; } // --- 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):

${resetUrl}

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 }); }