Identidad y autenticación: claves, NIP-42 y NIP-98

Por: Artiko
buzznostrnip-42nip-98seguridadidentidadrate-limiting

Identidad y autenticación: claves, NIP-42 y NIP-98

Levantaste un relay, lo configuraste y lo pusiste en producción sin crear una sola contraseña, un formulario de registro ni un proveedor de identidad. Este capítulo explica por qué: en Buzz la identidad es una clave criptográfica y la autenticación es una firma. El crate que lo implementa se documenta a sí mismo en una línea (crates/buzz-auth/src/lib.rs):

Sin validación de JWT, sin gestión de tokens, sin dependencia en tiempo de ejecución de un IdP.

El modelo de identidad: un par de claves y nada más

Cada participante — humano, agente o el relay mismo — es un par de claves secp256k1, la misma curva de Bitcoin. La clave pública es un valor x-only de 32 bytes, 64 caracteres hex en minúsculas.

FormaQué esDónde aparece
npub1...Clave pública en bech32 (NIP-19)buzz-admin add-member --pubkey npub1...
nsec1...Clave privada en bech32Clientes y agentes. Nunca sale del dispositivo
hex de 64 caracteresLa misma pubkey sin codificarRELAY_OWNER_PUBKEY, pubkey_allowlist, tags p

buzz-admin acepta indistintamente npub1... o hex donde pide una pubkey; las herramientas de git aceptan hex o nsec1... donde piden una clave privada. Un evento Nostr es JSON firmado: el campo id es el SHA-256 de una serialización canónica y sig es una firma Schnorr (BIP-340) sobre ese id. La función verify_event() de buzz-core comprueba las dos cosas por separado — un id reescrito no pasa aunque la firma sea válida. Es CPU-bound, así que el relay la ejecuta dentro de spawn_blocking.

flowchart LR
    cli["Cliente firma con nsec"] --> ev["Evento JSON con id y sig"]
    ev --> relay["buzz-relay"]
    relay --> v1["verify_event: firma Schnorr BIP-340"]
    relay --> v2["verify_event: SHA-256 de la serialización canónica"]
    v1 --> ok{"¿Ambas OK?"}
    v2 --> ok
    ok -->|sí| store["Pipeline de ingesta"]
    ok -->|no| rej["Rechazo: invalid signature"]

Consecuencia operativa: el relay nunca conoce un secreto del usuario. No hay base de contraseñas que filtrar ni sesiones que revocar. La contracara es que quien pierde su nsec pierde su identidad.

Dónde viven las claves privadas

Buzz fija un orden de precedencia claro (SECURITY.md):

  1. BUZZ_PRIVATE_KEY — cuando está puesta, siempre gana sobre los demás almacenes. Es el camino por el que agentes con harness y jobs de CI reciben su identidad.
  2. Keyring del sistema operativo — la app de escritorio guarda ahí los nsec: macOS Keychain, Windows Credential Manager, Secret Service de Linux vía D-Bus. Cubre la identidad humana y cada clave de agente gestionado.
  3. Archivo 0o600 solo-propietario — respaldo cuando no hay keyring, por ejemplo Linux headless.

La migración de claves planas al keyring importa la clave, la relee para verificar el round-trip y solo entonces borra el texto plano. Si el keyring no está alcanzable esa sesión, no migra: así una caída transitoria no puede resucitar una clave rotada desde un archivo remanente. Para generar una identidad desde el servidor, buzz-admin generate-key imprime la pubkey y el secreto e indica fijar BUZZ_PRIVATE_KEY.

El relay tiene su propia identidad en BUZZ_RELAY_PRIVATE_KEY, con la que firma los eventos de discovery de grupos (39000/39001/39002), las notificaciones de membresía y los mensajes de sistema. Si no la fijas se genera una clave aleatoria en cada arranque y esos eventos dejan de ser verificables tras un reinicio: en producción, fíjala. Ver Configuración: referencia de variables de entorno.

NIP-42: autenticación en WebSocket

Toda conexión WebSocket pasa por NIP-42, y el relay emite el desafío proactivamente apenas acepta la conexión.

sequenceDiagram
    participant C as Cliente
    participant R as buzz-relay
    participant DB as Postgres
    C->>R: WebSocket upgrade
    R->>R: resolver comunidad desde el host y adquirir permiso del semáforo
    R->>C: AUTH challenge de 32 bytes CSPRNG en hex
    C->>C: firmar evento kind 22242 con tags challenge y relay
    C->>R: AUTH evento firmado
    R->>R: verify_nip42_event - kind, firma, challenge, relay URL, timestamp
    R->>DB: allowlist y membresía si están habilitadas
    DB-->>R: veredicto
    R->>C: OK true - estado Authenticated

generate_challenge() produce 32 bytes de CSPRNG en hex: 64 caracteres. El cliente responde con un evento kind:22242 con un tag challenge y un tag relay. verify_nip42_event() comprueba, en orden: kind 22242, firma Schnorr y hash del id, coincidencia del challenge, coincidencia de la URL del relay normalizada, y created_at dentro de ±60 segundos.

La normalización de URL genera falsos negativos si no la conoces: se parsea con el crate url, se convierten localhost y ::1 a 127.0.0.1 y se recorta la barra final. Así ws://localhost:3000 y ws://127.0.0.1:3000/ son equivalentes, pero ws:// y wss:// no lo son. Si pones TLS delante, RELAY_URL debe reflejar la URL pública real o toda autenticación falla con RelayUrlMismatch. Y la ventana de ±60 s, que es la defensa contra replay, tiene como contracara que un reloj desincronizado rompe la autenticación por completo: es lo primero que hay que revisar cuando todos los clientes fallan de golpe.

Estado de la conexión y scopes

pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), Failed }
pub struct AuthContext {
    pub pubkey: nostr::PublicKey,
    pub scopes: Vec<Scope>,
    pub channel_ids: Option<Vec<uuid::Uuid>>,
    pub auth_method: AuthMethod,            // Nip42 | Nip98
    pub agent_owner_pubkey: Option<nostr::PublicKey>,
}

Una conexión NIP-42 autenticada recibe Scope::all_known(): en el código actual son 16 scopes — los catorce de mensajes, canales, usuarios, jobs, suscripciones y archivos, más ReposRead y ReposWrite. Conviene marcarlo porque ARCHITECTURE.md todavía dice “los 14 scopes”; el código es la fuente de verdad. ReposRead está declarado como reservado y no lo aplican las rutas HTTP de git, que usan NIP-98 directamente. Con todo, los scopes no son el control de acceso real: SECURITY.md es explícito en que la membresía de canal es la única compuerta, y quien no es miembro es rechazado aun estando autenticado. Ver Membresía, roles y moderación.

Dos invariantes que conviene tener presentes. Los eventos AUTH nunca se almacenan ni se auditan: no van a Postgres, no entran a la cadena de buzz-audit, no aparecen en consultas históricas — el motivo declarado en nip42.rs es que pueden contener tokens portadores. Y auth_required en NIP-11 es siempre true, porque los handlers de REQ, EVENT y COUNT rechazan toda conexión que no esté en AuthState::Authenticated; es independiente del toggle BUZZ_REQUIRE_AUTH_TOKEN.

NIP-98: autenticación en REST

NIP-98 es el patrón estándar de HTTP Auth de Nostr, el mismo que usan Blossom y otros servicios. Es stateless: el cliente firma un evento kind:27235 de vida corta y lo envía como Authorization: Nostr <base64(JSON del evento)>. verify_nip98_event() ejecuta ocho pasos documentados en el módulo: parsear el JSON, verificar kind == 27235, verificar la firma Schnorr, verificar created_at dentro de ±60 s, verificar que el tag ["u", <url>] coincida con la URL esperada normalizada, verificar el tag ["method", <metodo>] sin distinguir mayúsculas, verificar —si hay tag payload y el llamador pasó el cuerpo— que SHA-256(cuerpo) coincida, y devolver la pubkey.

Dos detalles que muerden en producción: el tag es la letra u, no url; y detrás de un proxy inverso hay que reconstruir la URL canónica desde X-Forwarded-Proto y X-Forwarded-Host antes de compararla. Si el relay ve http://127.0.0.1:3000/query pero el cliente firmó https://relay.example.com/query, el paso de la URL falla.

Protección contra replay

La verificación NIP-98 es estructuralmente completa pero no comprueba si ese event_id ya se usó: eso requiere estado compartido, y con varios pods una caché en proceso no sirve. buzz-auth define el trait Nip98ReplayGuard; la implementación de producción vive en buzz-pubsub sobre SET NX EX.

ReglaValor u obligación
TTL mínimo (DEFAULT_REPLAY_TTL_SECS)120 s — el doble de la tolerancia de ±60 s
TTL máximo (MAX_REPLAY_TTL_SECS)3600 s — valores mayores se recortan, no se aceptan
Operaciónset-if-absent atómico; leer-luego-escribir pierde ante concurrencia
Ámbito de la clavePor comunidad (nip98_replay_key)
Ante error de RedisFallar cerrado — rechazar, nunca “best effort”
OrdenVerificar primero, marcar después

El último punto es sutil y está documentado: si marcas antes de verificar, quien conozca un event_id futuro de una víctima puede quemar el slot y hacer DoS al evento legítimo. Y con replicaCount: 3 el seen-set en proceso no basta: o usas routing sticky por un header estable, o —recomendado— el seen-set compartido en Redis. El default del chart Helm es replicaCount: 1, que sí satisface la compuerta.

El atajo de desarrollo X-Pubkey

El puente HTTP acepta la cabecera X-Pubkey: <hex> solo cuando BUZZ_REQUIRE_AUTH_TOKEN=false. No hay firma que verificar, así que tampoco hay verificación de replay: el event id se sustituye por un hash de ceros. El relay lo advierte al arrancar con BUZZ_REQUIRE_AUTH_TOKEN is false — REST API requests bypass token auth. WebSocket protocol auth is unaffected. Set to true for production. — nota que dice explícitamente que la autenticación del protocolo WebSocket no se ve afectada. Además, los endpoints de operador (api/operator.rs) y los de invitaciones (api/invites.rs) siempre exigen NIP-98, sin fallback.

Cuál se usa y cuándo

NIP-42NIP-98
Kind y transporte22242, WebSocket27235, HTTP
ModeloChallenge/respuesta con estado por conexiónStateless, una firma por petición
Qué firma el clientechallenge + URL del relayURL + método + hash opcional del cuerpo
Dónde viajaMensaje ["AUTH", <evento>]Cabecera Authorization: Nostr <base64>
ReplayChallenge de un solo uso por conexiónSeen-set compartido, TTL ≥ 120 s
SuperficiesEVENT, REQ, COUNT, audio de huddle/events, /query, /count, media, git, operador, invitaciones

Ambos usan la misma tolerancia de ±60 s, pero no son intercambiables: son dos verificadores distintos en dos caminos distintos.

Rate limiting: el crate buzz-auth y la admisión compartida

buzz-auth define la interfaz; la implementación respaldada por Redis vive en buzz-pubsub y buzz-relay, con un script Lua atómico (INCR + EXPIRE). Conviene decirlo porque ARCHITECTURE.md §9 afirma que “no existe implementación de rate limiting”: esa entrada está desactualizada. El algoritmo es de ventana fija y el propio crate lo advierte —permite hasta 2× de ráfaga en los bordes de ventana—, no es un token bucket ni una ventana deslizante. El enum LimitType distingue cuatro categorías con su propio sufijo de clave: Messages (msg), ApiCalls (api), WsEvents (ws) e IpConnections (conn).

Las claves de Redis dejan explícito el modelo de aislamiento:

buzz:{community}:ratelimit:{pubkey_hex}:{sufijo}    # por comunidad + pubkey
buzz:ratelimit:ip:{ip}:conn                         # operator-global, sin comunidad

La misma pubkey activa en dos comunidades consume dos cuotas independientes. Los límites por IP, en cambio, son deliberadamente globales del operador: se aplican en el borde de red, antes de que termine la resolución host → comunidad. Un test fija que las claves sean todas en minúsculas, porque un mismo par que produjera dos claves distintas equivaldría a duplicar la cuota. Ver Multi-tenant.

Campo de RateLimitConfigDefaultVariable de entorno
human_messages_per_min60BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN
human_api_calls_per_min300BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN
human_ws_events_per_sec10BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC
agent_standard_messages_per_min120BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN
agent_standard_api_calls_per_min600BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN
agent_elevated_messages_per_min300BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN
agent_platform_messages_per_min600BUZZ_RATE_LIMIT_AGENT_PLATFORM_MESSAGES_PER_MIN

Cada valor debe ser un entero positivo. El relay elige el tier de agente cuando el AuthContext trae agent_owner_pubkey; en caso contrario aplica el tier humano. Los eventos WebSocket no se limitan segundo a segundo: admission.rs define WS_BURST_WINDOW_SECS = 5 y calcula el límite como per_second_limit * 5, porque el arranque del escritorio establece varias suscripciones vivas a la vez. Con el default de 10 eventos/s son 50 eventos en una ventana de 5 segundos.

Mensaje de rechazoCuándo
rate-limited: quota exceeded; retry in {n}sCuota agotada. En HTTP viene con 429
rate-limited: shared admission unavailableEl store compartido no responde. Falla cerrado. En HTTP, 503
rate-limited: too many concurrent requestsSaturación del handler_semaphore
rate-limited: observer frame rate exceeded (100/sec per agent)Frames de observador de un agente

La métrica a vigilar es buzz_admission_rejections_total, etiquetada por transport y por reason=quota|unavailable. Un pico de unavailable no significa abuso: significa que Redis está caído y estás rechazando tráfico legítimo. Ver Observabilidad.

Allowlist de pubkeys

La allowlist restringe qué claves públicas pueden autenticar contra tu relay, con una advertencia literal de NOSTR.md“Enable pubkey allowlist — must be set BEFORE relay startup. No es un toggle en caliente: BUZZ_PUBKEY_ALLOWLIST se lee una vez al construir la configuración.

# 1. Habilitar la allowlist — ANTES de arrancar el relay
export BUZZ_PUBKEY_ALLOWLIST=true

# 2. Arrancar el relay
just relay &                         # relay en :3000

# 3. Agregar una pubkey. No existe comando CLI: se inserta por SQL directo.
PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \
  "INSERT INTO pubkey_allowlist (pubkey) VALUES (decode('<64-char-hex-pubkey>', 'hex'))"

# 4. Conectar cualquier cliente NIP-29 + NIP-42 a ws://localhost:3000

La tabla real, en migrations/0001_initial_schema.sql, es pubkey_allowlist (community_id UUID NOT NULL, pubkey BYTEA NOT NULL, added_by BYTEA, added_at TIMESTAMPTZ, note TEXT) con clave primaria (community_id, pubkey). El relay consulta con WHERE community_id = $1 AND pubkey = $2, así que en un despliegue multi-comunidad tienes que insertar el community_id correcto; el INSERT abreviado del quick start asume la comunidad única del entorno de desarrollo. Para listar, SELECT encode(pubkey, 'hex'), added_at, note FROM pubkey_allowlist;; para quitar, DELETE FROM pubkey_allowlist WHERE pubkey = decode('<64-char-hex-pubkey>', 'hex');.

Semántica que hay que tener clara antes de habilitarla:

  • Solo aplica al camino NIP-42: el chequeo corre únicamente cuando auth_ctx.auth_method == AuthMethod::Nip42. Los usuarios con tokens de API válidos la evaden.
  • Falla cerrado: si la consulta a la base falla, la conexión se deniega (allowlist DB lookup failed, denying (fail-closed)).
  • El error es genérico: auth-required: verification failed. Para diagnosticar hay que mirar el log o la métrica buzz_auth_failures_total{reason="allowlist_denied"}.
  • Default false: todas las pubkeys autenticadas se aceptan.

Al arrancar, el relay ejecuta un backfill idempotente que migra las entradas de pubkey_allowlist a relay_members. Corre antes del bootstrap del dueño, para que habilitar la membresía no deje a todos fuera; solo actúa si relay_members está vacía para esa comunidad, para no reinsertar miembros que un admin quitó a propósito; y si falla con BUZZ_REQUIRE_RELAY_MEMBERSHIP=true, aborta.

flowchart TD
    auth["AUTH NIP-42 verificado"]
    ban{"¿Estado de baneo?"}
    al{"¿BUZZ_PUBKEY_ALLOWLIST activo?"}
    q{"¿pubkey en pubkey_allowlist?"}
    mem{"¿BUZZ_REQUIRE_RELAY_MEMBERSHIP?"}
    rm{"¿relay_members o fallback NIP-OA?"}
    close["OK false y cierre inmediato"]
    deny["auth-required: verification failed"]
    deny2["restricted: not a relay member"]
    okk["Authenticated"]

    auth --> ban
    ban -->|baneado| close
    ban -->|limpio| al
    al -->|no| mem
    al -->|sí| q
    q -->|"no, o error de DB"| deny
    q -->|sí| mem
    mem -->|no| okk
    mem -->|sí| rm
    rm -->|no| deny2
    rm -->|sí| okk

Identidades de agentes

Un agente no es una categoría especial de usuario: es otro par de claves. La diferencia está en cómo recibe la clave y en cómo declara a su dueño.

Cómo recibe la clave. El harness ACP (buzz-acp) exige BUZZ_PRIVATE_KEY, hex de 32 bytes o nsec1..., la única variable marcada como REQUERIDA en esa sección de .env.example. Existe el alias legado BUZZ_ACP_PRIVATE_KEY, aceptado por compatibilidad, pero el nombre canónico es BUZZ_PRIVATE_KEY, que además tiene precedencia sobre el keyring y el archivo plano.

Cómo declara a su dueño. Buzz define un NIP propio, NIP-OA (Owner Attestation), con un tag opcional auth de exactamente cuatro elementos:

["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
  • El evento sigue siendo autoría de event.pubkey, la del agente. NIP-OA es evidencia de autorización: no define suplantación ni reescritura de autoría por parte del relay.
  • La preimagen firmada es nostr:agent-auth: seguido de la pubkey del agente, : y las condiciones; el mensaje firmado es su SHA-256, con firma Schnorr BIP-340 del dueño.
  • Las condiciones admiten cláusulas kind=<decimal>, created_at<timestamp> y created_at>timestamp, separadas por &, sin espacios.
  • Más de un tag auth equivale a ninguno. Un tag con distinto de cuatro elementos es malformado.
  • Un tag válido es una capacidad reutilizable en varios eventos del mismo agente.

En el relay, BUZZ_ALLOW_NIP_OA_AUTH (default false) controla solo si NIP-OA puede conceder acceso de membresía en relays cerrados, es decir con BUZZ_REQUIRE_RELAY_MEMBERSHIP=true. En relays abiertos la extracción de la pubkey del dueño ocurre igual y sin condiciones, para el backfill agente → dueño: la firma es auto-probatoria. El tag se extrae del propio evento AUTH firmado, así que su integridad está protegida por la firma del evento. El agent_owner_pubkey resultante es además lo que decide el tier de rate limiting: presente implica límites de agente, ausente implica los humanos.

Pairing de dispositivos

Mover una identidad a un segundo dispositivo es un problema propio: pegar un nsec a mano no está cifrado ni autenticado. Buzz implementa NIP-AB (Device Pairing), una transferencia única iniciada por QR. En su terminología, source es el dispositivo que tiene el secreto y target el que lo recibe.

sequenceDiagram
    participant S as Source
    participant P as Pairing relay
    participant T as Target
    S->>S: par efímero, session secret de 32 bytes y QR nostrpair
    S->>P: SUB kind 24134
    T->>T: escanear QR y generar par efímero
    T->>P: SUB kind 24134, esperar EOSE y enviar offer con session_id
    P->>S: offer
    S->>S: ECDH más HKDF-SHA256, SAS de 6 dígitos
    T->>T: ECDH más HKDF-SHA256, SAS de 6 dígitos
    Note over S,T: el usuario compara los dos códigos en pantalla
    S->>P: sas-confirm
    P->>T: sas-confirm y verificación del transcript hash
    S->>P: payload cifrado con NIP-44
    P->>T: payload cifrado
    T->>P: complete

La versión 1 usa ECDH sobre secp256k1, HKDF-SHA256, SAS de 6 dígitos y cifrado NIP-44 v2. La versión viaja en el parámetro v del URI y en el campo version del offer, y una versión desconocida debe producir un error visible para el usuario, nunca ignorarse en silencio. Del modelo de amenaza, el operador debe retener que el relay de pairing solo ve ciphertext opaco entre pubkeys desechables y no aprende el payload, pero sí el timing y la frecuencia aproximada de los emparejamientos — para usuarios de alto riesgo el spec recomienda un relay privado. Y el secreto de sesión está expuesto en el QR hasta 120 segundos.

buzz-pair-relay: el sidecar

buzz-pair-relay es un relay efímero mínimo dedicado a estos handshakes. Su documentación es tajante: sin persistencia, sin autenticación, sin historial. Verifica firmas Schnorr contra el hash de id NIP-01 y empareja eventos kind:24134 contra suscripciones vivas filtradas por #p; el resto de su defensa son límites duros:

LímiteValor
Conexiones WebSocket / tamaño de frame128 / 4 KiB
Vida máxima de conexión120 s
EVENTs por conexión / entregas por #p6 / 12
Ventana de frescura de created_at±120 s
Deduplicación de event idscapacidad 1024, TTL 300 s, falla cerrado
Rate limit por conexión20 mensajes / 10 s, 10 eventos / 10 s

El binario hace bind a loopback por defecto (127.0.0.1:5000, configurable con BUZZ_PAIR_RELAY_BIND_ADDR) y debe correr detrás de un proxy inverso que enrute solo /pair, imponga timeouts de lectura HTTP y termine TLS: el sidecar no restringe rutas ni limita conexiones antes del upgrade.

Se despliega con la misma imagen del relay — el Dockerfile copia buzz-pair-relay a /usr/local/bin/, junto a buzz-relay y buzz-admin. En Kubernetes el chart trae un bloque pairingRelay con enabled: false por defecto, Service de tipo ClusterIP en el puerto 5000 y requests de 50m CPU / 32Mi; si lo habilitas, pairingRelay.url es obligatoria o el chart falla, y el chart no crea Ingress ni HTTPRoute — tienes que exponerlo tú apuntando a <release>-buzz-pairing:5000. Ver Relay gestionado: Railway y Kubernetes. Del lado del relay principal, BUZZ_PAIRING_RELAY_URL anuncia el sidecar en el documento NIP-11 y se valida al arrancar: debe ser una URL ws:// o wss:// con host, o la configuración es un error.

buzz-pairing-cli

El crate buzz-pairing-cli produce el binario buzz-pair, y su README es explícito: sirve para pruebas de interoperabilidad y para la presentación del NIP, no para producción.

cargo build --release -p buzz-pairing-cli
./target/release/buzz-pair source --relay ws://localhost:3000   # terminal 1
./target/release/buzz-pair target --show-secret                 # terminal 2, pegar el URI

Subcomandos: source (con --relay y --nsec opcional; sin --nsec genera una clave desechable), target (con --relay para sobrescribir la URL del QR y --show-secret, apagado por defecto) y test-vectors, que imprime los valores criptográficos derivados de las claves fijas del spec. Soporta NIP-42, así que funciona contra un relay Buzz sin ajustes.

Git firmado con Nostr

Buzz extiende el modelo de identidad a git con dos binarios independientes. No hay claves nuevas: es la misma identidad Nostr.

git-credential-nostr — autenticación con NIP-98

Cuando el servidor git de Buzz responde HTTP 401 con WWW-Authenticate: Nostr realm="...", method="GET", git invoca este helper con los datos de la petición por stdin. El helper firma un evento kind:27235 sobre la URL y el método, lo codifica en base64 y lo devuelve por stdout; git reintenta con Authorization: Nostr <token>.

# Requiere git 2.46 o superior — usa la capacidad authtype del protocolo de credenciales
cargo install --path crates/git-credential-nostr
git config --global credential.helper nostr
git config --global credential.useHttpPath true

mkdir -p ~/.nostr
echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key
git config --global nostr.keyfile ~/.nostr/key

Para CI, NOSTR_PRIVATE_KEY evita tocar el sistema de archivos y tiene precedencia sobre nostr.keyfile.

ErrorCausaArreglo
no nostr key configuredNi $NOSTR_PRIVATE_KEY ni nostr.keyfileCompletar el setup
insecure permissionsEl keyfile es legible por grupo u otroschmod 600 ~/.nostr/key
method hintEl WWW-Authenticate del servidor no trae method="..."Actualizar el servidor Buzz
useHttpPathFalta credential.useHttpPathgit config --global credential.useHttpPath true
Salida vacía, sin authgit anterior a 2.46Actualizar git
clock skew / auth rechazadaReloj desviado más de 60 sSincronizar el reloj

Esa última fila es la misma ventana de ±60 s de NIP-98: la sincronización horaria no es cosmética.

git-sign-nostr — firma de commits con NIP-GS

Firma commits y tags con claves Nostr usando Schnorr BIP-340, enchufado por la interfaz de programa de firma de git:

git config gpg.format x509
git config gpg.x509.program /path/to/git-sign-nostr
git config commit.gpgsign true
git config tag.gpgsign true
git config user.signingkey <hex-pubkey>
export NOSTR_PRIVATE_KEY=<hex-o-nsec>

git commit -m "signed with nostr" && git verify-commit HEAD

NIP-GS elige gpg.format=x509 deliberadamente: ese formato usa marcadores -----BEGIN SIGNED MESSAGE-----, que no colisionan con los de PGP ni con los de SSH, y su ruta de verificación no pasa argumentos extra. La armadura debe ser exactamente tres líneas separadas por LF. La prioridad de carga de la clave privada es fija: NOSTR_PRIVATE_KEY, luego BUZZ_PRIVATE_KEY, y por último el archivo apuntado por git config nostr.keyfile. Acepta hex de 64 caracteres o nsec1..., hay un tope de 128 bytes por variable de entorno y el proceso borra la variable de su propio entorno tras leerla. También soporta atestación de dueño vía BUZZ_AUTH_TAG.

Qué ve y qué no ve el operador del relay

Es la pregunta que toda comunidad hace antes de confiar en tu servidor. Con acceso a Postgres y al almacén de objetos, el operador sí ve:

  • Todas las pubkeys que se conectan, con timestamps de conexión y de cada evento.
  • El contenido en texto plano de los mensajes de canal: Buzz no los cifra extremo a extremo, el relay los almacena tal como fueron firmados.
  • Metadatos de canal, listas de miembros, roles, perfiles kind:0 y los blobs de media de Blossom.
  • La cadena de auditoría de buzz-audit, encadenada con SHA-256. Es tamper-evident, no tamper-resistant: detecta corrupción accidental o la edición de una fila, pero quien tenga escritura en la base puede recalcular toda la cadena.

No ve ninguna clave privada — nunca llegan al relay, solo llegan firmas —, ni el contenido de los DMs NIP-17 (kind:1059, gift wrap), aceptados con claves de firma efímeras y guardados como ciphertext opaco; ni los eventos AUTH, que nunca se almacenan ni se auditan; ni el payload de un pairing NIP-AB, aunque opere él mismo el sidecar.

Un detalle de diseño refuerza esto: el índice de búsqueda es la columna generada events.search_tsv, que excluye los kinds sensibles a nivel de almacenamiento. La expresión original de migrations/0001_initial_schema.sql es CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector, y migraciones posteriores fueron ampliando la lista: 0005 agrega 44200 (KIND_AGENT_TURN_METRIC, ciphertext NIP-44) y 0014 agrega 30350 (KIND_PUSH_LEASE). Un tsvector nulo nunca coincide con @@, así que los gift wraps no son buscables, ni siquiera por el operador.

Y hay un giro que conviene conocer desde ya: en una instalación nueva, la migración 0008_fresh_install_search_allowlist.sql invierte la lógica y aplica una lista blanca de solo cinco kinds indexables (0, 9, 40002, 45001, 45003), en lugar de la lista negra. Solo lo hace si la tabla events está vacía; un relay ya poblado conserva su expresión anterior. El detalle completo está en Búsqueda y registro de auditoría.

Dos advertencias que el operador debe asumir. El relay no fuerza TLS: es intencional, para permitir despliegues detrás de balanceadores e ingress controllers, y la terminación es responsabilidad tuya. Y el ingreso privado del dashboard de admin no es identidad por operador: la documentación dice explícitamente que la admisión por VPN o por IP de origen no atribuye acciones a personas, que cualquiera admitido al dashboard puede leer los adjuntos de feedback a los que tenga acceso, y que la atribución o revocación por persona exige identidad autenticada a nivel de ingress o de aplicación — algo que ese endpoint deliberadamente no afirma proveer.

Errores comunes

SíntomaCausa habitual
Todos los clientes fallan al autenticar de golpeReloj desviado más de 60 s
RelayUrlMismatch tras poner TLSRELAY_URL sigue en ws:// mientras el cliente firma wss://
Habilité la allowlist y no pasó nadaDebe fijarse antes del arranque
Nadie entra y el log no dice por quéEl error es genérico; revisa buzz_auth_failures_total{reason="allowlist_denied"}
Rechazos masivos con shared admission unavailableRedis caído: el rate limiter falla cerrado
NIP-98 falla solo detrás del proxyFalta reconstruir la URL desde X-Forwarded-* antes de comparar el tag u
El agente no arranca / git push pide contraseñaFalta BUZZ_PRIVATE_KEY; git anterior a 2.46 o sin credential.useHttpPath

Resumen

  • La identidad es un par de claves secp256k1 con firmas Schnorr BIP-340, verificadas junto con el hash SHA-256 del id. Sin JWT, sin tokens, sin IdP.
  • Las claves privadas viven en el keyring del sistema operativo con respaldo a un archivo 0o600; BUZZ_PRIVATE_KEY siempre tiene precedencia y así reciben identidad los agentes y CI.
  • NIP-42 autentica WebSocket con un desafío proactivo de 32 bytes CSPRNG y un evento kind:22242 con tags challenge y relay; tolerancia ±60 s; concede los 16 scopes conocidos.
  • NIP-98 autentica HTTP con un kind:27235 en Authorization: Nostr <base64> verificando URL, método y hash opcional del cuerpo; el replay guard exige seen-set compartido, atómico, TTL ≥ 120 s y que falle cerrado.
  • Los eventos AUTH nunca se almacenan ni se auditan, y auth_required en NIP-11 es siempre true.
  • El rate limiting es de ventana fija sobre Redis, con claves por comunidad y pubkey y claves globales por IP; admite hasta 2× de ráfaga en los bordes y una ráfaga WebSocket de 5 s.
  • La allowlist de pubkeys debe habilitarse antes del arranque, solo aplica a NIP-42, falla cerrado, no tiene comando CLI y se migra a relay_members en el arranque.
  • NIP-OA deja que un agente pruebe su dueño con un tag auth de cuatro elementos; más de un tag equivale a ninguno y el evento sigue siendo autoría del agente. NIP-AB transfiere identidades con QR, ECDH, SAS de 6 dígitos y NIP-44, sobre un sidecar que no persiste ni autentica nada.
  • git-credential-nostr autentica git con NIP-98 y git-sign-nostr firma commits con NIP-GS usando gpg.format=x509.
  • El operador ve pubkeys, timestamps y el contenido plano de los mensajes de canal; no ve claves privadas, ni el contenido de los gift wraps kind:1059, ni los eventos AUTH.

Siguiente: Membresía, roles y moderación de la comunidad