Skip to content

Commit c7f8dfc

Browse files
mejorado cifrado de claves rachet de los mensajes
1 parent b257369 commit c7f8dfc

12 files changed

Lines changed: 219 additions & 183 deletions

File tree

Docs/Services/CLAVES_SISTEMA.md

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ Ravage maneja varios tipos de claves criptográficas con propósitos distintos.
1212
| `SECRET_KEY_COKKIE` | Variable de entorno (hex) | `Buffer` | `.env.secret` | Cifra archivos locales de sesión y configuración de la app |
1313
| `SECRET_KEY_PRIVATE` | Variable de entorno (hex) | `Buffer` | `.env.secret` | Cifra el archivo de identidad E2EE (clave privada RSA) en disco |
1414
| `INTERNAL_ENCRYPTION_KEY` | Variable de entorno (hex) | `Buffer` | `.env.secret` | Cifra datos sensibles almacenados en MongoDB (buzón, etc.) |
15-
| Chain key (ratchet E2EE) | `randomBytes(32)` al crear chat | Hex string | Hex string (RSA-wrapped en `ratchet_keys`) | Clave raíz del ratchet de mensajes por chat |
16-
| Clave privada RSA | Generada con `generateKeyPairSync` | PEM string | Cifrada con `SECRET_KEY_PRIVATE` en `identityFile` | Descifra chain keys y mensajes E2EE |
15+
| Chain key (ratchet E2EE) | `randomBytes(32)` al crear chat | Hex string | Hex string (X25519-wrapped en `ratchet_keys`) | Clave raíz del ratchet de mensajes por chat |
16+
| Clave privada X25519 | Generada con `generateKeyPairSync('x25519')` | PEM string | Cifrada con `SECRET_KEY_PRIVATE` en `identityFile` | Descifra chain keys E2EE (ECDH) |
1717

1818
---
1919

@@ -83,10 +83,7 @@ Las chain keys implementan un ratchet de tipo Sender Key, similar al protocolo S
8383

8484
### Formato y convención
8585

86-
Las chain keys se almacenan y transportan siempre como **cadenas hexadecimales de 64 caracteres** (representación de 32 bytes). Este convenio es intencional:
87-
88-
- `descifrarConPrivada()` en `cryptoService.js` devuelve `.toString('utf8')`. Una cadena hex sobrevive sin corrupción a este round-trip (todos sus caracteres son ASCII imprimibles).
89-
- Si se almacenaran como bytes binarios, el `.toString('utf8')` los corrompería al encontrar secuencias de bytes no válidas UTF-8.
86+
Las chain keys se almacenan y transportan siempre como **cadenas hexadecimales de 64 caracteres** (representación de 32 bytes, todos caracteres ASCII). Esta convención es segura frente al round-trip de AES-GCM porque UTF-8 no corrompe ASCII.
9087

9188
### Flujo de la chain key
9289

@@ -95,31 +92,45 @@ Las chain keys se almacenan y transportan siempre como **cadenas hexadecimales d
9592
randomBytes(32).toString('hex') ← chain key inicial (hex, 64 chars)
9693
9794
98-
cifrarConPublica(chainKey, publicKey) ← RSA-OAEP cifra los 64 bytes UTF-8
99-
95+
cifrarConX25519(chainKey, publicKey) ← ECDH efímero + HKDF + AES-256-GCM
96+
genera: { ephPub, iv, data, tag }
10097
101-
ratchet_keys[].clave_envuelta ← almacenada cifrada en MongoDB por par emisor→receptor
98+
ratchet_keys[].clave_envuelta ← subdocumento { ephPub, iv, data, tag } en MongoDB
10299
103100
[Enviar / recibir mensaje]
104-
descifrarConPrivada(clave_envuelta) ← devuelve la chain key como hex string
101+
descifrarConX25519(clave_envuelta, privateKey) ← devuelve la chain key como hex string
105102
106103
107104
ratchetChainKey(chainKeyHex)
108105
├─ messageKey = HMAC-SHA256(Buffer.from(hex, 'hex'), 0x01) → Buffer (clave AES para este mensaje)
109106
└─ nextChainKey = HMAC-SHA256(Buffer.from(hex, 'hex'), 0x02) → hex string (siguiente estado del ratchet)
110107
```
111108

112-
El `nextChainKey` vuelve a ser hex para mantener el contrato y poder pasarse a la siguiente iteración de `ratchetChainKey`.
109+
### Por qué X25519 en lugar de RSA-OAEP
110+
111+
Con RSA-OAEP, comprometer una clave privada permite descifrar **todos** los `clave_envuelta` pasados almacenados en la DB. Con X25519 efímero, cada `clave_envuelta` se cifró con una clave efímera de un solo uso: comprometer la clave privada de identidad no permite descifrar mensajes anteriores (**forward secrecy** en la distribución de chain keys).
112+
113+
### Cómo funciona el cifrado X25519
114+
115+
Para cada par (emisor, receptor) al crear o rotar claves de chat:
116+
117+
1. Se genera un par X25519 **efímero** (vive solo durante la operación).
118+
2. `ECDH(ephPriv, recipientPub)` → 32 bytes de secreto compartido.
119+
3. `HKDF-SHA256(sharedSecret, info='ravage-ck-wrap')` → 32 bytes de wrapping key.
120+
4. `AES-256-GCM(chainKeyHex, wrappingKey)``{ iv, data, tag }`.
121+
5. Se almacena `{ ephPub (raw hex 32B), iv, data, tag }` en `ratchet_keys[].clave_envuelta`.
122+
123+
Para descifrar: receptor hace `ECDH(privKey, ephPub)` → misma wrapping key → descifra con AES-GCM.
113124

114125
### Archivos relevantes
115126

116127
| Archivo | Rol |
117128
|---|---|
118-
| `ChatRepository.js` | Genera la chain key inicial y la cifra con RSA pública de cada miembro |
119-
| `cryptoService.js` | `ratchetChainKey`, `cifrarConPublica`, `descifrarConPrivada` |
120-
| `cryptoWorker.js` | Worker pool: réplica de `_ratchetChainKey` y `_descifrarConPrivada` para operaciones paralelas |
129+
| `ChatRepository.js` | Genera la chain key inicial y la cifra con X25519 para cada miembro |
130+
| `cryptoService.js` | `ratchetChainKey`, `cifrarConX25519`, `descifrarConX25519`, `descifrarConX25519Multi` |
131+
| `cryptoWorker.js` | Worker pool: `_cifrarConX25519`, `_descifrarConX25519`, `_ratchetChainKey` |
121132
| `messageCryptoService.js` | Orquesta el descifrado de mensajes usando el ratchet |
122-
| `Chat.js` (modelo) | `clave_envuelta: String` — la chain key cifrada con RSA |
133+
| `Chat.js` (modelo) | `clave_envuelta: { ephPub, iv, data, tag }` — subdocumento en MongoDB |
123134

124135
---
125136

@@ -131,5 +142,5 @@ El `nextChainKey` vuelve a ser hex para mantener el contrato y poder pasarse a l
131142
| `SECRET_KEY_COKKIE` | En `.env.secret` (protegido por vault del SO) | AES-256-GCM |
132143
| `SECRET_KEY_PRIVATE` | En `.env.secret` (protegido por vault del SO) | AES-256-GCM |
133144
| `INTERNAL_ENCRYPTION_KEY` | En `.env.secret` | AES-256-GCM (cifra datos en MongoDB) |
134-
| Chain key | RSA-OAEP (SHA-256) con clave pública del receptor | HMAC-SHA256 (ratchet) → AES-256-GCM (mensajes) |
135-
| Clave privada RSA | AES-256-GCM con `SECRET_KEY_PRIVATE` | RSA-OAEP (descifrar chain keys) |
145+
| Chain key | X25519+HKDF+AES-256-GCM (efímero por operación) | HMAC-SHA256 (ratchet) → AES-256-GCM (mensajes) |
146+
| Clave privada X25519 | AES-256-GCM con `SECRET_KEY_PRIVATE` | ECDH (descifrar chain keys envueltas) |

Docs/Services/seguridad/LOGIN_SESION.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Este documento describe el flujo completo de autenticación en Ravage: registro,
1313
3. Se comprueba en DB que el correo no esté ya registrado (`correo_hash`).
1414
4. Si pasa, en background se ejecutan en paralelo:
1515
- **Hash de contraseña** con Argon2id (ver `Docs/Services/seguridad/HASHING_CONTRASENAS.md`).
16-
- **Generación de par de claves RSA** para cifrado E2EE.
16+
- **Generación de par de claves X25519** para cifrado E2EE.
1717
5. Se genera un código de verificación numérico aleatorio y se guarda en la colección `validationcodes` cifrado, vinculado al correo y al dispositivo.
1818
6. Se envía el código por correo. El usuario tiene **10 minutos** y **5 intentos** para introducirlo.
1919

@@ -154,7 +154,7 @@ autoLoginUsuario()
154154
Tras cualquier login exitoso (manual o autologin) se verifica que existe la clave privada RSA local.
155155

156156
- Si existe → sin acción.
157-
- Si no existe (p.ej. primer login en un dispositivo nuevo, o archivo corrupto) → se regeneran las claves RSA automáticamente, se actualiza la clave pública en DB y se guarda la privada en el archivo local.
157+
- Si no existe (p.ej. primer login en un dispositivo nuevo, o archivo corrupto) → se regeneran las claves X25519 automáticamente, se actualiza la clave pública en DB y se guarda la privada en el archivo local.
158158

159159
> **Atención**: regenerar la identidad rompe el descifrado de mensajes anteriores en todos los chats, ya que fueron cifrados con la clave pública antigua. Es un escenario de último recurso.
160160
@@ -197,5 +197,5 @@ Todos los archivos se guardan cifrados en el directorio de datos de la app (fuer
197197
| `sessionFile` | `{ username, token }` | Login con `mantenerSesion` | Cierre de sesión / token inválido |
198198
| `omitirVerificacionCuentaFile` | `{ username, token }` | Tras validar código de login | Token expirado / inválido |
199199
| `dispositivoConfianza` | `{ username, token }` | Al marcar como de confianza | Al revocar confianza |
200-
| `identityFile` | Claves RSA privada + pública | Registro / regeneración | Nunca (persiste entre sesiones) |
200+
| `identityFile` | Claves X25519 privada + pública | Registro / regeneración | Nunca (persiste entre sesiones) |
201201
| `securityPin` | `{ correo, pinHash }` | Al configurar el PIN | Al borrar el PIN |

backend/models/Chat.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ const ChatSchema = new mongoose.Schema({
2323
},
2424
ratchet_keys: [{
2525
emisor_id: mongoose.Schema.Types.ObjectId,
26-
receptor_id: mongoose.Schema.Types.ObjectId, // A quien va dirigida esta copia de la clave
27-
clave_envuelta: String, // ChainKey del emisor cifrada con RSA pública del receptor
26+
receptor_id: mongoose.Schema.Types.ObjectId,
27+
clave_envuelta: {
28+
ephPub: { type: String, required: true },
29+
iv: { type: String, required: true },
30+
data: { type: String, required: true },
31+
tag: { type: String, required: true },
32+
_id: false
33+
},
2834
counter: { type: Number, default: 0 }
2935
}],
3036
escaneres_seguridad: {

backend/repositories/ChatRepository.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { convertirObjectId } from '../utils/conversores.js';
77
import { getIDMongodbUsuario, getInvisibleUsuario, setUsuariosSilence, setUsuariosBloqueados, getUsuariosSilence, getUsuariosBloqueados } from '../STORAGE/Variables_sesion.js';
88
import { Añadir_Entrada_Buzon_Usuario } from './BuzonRepository.js';
99
import { descifrarListaMensajes } from '../services/messageCryptoService.js';
10-
import { cifrarConPublica, desencriptarDatosSistema, encriptarDatosSistema } from '../services/cryptoService.js';
10+
import { cifrarConX25519, desencriptarDatosSistema, encriptarDatosSistema } from '../services/cryptoService.js';
1111

1212

1313
const log = createLogger('chat-repo');
@@ -360,7 +360,7 @@ export async function CREAR_CHAT_NUEVO(ids = null, nombre = "", id_chat = null,
360360
ratchet_keys.push({
361361
emisor_id: emisor._id,
362362
receptor_id: receptor._id,
363-
clave_envuelta: cifrarConPublica(chainKey, receptor.publicKey),
363+
clave_envuelta: cifrarConX25519(chainKey, receptor.publicKey),
364364
counter: 0
365365
});
366366
}
@@ -731,7 +731,7 @@ export async function rotarClavesChat(id_chat, id_emisor) {
731731
updates.push({
732732
emisor_id: id_emisor_str,
733733
receptor_id: receptor._id,
734-
clave_envuelta: cifrarConPublica(newChainKey, receptor.publicKey),
734+
clave_envuelta: cifrarConX25519(newChainKey, receptor.publicKey),
735735
counter: 0
736736
});
737737
}

backend/repositories/MessageRepository.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ import { setUsuarioEnCache } from './UserRepository.js';
1414
import { descifrarListaMensajes, getMessageKey } from '../services/messageCryptoService.js';
1515
import {
1616
cifrarContenido,
17-
descifrarConPrivada,
18-
cifrarConPublica,
17+
descifrarConX25519,
18+
cifrarConX25519,
1919
crearCipherStream,
2020
crearDecipherStream,
2121
encriptarDatosSistema,
@@ -66,7 +66,7 @@ export async function ENVIAR_MENSAJE({ asunto = "", archivos = [], id_chat, id_e
6666
// Recibe identity_data como parámetro para no volver a pedirla
6767
async function intentarDescifrado(ent, id_data) {
6868
if (!id_data || !id_data.primary?.privateKey) throw new Error("No Identity keys found locally.");
69-
return descifrarConPrivada(ent.clave_envuelta, id_data.primary.privateKey);
69+
return descifrarConX25519(ent.clave_envuelta, id_data.primary.privateKey);
7070
}
7171

7272
try {
@@ -181,7 +181,7 @@ export async function ENVIAR_MENSAJE({ asunto = "", archivos = [], id_chat, id_e
181181
};
182182

183183
// Crear mensaje y actualizar ratchet en paralelo
184-
const nuevaClave = cifrarConPublica(nextChainKey, usuario.publicKey);
184+
const nuevaClave = cifrarConX25519(nextChainKey, usuario.publicKey);
185185
const [nuevoMensaje] = await Promise.all([
186186
MessagesRavage.create(mensaje),
187187
ChatsRavage.updateOne(

backend/services/controladorArchivos.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,16 +162,15 @@ async function _leerIdentidadLocal() {
162162
async function importarClavePrivada(pemContent, label = '') {
163163
try {
164164
const pem = pemContent.trim();
165-
// Validar que sea una clave privada válida usando Node crypto
166-
const { createPrivateKey } = await import('node:crypto');
167-
createPrivateKey(pem);
165+
const { createPrivateKey, createPublicKey } = await import('node:crypto');
166+
const privKeyObj = createPrivateKey(pem);
167+
const publicKey = createPublicKey(privKeyObj).export({ type: 'spki', format: 'pem' });
168168

169169
const id = _fingerprint(pem);
170170
const current = await _leerIdentidadLocal();
171171

172172
if (!current) {
173-
// Sin identidad previa → esta clave se convierte en la principal
174-
const nuevo = { primary: { id, privateKey: pem, publicKey: '', createdAt: Date.now() }, supportKeys: [] };
173+
const nuevo = { primary: { id, privateKey: pem, publicKey, createdAt: Date.now() }, supportKeys: [] };
175174
await saveIdentityFile(nuevo);
176175
return { ok: true, id, tipo: 'primary' };
177176
}
@@ -180,7 +179,7 @@ async function importarClavePrivada(pemContent, label = '') {
180179
if ((current.supportKeys || []).some(k => k.id === id)) return { ok: false, error: 'Esa clave ya está en la lista de soporte' };
181180

182181
current.supportKeys = current.supportKeys || [];
183-
current.supportKeys.push({ id, privateKey: pem, addedAt: Date.now(), label });
182+
current.supportKeys.push({ id, privateKey: pem, publicKey, addedAt: Date.now(), label });
184183
await saveIdentityFile(current);
185184
return { ok: true, id, tipo: 'support' };
186185
} catch (err) {
@@ -211,6 +210,7 @@ async function cambiarClavePrincipal(keyId) {
211210
current.supportKeys.unshift({
212211
id: oldPrimary.id,
213212
privateKey: oldPrimary.privateKey,
213+
publicKey: oldPrimary.publicKey || '',
214214
addedAt: oldPrimary.createdAt || Date.now(),
215215
label: 'Antigua principal'
216216
});

0 commit comments

Comments
 (0)