Identidad y autenticación: claves, NIP-42 y NIP-98
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.
| Forma | Qué es | Dónde aparece |
|---|---|---|
npub1... | Clave pública en bech32 (NIP-19) | buzz-admin add-member --pubkey npub1... |
nsec1... | Clave privada en bech32 | Clientes y agentes. Nunca sale del dispositivo |
| hex de 64 caracteres | La misma pubkey sin codificar | RELAY_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):
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.- 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. - Archivo
0o600solo-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.
| Regla | Valor 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ón | set-if-absent atómico; leer-luego-escribir pierde ante concurrencia |
| Ámbito de la clave | Por comunidad (nip98_replay_key) |
| Ante error de Redis | Fallar cerrado — rechazar, nunca “best effort” |
| Orden | Verificar 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-42 | NIP-98 | |
|---|---|---|
| Kind y transporte | 22242, WebSocket | 27235, HTTP |
| Modelo | Challenge/respuesta con estado por conexión | Stateless, una firma por petición |
| Qué firma el cliente | challenge + URL del relay | URL + método + hash opcional del cuerpo |
| Dónde viaja | Mensaje ["AUTH", <evento>] | Cabecera Authorization: Nostr <base64> |
| Replay | Challenge de un solo uso por conexión | Seen-set compartido, TTL ≥ 120 s |
| Superficies | EVENT, 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 RateLimitConfig | Default | Variable de entorno |
|---|---|---|
human_messages_per_min | 60 | BUZZ_RATE_LIMIT_HUMAN_MESSAGES_PER_MIN |
human_api_calls_per_min | 300 | BUZZ_RATE_LIMIT_HUMAN_API_CALLS_PER_MIN |
human_ws_events_per_sec | 10 | BUZZ_RATE_LIMIT_HUMAN_WS_EVENTS_PER_SEC |
agent_standard_messages_per_min | 120 | BUZZ_RATE_LIMIT_AGENT_STANDARD_MESSAGES_PER_MIN |
agent_standard_api_calls_per_min | 600 | BUZZ_RATE_LIMIT_AGENT_STANDARD_API_CALLS_PER_MIN |
agent_elevated_messages_per_min | 300 | BUZZ_RATE_LIMIT_AGENT_ELEVATED_MESSAGES_PER_MIN |
agent_platform_messages_per_min | 600 | BUZZ_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 rechazo | Cuándo |
|---|---|
rate-limited: quota exceeded; retry in {n}s | Cuota agotada. En HTTP viene con 429 |
rate-limited: shared admission unavailable | El store compartido no responde. Falla cerrado. En HTTP, 503 |
rate-limited: too many concurrent requests | Saturació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étricabuzz_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>ycreated_at>timestamp, separadas por&, sin espacios. - Más de un tag
authequivale 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ímite | Valor |
|---|---|
| Conexiones WebSocket / tamaño de frame | 128 / 4 KiB |
| Vida máxima de conexión | 120 s |
EVENTs por conexión / entregas por #p | 6 / 12 |
Ventana de frescura de created_at | ±120 s |
| Deduplicación de event ids | capacidad 1024, TTL 300 s, falla cerrado |
| Rate limit por conexión | 20 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.
| Error | Causa | Arreglo |
|---|---|---|
no nostr key configured | Ni $NOSTR_PRIVATE_KEY ni nostr.keyfile | Completar el setup |
insecure permissions | El keyfile es legible por grupo u otros | chmod 600 ~/.nostr/key |
method hint | El WWW-Authenticate del servidor no trae method="..." | Actualizar el servidor Buzz |
useHttpPath | Falta credential.useHttpPath | git config --global credential.useHttpPath true |
| Salida vacía, sin auth | git anterior a 2.46 | Actualizar git |
clock skew / auth rechazada | Reloj desviado más de 60 s | Sincronizar 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íntoma | Causa habitual |
|---|---|
| Todos los clientes fallan al autenticar de golpe | Reloj desviado más de 60 s |
RelayUrlMismatch tras poner TLS | RELAY_URL sigue en ws:// mientras el cliente firma wss:// |
| Habilité la allowlist y no pasó nada | Debe 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 unavailable | Redis caído: el rate limiter falla cerrado |
| NIP-98 falla solo detrás del proxy | Falta reconstruir la URL desde X-Forwarded-* antes de comparar el tag u |
El agente no arranca / git push pide contraseña | Falta 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_KEYsiempre 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
challengeyrelay; 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_requireden NIP-11 es siempretrue. - 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_membersen el arranque. - NIP-OA deja que un agente pruebe su dueño con un tag
authde 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-nostrautentica git con NIP-98 ygit-sign-nostrfirma commits con NIP-GS usandogpg.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.