Capítulo 8
Mensajes privados
Sobres dentro de sobres: cómo NIP-17 oculta quién habla con quién.
¡Psst! Acércate. Este capítulo va de secretos, así que traje sobres. Muchos sobres.
Todo lo que has visto hasta ahora es público a propósito: notas, likes, listas de seguidos. Pero a
veces Alice solo quiere decirle a Bob dónde verse. Nostr no tiene servidores privados donde
esconderse. Cada
Es más difícil de lo que parece, porque un secreto tiene dos partes:
- Qué dijiste: las palabras.
- Todo lo que lo rodea: quién lo envió, para quién es, cuándo y más o menos cuánto ocupaba.
Esa segunda parte se llama
Tres generaciones de notas secretas
Los mensajes privados de Nostr evolucionaron en tres pasos. Piénsalos así:
- NIP-04: una postal con un candado sobre el texto. Cualquiera puede seguir leyendo la dirección, el remitente y el matasellos.
- NIP-44: un candado mucho mejor. Sigue siendo una postal.
- NIP-17: la postal va dentro de un sobre firmado, y ese sobre va dentro de un paquete marrón sin marcas enviado por un desconocido.
Aquí tienes el mismo mensaje enviado de las tres formas, con
El laboratorio de sobres
Alice le envía a Bob el mismo mensaje de tres formas. Compara lo que puede leer un relay y luego intenta abrir cada sobre con distintas claves.
Lo que ve el relay
Esto es todo lo que hay por fuera del evento, legible por cualquier relay o fisgón.
5 datos de metadatos se filtran a la vista de todos.
- Remitente Filtrado Firmado por Alice: todo el mundo sabe quién lo escribió.
- Destinatario Filtrado La etiqueta p nombra a Bob, para que el relay pueda entregarlo.
- Hora de envío Filtrado Momento exacto: 3 oct 2026, 19:56.
- Tipo de mensaje Filtrado Kind 4: grita «¡esto es un DM!».
- Tamaño del mensaje Filtrado 64 bytes de texto cifrado para un mensaje de 61 bytes: el tamaño lo delata.
Intenta abrirlo
- DM cifrado kind 4 Cerrado
Remitente, destinatario y hora van por fuera.
JSON
{ "kind": 4 ,"created_at": 1791057408 , "tags": [ [ ] ] ,"content": "AxiMAWecYqFbohrMMvszmtmkNoZeqhAGnoVKln7a4I/LpCN7vdKRMjLKbFp+ofHM9bS9AkbhCU+Hb8vHonvIaQ==?iv=CWwoYdZwDhfv2EZQzNlV/w==" ,"pubkey": "e550c6e92808f358382b7b8c14345344f5ef9a299529834d1c26d57b6ace44cc" ,"id": "4074679fdba6a17f0d26fb64c9d1f5f63c43b961029943709e692cfc58c4a0b7" ,"sig": "fbda8485aec78662b3348fcbec3bf08e25c3d73b6ae80cc6fe32575270cac75fcb6009df2aae5e0330f717b1f27f4e8f660f7dd452112e0b196db26f3cc8a9dd" }
Aleatoriedad nueva: nonce nuevo, marcas de tiempo nuevas y, en NIP-17, una clave desechable recién creada.
Cosas que puedes probar:
- Compara las etiquetas rojas en la vista del relay al pasar de NIP-04 a NIP-17.
- Elige a Carol e intenta abrir el sobre. Su clave es perfectamente válida, solo que no es la correcta.
- En NIP-17, presiona Enviar de nuevo varias veces y mira cómo cambia el remitente exterior cada vez.
Paso 1: NIP-04, la postal con fugas
Después Alice revuelve su texto con ese secreto y publica un evento kind 4. Las palabras quedan ocultas, pero mira lo que queda por fuera:
pubkey: la real de Alice.clave pública Clave pública: Tu identidad pública: una clave secp256k1 de 32 bytes, mostrada como 64 caracteres hex o como npub. Puedes compartirla sin problema.- una
etiqueta Tag (etiqueta): Un array de strings adjunto a un evento, como ["p", <pubkey>] para mencionar a alguien o ["e", <id de evento>] para responder a una nota.p: la clave pública de Bob, para que el relay sepa a quién entregarlo. created_at: el segundo exacto en que presionó enviar.- la longitud del texto cifrado, que sigue la longitud del mensaje con un margen de 16 bytes.
Cualquiera puede dibujar un mapa de quién habla con quién, cuándo y cuánto. Por eso NIP-04 está oficialmente obsoleto: la propia especificación advierte que filtra metadatos y que está muy lejos del estado del arte.
NIP-04 no tiene comprobación de integridad. Si Carol prueba su clave, normalmente obtiene un error, pero a veces obtiene basura aleatoria que parece un descifrado correcto. Un buen esquema siempre debería decir “esto no es para ti”.
Bajo el capó: un evento NIP-04 Mostrar detalles
Un evento kind 4. Solo content va cifrado; todo lo demás es público:
{
"kind": 4,
"pubkey": "<pubkey de alice>",
"created_at": 1735689600,
"tags": [["p", "<pubkey de bob>"]],
"content": "J7d9…texto cifrado en base64…==?iv=AQEBAQEBAQEBAQEBAQEBAQ==",
"id": "…",
"sig": "…"
}- Clave: la coordenada x en bruto del punto compartido ECDH, usada directamente como clave AES-256 (sin hash ni derivación de claves).
- Cifrado: AES-256-CBC con un IV aleatorio de 16 bytes y sin MAC, así que las manipulaciones y las claves equivocadas no se detectan de forma fiable.
- Formato:
base64(ciphertext) + "?iv=" + base64(iv).
Especificación: NIP-04 (obsoleto en favor de NIP-17).
import * as nip04 from "nostr-tools/nip04";
// Solo para lo antiguo: lee DMs viejos, no envíes nuevos.
const content = nip04.encrypt(aliceSecretKey, bobPubkey, "Nos vemos a las 9");
const text = nip04.decrypt(bobSecretKey, alicePubkey, content);Paso 2: NIP-44, un candado de confianza
- A prueba de manipulaciones: cada bloque lleva un código de autenticación. Cambia un solo bit, o usa la clave equivocada, y el descifrado se niega rotundamente en vez de devolverte basura.
- Claves nuevas en cada mensaje: un nonce aleatorio hace que cada mensaje use su propio material de claves, así que enviar el mismo texto dos veces nunca da el mismo bloque.
- Relleno: los mensajes se redondean hacia arriba en tramos de tamaño, así que “sí” y “¡ni hablar!” parecen idénticos.
Pruébalo: escribe algo corto, luego algo un poco más largo, y mira qué tamaños se mantienen iguales.
¿Puede un relay adivinar tu mensaje por su tamaño?
Escribe un mensaje y mira cuántos bytes revela cada esquema. NIP-44 redondea los tamaños hacia arriba en tramos para que los mensajes cortos se parezcan.
| Esquema | Tamaño visible |
|---|---|
| Mensaje real | 3 bytes |
| Texto cifrado NIP-04 | 16 bytes |
| NIP-44 con relleno | 32 bytes |
Con NIP-44, cualquier mensaje de 1 a 32 bytes ocupa exactamente esto.
Pero fíjate en lo que no cambió en el laboratorio al pasar de NIP-04 a NIP-44: si metes un bloque NIP-44 en el mismo tipo de evento, la clave de Alice, la etiqueta de Bob y la marca de tiempo siguen por fuera. Un candado mejor no arregla un sobre transparente.
Bajo el capó: NIP-44 v2, paso a paso Mostrar detalles
- Secreto compartido: ECDH entre la clave secreta de Alice y la clave pública de Bob (solo la coordenada x).
- Clave de conversación:
HKDF-extract(SHA-256, ikm = shared_x, salt = "nip44-v2"). Es la misma en ambas direcciones. - Claves del mensaje: elige un nonce aleatorio de 32 bytes, luego
HKDF-expand(conversation_key, info = nonce, 76)y divídelo en una clave ChaCha20 (32 bytes), un nonce ChaCha20 (12) y una clave HMAC (32). - Relleno: antepón al texto UTF-8 su longitud (u16 big-endian) y rellena con ceros hasta el tamaño del tramo (mínimo 32 bytes, luego potencias de dos divididas en fragmentos).
- Cifra los bytes rellenados con ChaCha20.
- Autentica:
HMAC-SHA256(hmac_key, nonce ‖ ciphertext). - Payload:
base64(0x02 ‖ nonce ‖ ciphertext ‖ mac); el0x02inicial es la versión.
Lo que no te da: secreto hacia adelante (forward secrecy; una clave secreta robada abre todos los mensajes pasados) ni ninguna protección de metadatos. Especificación: NIP-44.
import * as nip44 from "nostr-tools/nip44";
const conversationKey = nip44.getConversationKey(aliceSecretKey, bobPubkey);
const payload = nip44.encrypt("Nos vemos a las 9", conversationKey);
// Bob deriva exactamente la misma clave desde su lado:
const text = nip44.decrypt(payload, nip44.getConversationKey(bobSecretKey, alicePubkey));Paso 3: NIP-17, sobres hasta el fondo
- El rumor (kind 14): el mensaje de chat real, escrito por Alice. Es un evento normal con id,
pero sin firma. Es a propósito: si alguna vez se filtra, nadie puede demostrar que lo
escribió Alice. Un
, literalmente.rumor Rumor: Un evento con id pero sin firma: el contenido más interno de un mensaje con gift wrap. - El sello (kind 13): el rumor, cifrado para Bob y firmado por Alice. Así es como Bob sabe
que de verdad es ella. El
no tiene ninguna etiqueta, así que no dice para quién es.sello Sello: La capa intermedia de un mensaje con gift wrap: un evento kind 13 firmado por el remitente real que contiene el rumor cifrado. - El gift wrap (kind 1059): el sello, cifrado otra vez y firmado por una clave aleatoria
recién creada que se usa una vez y se descarta. Lleva una única etiqueta
ppara que el relay pueda entregárselo a Bob.
El relay solo ve la capa 3. El remitente es un desconocido que nunca volverá a publicar, la marca de tiempo está alterada a propósito (hasta dos días en el pasado), el kind real está oculto y el tamaño va rellenado.
Vuelve al laboratorio, elige NIP-17 y ábrelo capa a capa como Bob: wrap, luego sello, luego rumor. Cuando se abre el sello, tu navegador comprueba que quien firma el sello es la misma clave que el autor del rumor; si no, cualquiera podría falsificar un mensaje “de Alice”.
El wrap tiene que decir para quién es, así que el relay se entera de que Bob recibió algo.
NIP-17 pide a los relays que solo entreguen los gift wraps a la persona a la que van dirigidos,
después de que Bob demuestre quién es con la autenticación
Bajo el capó: las tres capas en JSON Mostrar detalles
Rumor, kind 14, sin firmar (sin sig):
{
"id": "…",
"pubkey": "<pubkey de alice>",
"created_at": 1735689600,
"kind": 14,
"tags": [["p", "<pubkey de bob>"]],
"content": "Nos vemos a las 9"
}Sello, kind 13, firmado por Alice, las etiquetas deben estar vacías:
{
"kind": 13,
"pubkey": "<pubkey de alice>",
"created_at": 1735601234,
"tags": [],
"content": "<NIP-44(JSON del rumor), Alice → Bob>",
"id": "…",
"sig": "…"
}Gift wrap, kind 1059, firmado por una clave aleatoria de un solo uso:
{
"kind": 1059,
"pubkey": "<pubkey desechable aleatoria>",
"created_at": 1735555555,
"tags": [["p", "<pubkey de bob>"]],
"content": "<NIP-44(JSON del sello), clave desechable → Bob>",
"id": "…",
"sig": "…"
}Reglas que conviene conocer:
- Los receptores deben comprobar que la
pubkeydel sello es igual a lapubkeydel rumor. - El
created_atdel sello y del wrap debería aleatorizarse hasta dos días en el pasado. - El remitente también envuelve una copia para sí mismo, para poder recuperar sus propios mensajes enviados en cualquier dispositivo. Los chats de grupo envuelven una copia por participante.
- El kind 15 es el hermano del kind 14 para mensajes con archivos; las etiquetas
subjectdan nombre a una conversación. - Los DMs van a los relays de la lista kind 10050 del destinatario.
Especificaciones: NIP-17 (DMs privados) y NIP-59 (gift wrap).
import * as nip17 from "nostr-tools/nip17";
// Una sola llamada construye rumor → sello → wrap con una clave desechable nueva.
const wrap = nip17.wrapEvent(aliceSecretKey, { publicKey: bobPubkey }, "Nos vemos a las 9");
// Bob abre ambas capas y recupera el rumor kind 14.
const rumor = nip17.unwrapEvent(wrap, bobSecretKey);
console.log(rumor.kind, rumor.content); // 14 "Nos vemos a las 9"NIP-17 oculta mucho, pero no es un mensajero seguro completo: no hay secreto hacia adelante, así que
si tu
Comprobación rápida
Revisa tus sobres
Lo esencial
- Cifrar el texto no basta: quién, cuándo y cuánto ocupa pueden decir tanto como el qué.
- NIP-04 está obsoleto: oculta las palabras, pero deja a la vista remitente, destinatario, hora y tamaño.
- NIP-44 es el candado moderno (ChaCha20 + HMAC + relleno), pero por sí solo no oculta los metadatos.
- NIP-17 mete un rumor kind 14 dentro de un sello kind 13 dentro de un gift wrap kind 1059 firmado por una clave desechable.
- El relay sigue sabiendo quién es el destinatario, por eso los relays de DM solo deberían entregar los wraps a su dueño después de AUTH.