Mensajes directos y privacidad

Por: Artiko
buzznostrdmprivacidadnip-17gift-wrapcli

Mensajes directos y privacidad

Este capítulo trata de la conversación que no ocurre en un canal. En Buzz eso tiene dos formas técnicas distintas, con garantías distintas, y confundirlas es el error más caro que puedes cometer en un workspace. Vamos a separarlas primero y a explicarlas después.

Los capítulos anteriores cubrieron canales y mensajes y hilos, reacciones y actividad. Todo lo que aprendiste allí sobre enviar, editar, responder en hilo y reaccionar sigue aplicando dentro de un DM, porque un DM de Buzz es un canal. La diferencia está en cómo se crea, quién puede entrar y qué garantías de confidencialidad ofrece.

Lo esencial, dicho de frente

Antes de cualquier detalle, la afirmación que debes memorizar:

El mensaje directo que usas en la aplicación de escritorio no está cifrado extremo a extremo. Es un canal privado de dos a nueve personas cuyo contenido se almacena en la base de datos del relay igual que el de cualquier otro canal. El operador del relay puede leerlo.

El cifrado extremo a extremo existe en Buzz, pero por otro camino: el gift wrap NIP-17, evento de kind 1059, que el relay acepta y reenvía sin poder leerlo. Ese camino lo usan clientes Nostr de terceros conectados por WebSocket, no la interfaz de escritorio ni el buzz-cli.

Las dos cosas se llaman «DM» en el lenguaje cotidiano. No son lo mismo.

flowchart TD
    A["Quieres hablar en privado"] --> B{"¿Qué camino usas?"}
    B -->|"App de escritorio o buzz-cli"| C["Canal de tipo dm"]
    B -->|"Cliente Nostr por WebSocket"| D["Gift wrap NIP-17 kind 1059"]
    C --> C1["Contenido en claro en Postgres"]
    C --> C2["Visible para el operador del relay"]
    C --> C3["Indexado en la búsqueda del workspace"]
    D --> D1["Contenido cifrado, opaco para el relay"]
    D --> D2["El relay ve destinatario, tamaño y hora"]
    D --> D3["Excluido del índice de búsqueda"]

Parte 1: el DM como canal

Qué es exactamente

En el modelo de datos de Buzz, un mensaje directo es un canal con channel_type = 'dm' y visibility = 'private'. El tipo Dm convive con Stream, Forum y Workflow en la misma enumeración de tipos de canal. No hay una tabla aparte ni un almacenamiento especial: es la tabla channels de siempre, con una columna de tipo distinta.

Eso tiene una consecuencia directa y práctica: todo lo que sabes hacer en un canal funciona en un DM. Puedes responder en hilo, reaccionar con emoji, adjuntar archivos, pegar un diff, abrir un huddle y escribir en su canvas.

El conjunto de participantes es inmutable

Esta es la regla que más sorprende a quien llega de Slack. La documentación del módulo de persistencia lo dice en una línea:

«DMs are channels with channel_type='dm' and visibility='private'. Participant sets are immutable — adding a member creates a NEW DM.»

El relay identifica cada DM por una huella SHA-256 del conjunto de participantes: ordena las claves públicas lexicográficamente, elimina duplicados y las concatena antes de hashear. Como el orden se normaliza, el mismo grupo de personas siempre produce la misma huella, sin importar quién abre la conversación ni en qué orden se listen las claves.

De ahí salen tres comportamientos que conviene tener claros:

  1. Abrir un DM que ya existe no crea nada. El relay busca la huella, la encuentra y devuelve el canal existente. La respuesta trae created: false.
  2. Reabrir un DM que ocultaste lo hace reaparecer. La operación de apertura limpia tu marca de ocultamiento antes de devolver el canal.
  3. Añadir a alguien crea una conversación nueva. No se «expande» el grupo: se calcula la huella del conjunto ampliado y se abre el DM correspondiente. El historial anterior se queda donde estaba, con las personas que estaban.
sequenceDiagram
    participant U as Tú
    participant R as Relay
    participant DB as Base de datos
    U->>R: kind 41010 con p-tags de participantes
    R->>R: Valida 1 a 8 destinatarios
    R->>R: Calcula huella SHA-256 del conjunto
    R->>DB: Busca canal por huella
    alt Ya existe
        DB-->>R: Canal encontrado
        R->>DB: Limpia hidden_at para quien abre
        R-->>U: channel_id existente, created false
    else No existe
        R->>DB: Crea canal dm privado
        R->>R: Emite kind 39000 con tag hidden
        R->>R: Emite notificaciones kind 44100
        R-->>U: channel_id nuevo, created true
    end

El límite es nueve

Un DM admite como máximo nueve participantes: tú más ocho. El límite se comprueba en tres capas independientes, y cada una tiene su propio mensaje de error, cosa que ayuda a diagnosticar de dónde viene el rechazo:

CapaCondiciónMensaje
buzz-climenos de 1 o más de 8 claves--pubkey: must provide 1-8 pubkeys
Relay, al abrirmás de 8 destinatariosinvalid: pubkeys may contain at most 8 other participants (9 total)
Relay, al añadirel conjunto ampliado supera 9invalid: DM supports at most 9 participants

La documentación de visión del proyecto lo resume en la tabla de superficies: «DMs — 1:1 and group. Up to 9.»

Ocultar no es borrar

Puedes quitar un DM de tu barra lateral. Eso publica un evento kind 41012 (KIND_DM_HIDE) y el relay marca hidden_at = NOW() en tu fila de membresía, no en el canal. Los demás participantes no notan nada: para ellos la conversación sigue exactamente igual.

Consecuencias concretas:

  • El contenido no se borra. Sigue en la base de datos y sigue siendo tuyo.
  • No hay comando de «des-ocultar». La forma de recuperarlo es abrir de nuevo el DM con el mismo conjunto de participantes; la apertura limpia hidden_at automáticamente.
  • El relay publica un resumen de lo que ocultaste. Es el kind 30622 (KIND_DM_VISIBILITY), firmado por el relay, con un tag h por cada DM que tienes oculto. Está pensado para que tus dispositivos vean lo mismo, y está protegido: solo tú puedes leer tu propio snapshot, ni siquiera conociendo su identificador de evento.

Parte 2: manejarlos desde la aplicación

Empezar una conversación

Hay dos caminos en la aplicación de escritorio, ambos presentes en el código real de desktop/:

  • Pantalla de mensaje nuevo. Es una superficie con forma de conversación: la cabecera del chat se convierte en un campo «To:» y los destinatarios se buscan en un popover adjunto. El límite de destinatarios seleccionables está fijado en 8, coherente con el límite del protocolo.
  • Desde el perfil de una persona. El popover de perfil incluye una acción de mensaje que abre directamente el DM 1:1 con esa clave pública.

Mencionar a alguien que no está en la conversación

Aquí la inmutabilidad del conjunto se hace visible en la interfaz. Si estás en un DM y mencionas a alguien que no es participante, la aplicación prepara un DM nuevo con el conjunto ampliado antes de enviar: recoge los participantes actuales, añade los nuevos, elimina duplicados y tu propia clave, y abre la conversación resultante. El mensaje se envía allí.

No es un fallo: es la única forma coherente de comportarse cuando el conjunto de participantes es la identidad de la conversación.

Menciones a agentes dentro de un hilo de DM

Los hilos dentro de un DM tienen una restricción propia y explícita. La aplicación rechaza mencionar a un agente que no esté ya en la conversación, con este texto literal:

Agents must already be in a DM to be mentioned in its threads.
Start a new conversation that includes the agent.

Y si las menciones son de persona (las que crearían un agente nuevo), se rechazan siempre. La razón está escrita en el propio código: el conjunto de participantes de un DM está fijado en su creación, así que una respuesta de hilo solo puede mencionar agentes que ya estén dentro. Sobre agentes hay un capítulo entero: agentes como compañeros de equipo.

Notificaciones

Un DM te notifica menos de lo que crees, y de forma deliberada. La suscripción de un DM recibe todos los eventos con tag h de ese canal: borrados, reacciones, ediciones, eventos administrativos. Antes de disparar una notificación del sistema, la aplicación filtra a los tipos que un humano considera «mensaje»: los tipos de mensaje de canal, más el evento de huddle iniciado, que se incluye solo en DMs porque ahí la tarjeta de inicio es la invitación.

El modelo de notificaciones por superficie del proyecto define los DM como «URGENT only». Los huddles se tratan en el capítulo 7.

El DM de moderación

Hay un DM que se comporta distinto: la conversación 1:1 entre un miembro y la identidad propia del relay. Es el canal que la moderación usa para explicar una acción. En ese canal, y solo en ese, el compositor aparece deshabilitado: no puedes responder.

La identificación es del lado del cliente y explícitamente «best-effort»: la aplicación compara el único otro participante contra la clave pública self que el relay anuncia en su documento NIP-11. Si el relay no está accesible o no anuncia self, la comprobación falla en abierto y el compositor se queda habilitado. Es una comodidad visual, no una barrera: quien decide qué hace una escritura es el relay.

Todo lo relativo a moderación, sanciones y auditoría es terreno del operador y está cubierto en el curso de administrador.

Parte 3: manejarlos desde el CLI

El grupo dms del buzz-cli tiene exactamente cuatro subcomandos. Ni uno más.

SubcomandoFlagsQué hace
list--limit <u32>Lista conversaciones de DM
open--pubkey <HEX64> repetible, de 1 a 8Abre o recupera un DM
add-member--channel <UUID>, --pubkey <HEX64>Añade una clave, creando un DM nuevo
hide--channel <UUID>Oculta el DM de tu lista

Recuerda del capítulo 10 que toda operación contra el relay necesita BUZZ_PRIVATE_KEY, que la salida es JSON por stdout y los errores JSON por stderr.

Abrir y escribir

export BUZZ_RELAY_URL="https://buzz.miempresa.com"
export BUZZ_PRIVATE_KEY="nsec1..."

# 1. Abrir el DM y quedarse con el UUID del canal
DM_ID=$(buzz dms open \
  --pubkey "aabb...64hex" \
  | jq -r '.dm_id')

echo "$DM_ID"
# 3f2c9a10-8d4e-4c0b-9f31-5a7c2e1b0d44

# 2. Escribir dentro: es un canal, así que se usa messages send
buzz messages send \
  --channel "$DM_ID" \
  --content "Te paso el contexto del incidente de anoche."

La respuesta de dms open es la respuesta normalizada de escritura con un campo dm_id añadido:

{
  "event_id": "9c1f...",
  "accepted": true,
  "message": "response:{\"channel_id\":\"3f2c9a10-...\",\"created\":true}",
  "dm_id": "3f2c9a10-8d4e-4c0b-9f31-5a7c2e1b0d44"
}

Si el DM ya existía, created viene en false y dm_id es el mismo de siempre. Ese comportamiento hace que dms open sea seguro de ejecutar en un script: es idempotente por conjunto de participantes.

Leer, ampliar y ocultar

# Leer los últimos mensajes del DM
buzz messages get --channel "$DM_ID" --limit 50

# Responder en hilo dentro del DM
buzz messages send \
  --channel "$DM_ID" \
  --reply-to "<event-id>" \
  --content "Confirmado, lo despliego yo."

# Añadir a alguien: OJO, esto crea una conversación NUEVA
buzz dms add-member --channel "$DM_ID" --pubkey "ccdd...64hex"

# Sacar el DM de tu lista
buzz dms hide --channel "$DM_ID"

Sobre add-member, el relay comprueba tres cosas antes de actuar: que tú seas miembro del DM (forbidden: not a member of this DM si no), que el canal sea realmente de tipo dm (invalid: channel is not a DM) y que el conjunto resultante no supere nueve participantes. Después ejecuta internamente la misma apertura de siempre con el conjunto ampliado, que crea un canal nuevo.

Una limitación real de dms list

Hay que decirlo con claridad: buzz dms list consulta eventos de kind 41001 filtrados por tu clave pública, y en el código del relay nada emite hoy ese kind. La constante existe en el registro de kinds y el CLI la consulta, pero no hay productor. En la práctica es muy probable que recibas un array vacío.

La alternativa que sí funciona hoy pasa por el descubrimiento estándar de grupos: los DM emiten metadatos kind 39000 (con un tag hidden que los marca como DM) y eventos de miembros kind 39002, que es exactamente lo que consulta el listado de canales por membresía.

# Todos los canales de los que eres miembro, DMs incluidos
buzz channels list --member

# Detalle de uno concreto, para ver tipo y participantes
buzz channels get --channel "$DM_ID"

Parte 4: el gift wrap NIP-17

Este es el camino cifrado. No lo produce la aplicación de escritorio ni el buzz-cli: lo produce un cliente Nostr que hable NIP-17 y se conecte por WebSocket al relay. El capítulo 13 cubre en detalle cómo conectar clientes de terceros.

Qué hace el relay con un gift wrap

La tabla de conformidad de Buzz marca los DM NIP-17 como soportados, con estas propiedades exactas:

  • Se aceptan eventos de kind 1059 firmados con claves de firma efímeras.
  • Se almacenan de forma community-global: sin canal asociado (channel_id = None) dentro de la comunidad a la que estás conectado.
  • Se entregan por suscripciones filtradas por #p.
  • No se indexan en la búsqueda.

La clave efímera obliga a una excepción interesante. El relay normalmente rechaza cualquier evento cuya clave pública no coincida con la identidad autenticada de la conexión, con el mensaje invalid: event pubkey does not match authenticated identity. El gift wrap es la única excepción a esa regla, precisamente porque su firma exterior es desechable y no debe identificar al remitente.

Cómo se leen

Con cualquier cliente que hable NIP-01 y NIP-42. El ejemplo canónico de la documentación de Buzz usa nak:

# Traer los DM gift-wrapped dirigidos a ti
nak req -k 1059 --tag "p=<tu-pubkey-hex>" \
  --auth --sec <privkey> ws://localhost:3000

El filtro #p no es opcional. El relay clasifica el kind 1059 como p-gated: una suscripción global que pueda casar con ese kind debe incluir un filtro #p cuyos valores sean todos tu clave autenticada. Si lo omites o incluyes la clave de otra persona, la suscripción se cierra con:

restricted: p-gated events require #p matching your pubkey

La lista completa de kinds p-gated es 24200, 44100, 44101, 1059, 30622 y 44200. Está diseñada para impedir que nadie escuche los DM ajenos ni los cambios de membresía ajenos.

Qué se cifra y qué no

flowchart LR
    subgraph Opaco["Opaco para el relay"]
        A["Contenido del mensaje"]
        B["Identidad real del remitente"]
        C["Marca de tiempo interna"]
    end
    subgraph Visible["Visible para el relay"]
        D["Kind 1059"]
        E["Clave efímera de firma"]
        F["Tag p con el destinatario"]
        G["created_at exterior"]
        H["Tamaño del evento"]
    end
    Opaco --> S["Sobre gift wrap"]
    S --> Visible

El comentario del propio registro de kinds describe el kind 1059 como el «sobre exterior para DMs privados» que «oculta remitente, contenido y marca de tiempo». Lo que no oculta, y conviene asumir sin ilusiones:

  • A quién escribes. El tag p con la clave pública del destinatario es público por construcción: el relay lo necesita para enrutar.
  • Cuándo. El created_at exterior y el momento de llegada al relay.
  • Cuánto. El tamaño del evento cifrado, que correlaciona con la longitud del mensaje.
  • Con qué frecuencia. El patrón temporal de tus envíos.

Ese es el análisis de metadatos clásico. El cifrado protege el contenido, no la existencia de la conversación.

Tres detalles operativos que importan

  1. Solo por WebSocket. El relay rechaza el kind 1059 (y el de presencia) si llega por el puente HTTP: invalid: kind 1059 is only accepted via WebSocket. Como el buzz-cli trabaja contra el relay por HTTP, no puede enviar gift wraps. Esto no es una opinión, es la consecuencia de dos líneas de código.
  2. No disparan workflows. El motor de automatización se salta explícitamente los gift wraps: no puedes escribir un workflow que reaccione al contenido de un DM cifrado, porque el relay no lo tiene. Ver el capítulo 11.
  3. NIP-04 y NIP-44 no están implementados como transportes de DM, y el kind 10050 (lista de relays de DM) está diferido. Si tu cliente favorito solo habla NIP-04, no va a funcionar.

Parte 5: qué ve el operador del relay

Esta sección es la razón de ser del capítulo. Sin adornos.

ElementoDM como canalGift wrap NIP-17
Contenido del mensajeEn claro en la tabla eventsCifrado, opaco
Identidad del remitenteVisibleOculta tras clave efímera
Identidad del destinatarioVisible en la membresía del canalVisible en el tag p
Hora y tamañoVisiblesVisibles
AdjuntosBlobs en el almacenamiento de mediaIgual, si el cliente los sube ahí
Índice de búsqueda: los mensajes son kind 9, dentro de la lista blancaNo: search_tsv a NULL
Copias de seguridadIncluye el contenidoIncluye solo el cifrado

Sobre la exclusión de búsqueda, el detalle exacto: el índice de texto completo se construye sobre una columna generada de la tabla events, y en una instalación nueva esa columna solo se calcula para una lista blanca de kinds — 0, 9, 40002, 45001 y 45003. Todo lo demás, gift wraps 1059 incluidos, queda a NULL. Un tsvector nulo nunca casa, así que esos eventos son inbuscables a nivel de almacenamiento, no por una comprobación de permisos que alguien pueda saltarse. El capítulo 8 explica el resto del sistema de búsqueda.

Fíjate en la asimetría: los mensajes de tus DM de la aplicación sí entran en el índice, porque son eventos de canal normales. Eso es útil para ti (los encuentras con buzz messages search) y honesto de reconocer respecto a quién más los tiene delante.

Lo que gobierna el acceso es una sola cosa, según el modelo de seguridad del proyecto: «Channel membership is the only gate — enforced by the relay at every operation». El relay comprueba el acceso antes de registrar una suscripción, sin ventana de carrera. Esa es una buena garantía frente a otros miembros del workspace. No es una garantía frente a quien administra la base de datos.

Cómo se despliega, se cifra en reposo, se respalda y se audita ese relay es materia de operador. Está cubierto en el curso de administrador, y ahí es donde debes ir si necesitas saber quién tiene acceso al Postgres de tu workspace.

Parte 6: canal privado contra DM

Ambos son «privados». Las garantías no son idénticas.

DimensiónCanal privadoDM
Tipostream o forum, visibilidad privatetipo dm, visibilidad private
MembresíaMutable: se añade y se quita genteInmutable: el conjunto define el canal
Quién invitaOwner o admin del canalNadie invita; se abre con el conjunto entero
TamañoSin tope funcional propio de DMMáximo 9 personas
NombreTiene nombre y descripciónSe rotula con los nombres de los participantes
Descubrimientokind 39000 con tag privatekind 39000 con tag hidden
Salirchannels leaveNo existe: se oculta con dms hide
Historial al entrarVes el canal desde que te añadenNo aplica: naciste dentro
Cifrado del contenidoNoNo

La diferencia de garantía que más importa es la de membresía. En un canal privado, un owner o admin puede añadir a una persona mañana, y esa persona pasará a formar parte de la sala. En un DM eso es estructuralmente imposible: añadir a alguien produce otra conversación, con otro identificador, y la anterior queda intacta y cerrada.

Si lo que necesitas es «esta conversación no puede crecer sin que quede constancia de que empezó otra», el DM te lo da y el canal privado no. Si lo que necesitas es cifrado extremo a extremo, ninguno de los dos te lo da: eso es el gift wrap.

Parte 7: limitaciones conocidas

Lista honesta, toda verificable en el repositorio.

  1. Los DM de la app no son E2E. Ya está dicho arriba, pero es la limitación número uno y merece repetirse.
  2. buzz dms list puede devolver vacío. Consulta el kind 41001, que hoy no tiene productor en el relay. Usa buzz channels list --member.
  3. El CLI no puede enviar gift wraps. El kind 1059 solo se acepta por WebSocket, y el CLI habla HTTP.
  4. No hay comando de des-ocultar. Reabrir el DM con los mismos participantes es el único camino.
  5. La descarga de media exige membresía de comunidad, no de canal. La lectura de un blob se autentica con una prueba firmada Blossom y comprueba que seas miembro de la comunidad. No comprueba que seas miembro del canal donde se compartió. Quien conozca el SHA-256 de un archivo y tenga cuenta en el workspace puede descargarlo. Trata los adjuntos de un DM como confidenciales frente al exterior, no frente a tus compañeros.
  6. NIP-04 y NIP-44 no implementados; kind 10050 diferido. Limita qué clientes de terceros sirven.
  7. Los clientes móviles están en construcción. La tabla del proyecto los sitúa en la columna «being wired up», junto a las compuertas de aprobación de workflows y los eventos de ciclo de vida de huddles. No planifiques alrededor de ellos todavía.
  8. No hay borrado real. Ocultar marca tu fila de membresía. El contenido permanece.

Parte 8: rutina práctica

Un puñado de hábitos que evitan sustos:

  • Decide por defecto en canal, no en DM. Un DM de cuatro personas es una decisión que nadie más podrá encontrar después. Si la conversación produce un acuerdo, súbelo a un canal.
  • Usa el DM para lo que realmente es privado entre personas, no para lo que es simplemente incómodo de publicar.
  • Asume que el operador puede leer. Si algo no debería ser legible por quien administra la infraestructura, no va en un DM de Buzz: va por un canal con cifrado extremo a extremo, o no va.
  • Ojo al ampliar. Antes de mencionar a alguien nuevo dentro de un DM, recuerda que estás abriendo una conversación distinta y que el nuevo no verá el historial anterior. Si necesitas que lo vea, reenvía lo relevante a mano.
  • Comprueba con quién estás hablando. Los nombres de perfil no son únicos. La clave pública sí. buzz users get --pubkey <hex> resuelve dudas.
  • Los adjuntos sensibles merecen otro canal de transporte. Ver la limitación 5.

Resumen

  • Un DM de Buzz es un canal con channel_type = 'dm' y visibility = 'private'. Su contenido no está cifrado extremo a extremo y el operador del relay puede leerlo.
  • El conjunto de participantes es inmutable y se identifica por una huella SHA-256 ordenada: reabrirlo devuelve el mismo canal; añadir a alguien crea una conversación nueva. Máximo nueve personas.
  • Ocultar un DM (kind 41012) marca tu fila de membresía y publica un snapshot kind 30622 que solo tú puedes leer. No borra nada, y se deshace reabriendo.
  • El grupo dms del CLI tiene cuatro subcomandos: list, open, add-member y hide. Para escribir dentro se usa buzz messages send --channel <uuid>. dms list puede venir vacío: usa channels list --member.
  • El cifrado extremo a extremo real es el gift wrap NIP-17, kind 1059: claves de firma efímeras, almacenamiento sin canal, entrega por #p, fuera del índice de búsqueda, solo por WebSocket y no dispara workflows.
  • Un gift wrap no oculta metadatos: destinatario, hora, tamaño y frecuencia quedan visibles para el relay.
  • Frente a un canal privado, el DM gana en inmutabilidad de la membresía y pierde en gestionabilidad. Ninguno de los dos cifra el contenido.
  • Limitaciones vigentes: kind 41001 sin productor, media accesible por membresía de comunidad y no de canal, NIP-04 y NIP-44 ausentes, kind 10050 diferido y clientes móviles en construcción.

Siguiente: Medios, canvases y huddles