Compare commits
7 Commits
e8e37d77fe
...
bf0884527e
| Author | SHA1 | Date | |
|---|---|---|---|
| bf0884527e | |||
| 2960b0984a | |||
| b44aa02326 | |||
| 024af306fe | |||
| b72976e06c | |||
| 3d9b4ce392 | |||
| cb6b51156a |
@@ -2,7 +2,7 @@
|
||||
name: uniweb
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.1.0
|
||||
version: 0.2.0
|
||||
description: "Frontend web del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) + gateway Go (REST+SSE) que actúa de peer del bus para el navegador."
|
||||
tags: [service, messaging, web, frontend, e2e]
|
||||
uses_functions:
|
||||
@@ -118,6 +118,18 @@ programáticos) ve a `unibus`; `uniweb` solo es la capa web encima.
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1:
|
||||
el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje
|
||||
de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305,
|
||||
sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json`
|
||||
de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests
|
||||
del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una
|
||||
interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador
|
||||
`nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`):
|
||||
**19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame
|
||||
marshal/sign, nkey y canonical request. La clave privada del usuario nunca se
|
||||
serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en
|
||||
la Fase 3 (E2E) por requerir un unibus vivo con WebSocket.
|
||||
- v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway
|
||||
(`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad
|
||||
respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
issue: 0001
|
||||
title: uniweb como cliente browser-nativo del bus (eliminar el gateway Go, desacoplar de unibus)
|
||||
status: spec
|
||||
created: 2026-06-13
|
||||
domain: architecture
|
||||
scope: uniweb (web/src/bus, web/src, eliminar cmd/webgw + go.mod) + unibus (pkg/embeddednats WebSocket, cmd/membershipd CORS, export de vectores de test cripto)
|
||||
---
|
||||
|
||||
# Objetivo
|
||||
|
||||
Convertir `uniweb` en un cliente del bus **browser-nativo y autónomo**, al mismo nivel que
|
||||
`unibus_android`: la SPA habla directamente con el data plane (NATS sobre WebSocket) y el
|
||||
control plane (HTTP), e implementa el protocolo del bus y su cifrado E2E **en el propio
|
||||
navegador** (TypeScript + `@noble`). Como consecuencia se **elimina el gateway Go**
|
||||
(`cmd/webgw`) y `uniweb` deja de depender del módulo Go de `unibus`: queda como una app de solo
|
||||
frontend (`web/`), sin `go.mod`, sin `replace => ../unibus`.
|
||||
|
||||
# Por qué (estado actual auditado el 13/06/2026)
|
||||
|
||||
`uniweb` v0.1.0 nació partiendo `unibus` v0.13.0 en dos: la SPA (`web/`) y un gateway Go
|
||||
(`cmd/webgw`). El gateway importa `pkg/{busauth,client,frame,room}` de unibus y
|
||||
`functions/cybersecurity` del registry, así que `uniweb` solo compila con `unibus` presente en
|
||||
disco (`replace github.com/enmanuel/unibus => ../unibus`). Esa es la deuda que este issue salda.
|
||||
|
||||
Hay además un **defecto de seguridad** que este rediseño corrige de paso. Hoy el flujo wallet
|
||||
funciona así:
|
||||
|
||||
- La SPA deriva la identidad del usuario (Ed25519 + X25519) de una mnemónica BIP39 y la cifra
|
||||
at-rest en el dispositivo.
|
||||
- Pero al abrir sesión, `POST /api/session` **envía al gateway la identidad COMPLETA,
|
||||
incluida la clave privada** (`sign_priv` 64 bytes + `kex_priv` 32 bytes, en hex). El gateway
|
||||
construye un `cs.Identity` con la privada y abre un cliente del bus que **actúa como el
|
||||
usuario server-side** (ver `cmd/webgw/session.go`, `cmd/webgw/identity.go`).
|
||||
|
||||
Es decir: la clave privada del usuario **viaja al servidor y vive en la RAM del gateway**. El
|
||||
cifrado de contenido sigue siendo E2E respecto al broker NATS, pero NO respecto al gateway —
|
||||
el gateway puede leer y firmar todo en nombre del usuario. El report 0019 prometía que "la
|
||||
clave privada nunca viaja al servidor"; eso solo será cierto cuando la cripto del bus ocurra
|
||||
en el navegador. Este issue lo hace cierto.
|
||||
|
||||
`unibus_android` ya demuestra que el patrón es viable: es un cliente Kotlin que habla directo
|
||||
con NATS (:4250) y el control plane (:8470) y hace toda la cripto E2E en el dispositivo, sin
|
||||
gateway y sin compartir su clave privada.
|
||||
|
||||
# Diseño objetivo
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ unibus (el bus) │
|
||||
│ membershipd :8470 control-plane HTTP │
|
||||
│ + CORS (nuevo) /register /session... │
|
||||
│ nats-server (embebido) │
|
||||
│ :4250 TCP (peers Go, android) │
|
||||
│ :4250+ WS (navegador, nats.ws) (nuevo)│
|
||||
└─────────────────────────────────────────────┘
|
||||
▲ TCP ▲ WebSocket
|
||||
│ │
|
||||
┌────────┴───────┐ ┌─────────┴──────────────┐
|
||||
│ peers Go / │ │ uniweb (SPA, browser) │
|
||||
│ android (Kt) │ │ web/src/bus/ (TS SDK)│
|
||||
│ cripto nativa │ │ cripto en @noble │
|
||||
└────────────────┘ │ priv NUNCA sale │
|
||||
└────────────────────────┘
|
||||
```
|
||||
|
||||
`uniweb` final = solo `web/`. Sin `cmd/webgw`, sin `go.mod`. Se sirve como estáticos (cualquier
|
||||
static server; en dev, vite). La identidad del usuario se desbloquea y se usa **solo** en el
|
||||
navegador.
|
||||
|
||||
# Fases
|
||||
|
||||
## Fase 0 — Preparar unibus (habilitar clientes browser)
|
||||
|
||||
Cambios en `unibus` (aditivos, no rompen peers Go/android existentes):
|
||||
|
||||
1. **WebSocket en el nats-server embebido** (`pkg/embeddednats`): configurar `opts.Websocket`
|
||||
(puerto dedicado, `NoTLS` en loopback para dev; TLS en prod). Permite que `nats.ws` conecte
|
||||
desde el navegador. Flag/env para el puerto WS.
|
||||
2. **CORS en el control plane** (`cmd/membershipd` / `pkg/membership`): añadir cabeceras
|
||||
`Access-Control-Allow-Origin/Methods/Headers` y manejar preflight `OPTIONS` en los endpoints
|
||||
que el browser llamará directo (`/register`, `/session` o equivalente, listado de rooms,
|
||||
invite, etc.). Configurable (allowlist de orígenes), no `*` en prod.
|
||||
3. **Exportar vectores de test cripto**: un comando/test que vuelca vectores deterministas
|
||||
(derivación de identidad desde seed, envelope de frame seal/open, reparto sealed-box de la
|
||||
room key, firma por-mensaje) a JSON, para que los tests TS validen **paridad byte a byte**
|
||||
con la implementación Go de referencia.
|
||||
|
||||
## Fase 1 — SDK del bus en TypeScript (`web/src/bus/`)
|
||||
|
||||
Port de la lógica que hoy vive en el gateway (`pkg/client` y dependencias). La cripto base ya
|
||||
está disponible (`@noble/curves`, `@noble/hashes`, `@noble/ciphers`); lo que se porta es el
|
||||
**protocolo**, no las primitivas.
|
||||
|
||||
- `frame.ts` — port de `pkg/frame` (~100 LOC): estructura del frame, ULID ids, timestamp,
|
||||
threading (`ThreadID`, `ReplyTo`), tipos `MSG`/`REACT`.
|
||||
- `room.ts` — port de `pkg/room` (~42 LOC): `Policy`, tipos de clave de room, epochs.
|
||||
- `busauth.ts` — port de `pkg/busauth` (~331 LOC): autenticación nkey contra el control plane
|
||||
(nonce + firma Ed25519). Verificar cómo el browser obtiene/firma el nonce con la identidad
|
||||
del wallet (decisión: ¿la identidad del bus deriva del mismo seed BIP39?).
|
||||
- `client.ts` — port de `pkg/client` (~1368 LOC, el grueso): conexión `nats.ws`, `Join`,
|
||||
`Publish`/`PublishReply`/`React`, `Subscribe`, listado de rooms/users, `CreateRoom`,
|
||||
`Invite`, `Kick`; envelope E2E (ChaCha20-Poly1305 seal/open con la room key); reparto de la
|
||||
room key por sealed-box (X25519) a los invitados; rotación de epoch en LEAVE/KICK.
|
||||
|
||||
## Fase 2 — Cablear la SPA al SDK y eliminar el gateway
|
||||
|
||||
- Reemplazar `web/src/api.ts` (hoy llama `/api/*` del gateway) por llamadas al SDK del bus +
|
||||
al control plane directo.
|
||||
- La identidad del wallet (`web/src/wallet/`) se usa **localmente** para firmar/abrir; la
|
||||
privada nunca se serializa hacia la red.
|
||||
- Borrar `cmd/webgw/`, `go.mod`, `go.sum`. `uniweb` queda como app de solo frontend.
|
||||
- Servir la SPA: static server trivial (sin lógica de bus). Documentar en `app.md`.
|
||||
- Actualizar `app.md`: `lang` deja de ser `go` puro (o se marca `framework: react`, sin
|
||||
`entry_point` Go), quitar `uses_functions` Go, ajustar `service`/`e2e_checks`.
|
||||
|
||||
## Fase 3 — Validación (DoD)
|
||||
|
||||
- Tests TS de paridad cripto contra los vectores de la Fase 0.
|
||||
- E2E visual: join (invitación) / login (passphrase) / recover (mnemónica) + enviar y recibir
|
||||
mensajes cifrados **browser ↔ browser** y **browser ↔ peer Go**.
|
||||
- Auditoría de red (DevTools / proxy): confirmar que **ninguna petición transporta la clave
|
||||
privada**.
|
||||
|
||||
# Riesgos / decisiones abiertas
|
||||
|
||||
- **nkey auth del navegador**: el bus autentica peers con nkey (Ed25519). Decidir si la
|
||||
identidad nkey del bus deriva del mismo seed BIP39 del wallet o es separada. Afecta a
|
||||
`busauth.ts` y a cómo el control plane reconoce al usuario.
|
||||
- **WebSocket + TLS en prod**: el navegador exigirá `wss://` salvo loopback. Encaja con el
|
||||
issue de TLS del bus (unibus 0001-bus-auth-and-tls).
|
||||
- **CORS vs same-origin**: alternativa a CORS = servir la SPA detrás de un reverse proxy que
|
||||
comparta origen con el control plane. Decidir en Fase 0/2.
|
||||
- **Tamaño del bundle**: la cripto + nats.ws añaden peso; medir y, si hace falta, code-split.
|
||||
- **Paridad de protocolo**: cualquier divergencia en el envelope o el reparto de claves rompe
|
||||
la interoperabilidad con peers Go/android. Los vectores de la Fase 0 son el contrato.
|
||||
|
||||
# Definition of Done (3 capas)
|
||||
|
||||
## Capa 1 — Mecánica
|
||||
- `uniweb` compila y se sirve **sin** `unibus` en disco y **sin** `go.mod` (es solo `web/`).
|
||||
- `pnpm build` verde; `unibus` build/vet/test verdes tras los cambios aditivos de Fase 0.
|
||||
|
||||
## Capa 2 — Cobertura de comportamiento
|
||||
| Escenario | Tipo | Evidencia | Esperado |
|
||||
|---|---|---|---|
|
||||
| Golden: browser ↔ peer Go intercambian mensaje cifrado | e2e | dos clientes en una room, mensaje round-trip | plaintext idéntico en ambos extremos |
|
||||
| Edge: recover en dispositivo nuevo re-deriva la misma identidad | e2e | pegar mnemónica → `sign_pub` reconstruido | igual al original (vector Go) |
|
||||
| Edge: rotación de epoch en KICK | e2e | KICK a un miembro → publicar | el kicked ya no descifra; los demás sí |
|
||||
| Error: passphrase incorrecta | unit | unlock con clave mala | `WrongPasswordError`, sin tocar la red |
|
||||
| Paridad: envelope seal/open TS vs Go | unit | vectores de Fase 0 | bytes idénticos |
|
||||
|
||||
## Capa 3 — Vida útil
|
||||
| Métrica | Umbral | Dónde | Ventana |
|
||||
|---|---|---|---|
|
||||
| Mensajes browser↔Go sin pérdida | 100% | uso real | 7 días |
|
||||
| Peticiones que filtran la priv | 0 | audit de red | continuo |
|
||||
| Errores de descifrado | 0 | consola/log SPA | 7 días |
|
||||
|
||||
## Anti-criterios (invalidan la DoD)
|
||||
- La clave privada aparece en CUALQUIER petición de red.
|
||||
- `uniweb` necesita `unibus` en disco o como módulo Go para compilar/servir.
|
||||
- Mensajes browser↔Go ilegibles por divergencia de protocolo.
|
||||
- Queda algún `cmd/webgw`/`go.mod` en `uniweb`.
|
||||
|
||||
# Notas
|
||||
|
||||
Onboarding: tras este issue, `uniweb` se desarrolla y despliega como cualquier SPA estática.
|
||||
No necesita el binario del bus para nada salvo apuntar `nats.ws` al puerto WebSocket del bus y
|
||||
el control plane HTTP a `membershipd`. El bus (`unibus`) y los demás clientes (`unibus_android`)
|
||||
no cambian de contrato: este issue solo añade el transporte WebSocket y CORS, ambos aditivos.
|
||||
+8
-3
@@ -6,17 +6,21 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@tabler/icons-react": "^3.36.0",
|
||||
"nats.ws": "^1.30.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.0",
|
||||
@@ -26,6 +30,7 @@
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
"vite": "^6.0.3",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+284
@@ -14,6 +14,9 @@ importers:
|
||||
'@mantine/hooks':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(react@19.2.7)
|
||||
'@noble/ciphers':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
'@noble/curves':
|
||||
specifier: ^2.2.0
|
||||
version: 2.2.0
|
||||
@@ -26,12 +29,18 @@ importers:
|
||||
'@tabler/icons-react':
|
||||
specifier: ^3.36.0
|
||||
version: 3.44.0(react@19.2.7)
|
||||
nats.ws:
|
||||
specifier: ^1.30.3
|
||||
version: 1.30.3
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7
|
||||
react-dom:
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.7(react@19.2.7)
|
||||
tweetnacl:
|
||||
specifier: ^1.0.3
|
||||
version: 1.0.3
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.0
|
||||
@@ -57,6 +66,9 @@ importers:
|
||||
vite:
|
||||
specifier: ^6.0.3
|
||||
version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||
vitest:
|
||||
specifier: ^4.1.8
|
||||
version: 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -348,6 +360,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^19.2.0
|
||||
|
||||
'@noble/ciphers@2.2.0':
|
||||
resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
|
||||
'@noble/curves@2.2.0':
|
||||
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
|
||||
engines: {node: '>= 20.19.0'}
|
||||
@@ -503,6 +519,9 @@ packages:
|
||||
'@scure/bip39@2.2.0':
|
||||
resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tabler/icons-react@3.44.0':
|
||||
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
|
||||
peerDependencies:
|
||||
@@ -523,6 +542,12 @@ packages:
|
||||
'@types/babel__traverse@7.28.0':
|
||||
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
@@ -540,6 +565,39 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/expect@4.1.8':
|
||||
resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==}
|
||||
|
||||
'@vitest/mocker@4.1.8':
|
||||
resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==}
|
||||
|
||||
'@vitest/runner@4.1.8':
|
||||
resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==}
|
||||
|
||||
'@vitest/snapshot@4.1.8':
|
||||
resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==}
|
||||
|
||||
'@vitest/spy@4.1.8':
|
||||
resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==}
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
baseline-browser-mapping@2.10.34:
|
||||
resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -557,6 +615,10 @@ packages:
|
||||
caniuse-lite@1.0.30001797:
|
||||
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -587,6 +649,9 @@ packages:
|
||||
electron-to-chromium@1.5.368:
|
||||
resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
esbuild@0.25.12:
|
||||
resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -596,6 +661,13 @@ packages:
|
||||
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -634,6 +706,9 @@ packages:
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
@@ -642,10 +717,25 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nats.ws@1.30.3:
|
||||
resolution: {integrity: sha512-aM77V2SEc+B6lbxCMZK3qfRy4jg8pmHj+wZzQKDiDIQYhLPj6U2NSHHBex0syj72Ayzl4uR5Lp3aKXTaVLbRpw==}
|
||||
deprecated: 'Package deprecated. Use @nats-io/nats-core or nats.js instead: https://github.com/nats-io/nats.js'
|
||||
|
||||
nkeys.js@1.1.0:
|
||||
resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
node-releases@2.0.47:
|
||||
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -751,10 +841,19 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
sugarss@5.0.1:
|
||||
resolution: {integrity: sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw==}
|
||||
engines: {node: '>=18.0'}
|
||||
@@ -768,13 +867,27 @@ packages:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-fest@5.7.0:
|
||||
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -853,6 +966,52 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.8:
|
||||
resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.8
|
||||
'@vitest/browser-preview': 4.1.8
|
||||
'@vitest/browser-webdriverio': 4.1.8
|
||||
'@vitest/coverage-istanbul': 4.1.8
|
||||
'@vitest/coverage-v8': 4.1.8
|
||||
'@vitest/ui': 4.1.8
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -1109,6 +1268,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
|
||||
'@noble/ciphers@2.2.0': {}
|
||||
|
||||
'@noble/curves@2.2.0':
|
||||
dependencies:
|
||||
'@noble/hashes': 2.2.0
|
||||
@@ -1199,6 +1360,8 @@ snapshots:
|
||||
'@noble/hashes': 2.2.0
|
||||
'@scure/base': 2.2.0
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tabler/icons-react@3.44.0(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tabler/icons': 3.44.0
|
||||
@@ -1227,6 +1390,13 @@ snapshots:
|
||||
dependencies:
|
||||
'@babel/types': 7.29.7
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
@@ -1249,6 +1419,49 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/expect@4.1.8':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.8
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||
|
||||
'@vitest/pretty-format@4.1.8':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.8
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.8': {}
|
||||
|
||||
'@vitest/utils@4.1.8':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
baseline-browser-mapping@2.10.34: {}
|
||||
|
||||
browserslist@4.28.2:
|
||||
@@ -1263,6 +1476,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001797: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
@@ -1279,6 +1494,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.368: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
esbuild@0.25.12:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.25.12
|
||||
@@ -1310,6 +1527,12 @@ snapshots:
|
||||
|
||||
escalade@3.2.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -1331,12 +1554,29 @@ snapshots:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.12: {}
|
||||
|
||||
nats.ws@1.30.3:
|
||||
optionalDependencies:
|
||||
nkeys.js: 1.1.0
|
||||
|
||||
nkeys.js@1.1.0:
|
||||
dependencies:
|
||||
tweetnacl: 1.0.3
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.47: {}
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -1456,8 +1696,14 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
sugarss@5.0.1(postcss@8.5.15):
|
||||
dependencies:
|
||||
postcss: 8.5.15
|
||||
@@ -1466,13 +1712,21 @@ snapshots:
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-fest@5.7.0:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
@@ -1514,4 +1768,34 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
sugarss: 5.0.1(postcss@8.5.15)
|
||||
|
||||
vitest@4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.8
|
||||
'@vitest/mocker': 4.1.8(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
|
||||
'@vitest/pretty-format': 4.1.8
|
||||
'@vitest/runner': 4.1.8
|
||||
'@vitest/snapshot': 4.1.8
|
||||
'@vitest/spy': 4.1.8
|
||||
'@vitest/utils': 4.1.8
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||
why-is-node-running: 2.3.0
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Parity tests for the auth bridge: the browser must produce the same NATS nkey and
|
||||
// the same signed control-plane request bytes as the Go client, or it would not
|
||||
// authenticate on either plane (issue 0001, Phase 1).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import vectors from "./testdata/vectors.json";
|
||||
import { hexToBytes, bytesToHex, base64ToBytes } from "./crypto.js";
|
||||
import { nkeyPublic, canonicalRequest, signedHeaders } from "./busauth.js";
|
||||
|
||||
describe("NATS nkey encoding", () => {
|
||||
it("derives the same user nkey ('U...') as Go from the Ed25519 pubkey", () => {
|
||||
const v = vectors.nkey;
|
||||
expect(nkeyPublic(hexToBytes(v.sign_pub_hex))).toBe(v.nkey_public);
|
||||
});
|
||||
});
|
||||
|
||||
describe("control-plane request signing", () => {
|
||||
it("builds the same canonical request bytes as Go", () => {
|
||||
const v = vectors.control_request;
|
||||
const got = canonicalRequest(v.method, v.path, v.ts, v.nonce, hexToBytes(v.body_hex));
|
||||
expect(bytesToHex(got)).toBe(v.canonical_hex);
|
||||
});
|
||||
|
||||
it("produces the same Ed25519 signature as Go (X-Unibus-Sig)", () => {
|
||||
const v = vectors.control_request;
|
||||
const headers = signedHeaders(
|
||||
hexToBytes(vectors.sign.sign_pub_hex),
|
||||
hexToBytes(v.sign_priv_hex),
|
||||
v.method,
|
||||
v.path,
|
||||
v.ts,
|
||||
v.nonce,
|
||||
hexToBytes(v.body_hex),
|
||||
);
|
||||
// X-Unibus-Sig is base64-standard; decode and compare hex to the Go vector.
|
||||
expect(bytesToHex(base64ToBytes(headers["X-Unibus-Sig"]))).toBe(v.sig_hex);
|
||||
expect(headers["X-Unibus-Pub"]).toBe(vectors.sign.sign_pub_hex);
|
||||
expect(headers["X-Unibus-Ts"]).toBe(v.ts);
|
||||
expect(headers["X-Unibus-Nonce"]).toBe(v.nonce);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
// Bridges the user's Ed25519 identity to the two authentication surfaces of the
|
||||
// bus, ported from Go pkg/busauth and the client's request signing:
|
||||
//
|
||||
// - DATA PLANE (NATS): a NATS user nkey IS an Ed25519 keypair. nkeyPublic encodes
|
||||
// the Ed25519 public key into the "U..." nkey string the server expects, and
|
||||
// natsAuthenticator signs the server-presented nonce with the same key — so the
|
||||
// browser authenticates to NATS with the user's identity, no extra key material.
|
||||
// - CONTROL PLANE (HTTP): every request to membershipd is signed. canonicalRequest
|
||||
// reproduces Go's membership.CanonicalRequest, and signedHeaders attaches the
|
||||
// X-Unibus-Pub/Ts/Nonce/Sig headers the server verifies.
|
||||
//
|
||||
// Parity with Go is pinned by the `nkey` and `control_request` vectors in
|
||||
// testdata/vectors.json (busauth.test.ts).
|
||||
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { signEd25519, bytesToHex, bytesToBase64 } from "./crypto.js";
|
||||
|
||||
// --- NATS nkey encoding (base32 + crc16, matching github.com/nats-io/nkeys) ---
|
||||
|
||||
// PrefixByteUser is nkeys' user prefix (20 << 3). Its top 5 bits encode to 'U', so
|
||||
// every user nkey string starts with "U".
|
||||
const PREFIX_USER = 20 << 3;
|
||||
|
||||
// crc16 table (CRC-16/XMODEM, poly 0x1021, MSB-first) — the exact CRC nkeys appends.
|
||||
const CRC16TAB: Uint16Array = (() => {
|
||||
const tab = new Uint16Array(256);
|
||||
for (let i = 0; i < 256; i++) {
|
||||
let crc = (i << 8) & 0xffff;
|
||||
for (let j = 0; j < 8; j++) {
|
||||
crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
|
||||
}
|
||||
tab[i] = crc;
|
||||
}
|
||||
return tab;
|
||||
})();
|
||||
|
||||
function crc16(data: Uint8Array): number {
|
||||
let crc = 0;
|
||||
for (const b of data) crc = ((crc << 8) & 0xffff) ^ CRC16TAB[((crc >> 8) ^ b) & 0xff];
|
||||
return crc & 0xffff;
|
||||
}
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
|
||||
// base32Encode is RFC4648 standard base32 WITHOUT padding, as nkeys uses.
|
||||
function base32Encode(data: Uint8Array): string {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let out = "";
|
||||
for (const b of data) {
|
||||
value = (value << 8) | b;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
|
||||
bits -= 5;
|
||||
}
|
||||
}
|
||||
if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return out;
|
||||
}
|
||||
|
||||
// nkeyPublic encodes a 32-byte Ed25519 public key as a NATS user nkey ("U...").
|
||||
// Layout: prefixByte || pubkey(32) || crc16-little-endian(2), base32 (no padding).
|
||||
export function nkeyPublic(signPub: Uint8Array): string {
|
||||
if (signPub.length !== 32) throw new Error(`nkeyPublic: signPub must be 32 bytes, got ${signPub.length}`);
|
||||
const raw = new Uint8Array(1 + 32);
|
||||
raw[0] = PREFIX_USER;
|
||||
raw.set(signPub, 1);
|
||||
const crc = crc16(raw);
|
||||
const full = new Uint8Array(raw.length + 2);
|
||||
full.set(raw, 0);
|
||||
full[raw.length] = crc & 0xff; // little-endian
|
||||
full[raw.length + 1] = (crc >> 8) & 0xff;
|
||||
return base32Encode(full);
|
||||
}
|
||||
|
||||
// natsAuthenticator returns the callback a NATS WebSocket connection uses to
|
||||
// authenticate: it presents the user nkey and signs the server's nonce with the
|
||||
// Ed25519 key. The nonce arrives as a string; we sign its UTF-8 bytes and return the
|
||||
// signature base64url-encoded, the form the NATS protocol expects.
|
||||
export function natsAuthenticator(signPub: Uint8Array, signPriv: Uint8Array) {
|
||||
const nkey = nkeyPublic(signPub);
|
||||
return (nonce: string) => {
|
||||
const sig = signEd25519(signPriv, new TextEncoder().encode(nonce));
|
||||
const b64url = bytesToBase64(sig).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
return { nkey, sig: b64url };
|
||||
};
|
||||
}
|
||||
|
||||
// --- control-plane request signing (HTTP) ------------------------------------
|
||||
|
||||
// canonicalRequest reproduces Go's membership.CanonicalRequest: the bytes signed for
|
||||
// a control-plane HTTP request. body is the raw request body (empty for GET).
|
||||
export function canonicalRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
ts: string,
|
||||
nonce: string,
|
||||
body: Uint8Array,
|
||||
): Uint8Array {
|
||||
const bodyHashHex = bytesToHex(sha256(body));
|
||||
return new TextEncoder().encode([method, path, ts, nonce, bodyHashHex].join("\n"));
|
||||
}
|
||||
|
||||
export interface ControlHeaders {
|
||||
"X-Unibus-Pub": string;
|
||||
"X-Unibus-Ts": string;
|
||||
"X-Unibus-Nonce": string;
|
||||
"X-Unibus-Sig": string;
|
||||
}
|
||||
|
||||
// signedHeaders builds the transport-auth headers for a control-plane request,
|
||||
// signing canonicalRequest with the user's Ed25519 key. ts/nonce are injected so the
|
||||
// function is deterministic and testable; in production use the current unix seconds
|
||||
// and a fresh 16-byte random nonce (base64).
|
||||
export function signedHeaders(
|
||||
signPub: Uint8Array,
|
||||
signPriv: Uint8Array,
|
||||
method: string,
|
||||
path: string,
|
||||
ts: string,
|
||||
nonce: string,
|
||||
body: Uint8Array,
|
||||
): ControlHeaders {
|
||||
const sig = signEd25519(signPriv, canonicalRequest(method, path, ts, nonce, body));
|
||||
return {
|
||||
"X-Unibus-Pub": bytesToHex(signPub),
|
||||
"X-Unibus-Ts": ts,
|
||||
"X-Unibus-Nonce": nonce,
|
||||
"X-Unibus-Sig": bytesToBase64(sig), // base64 standard, matching the Go client
|
||||
};
|
||||
}
|
||||
|
||||
// freshNonce returns a base64 (standard) 16-byte random nonce for a live request.
|
||||
export function freshNonce(): string {
|
||||
return bytesToBase64(crypto.getRandomValues(new Uint8Array(16)));
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// The browser-native bus client, ported from Go pkg/client. It does what the Go
|
||||
// gateway used to do server-side — only now it runs in the browser, so the user's
|
||||
// private key never leaves the device (issue 0001).
|
||||
//
|
||||
// The module is split so the security-critical part is pure and unit-testable
|
||||
// without a live server:
|
||||
// - sealRoomMessage / openRoomMessage: the room ENVELOPE (build a frame, AEAD-seal
|
||||
// the payload with the room key using the subject as AAD, sign it; and the
|
||||
// inverse: verify the signature and open the payload). These are pure and pinned
|
||||
// by tests.
|
||||
// - NatsTransport: the data-plane transport interface. The concrete WebSocket
|
||||
// implementation (nats.ws) is thin glue wired and E2E-tested in a later phase.
|
||||
// - ControlPlane: the signed HTTP client for membershipd (rooms, keys, members).
|
||||
// - BusClient: orchestrates transport + control plane + envelope.
|
||||
|
||||
import { Policy, Room } from "./room.js";
|
||||
import {
|
||||
Frame,
|
||||
FrameType,
|
||||
marshal,
|
||||
unmarshal,
|
||||
signingBytes,
|
||||
} from "./frame.js";
|
||||
import {
|
||||
sealAEAD,
|
||||
openAEAD,
|
||||
randomNonce,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
sealKeyBox,
|
||||
openKeyBox,
|
||||
endpointID,
|
||||
bytesToBase64,
|
||||
} from "./crypto.js";
|
||||
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||
|
||||
// Identity is the user's full cryptographic identity. The private halves stay in
|
||||
// memory in the browser and are NEVER serialized to the network.
|
||||
export interface Identity {
|
||||
signPub: Uint8Array;
|
||||
signPriv: Uint8Array; // 64-byte Ed25519 (seed||pub)
|
||||
kexPub: Uint8Array;
|
||||
kexPriv: Uint8Array;
|
||||
}
|
||||
|
||||
// --- ULID (message ids), Crockford base32, time-ordered ----------------------
|
||||
|
||||
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
|
||||
export function newULID(nowMs: number = Date.now()): string {
|
||||
let ts = "";
|
||||
let t = nowMs;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
ts = CROCKFORD[t % 32] + ts;
|
||||
t = Math.floor(t / 32);
|
||||
}
|
||||
const rnd = crypto.getRandomValues(new Uint8Array(16));
|
||||
let r = "";
|
||||
for (let i = 0; i < 16; i++) r += CROCKFORD[rnd[i] & 31];
|
||||
return ts + r;
|
||||
}
|
||||
|
||||
// --- room envelope (pure, the security-critical core) ------------------------
|
||||
|
||||
export interface SealOptions {
|
||||
type: FrameType;
|
||||
subject: string;
|
||||
sender: string; // this peer's endpoint id
|
||||
signPriv: Uint8Array;
|
||||
policy: Policy;
|
||||
epoch: number;
|
||||
plaintext: Uint8Array;
|
||||
roomKey?: Uint8Array; // required when policy.encrypt
|
||||
threadID?: string;
|
||||
replyTo?: string;
|
||||
msgID?: string; // defaults to a fresh ULID
|
||||
}
|
||||
|
||||
// sealRoomMessage builds a wire frame from plaintext exactly as Go's publishFrame:
|
||||
// for encrypted rooms the payload is ChaCha20-Poly1305-sealed with the room key and
|
||||
// the SUBJECT as additional authenticated data; for signed rooms an Ed25519
|
||||
// signature over the canonical bytes is attached.
|
||||
export function sealRoomMessage(o: SealOptions): Frame {
|
||||
const f: Frame = {
|
||||
type: o.type,
|
||||
subject: o.subject,
|
||||
sender: o.sender,
|
||||
msgID: o.msgID ?? newULID(),
|
||||
epoch: o.epoch,
|
||||
threadID: o.threadID,
|
||||
replyTo: o.replyTo,
|
||||
};
|
||||
if (o.policy.encrypt) {
|
||||
if (!o.roomKey) throw new Error("sealRoomMessage: encrypted room requires roomKey");
|
||||
const nonce = randomNonce();
|
||||
f.nonce = nonce;
|
||||
f.payload = sealAEAD(o.roomKey, nonce, o.plaintext, new TextEncoder().encode(o.subject));
|
||||
} else {
|
||||
f.payload = o.plaintext;
|
||||
}
|
||||
if (o.policy.signMsgs) {
|
||||
f.sig = signEd25519(o.signPriv, signingBytes(f));
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
// openRoomMessage is the inverse: it verifies the signature (for signed rooms) and
|
||||
// opens the AEAD payload (for encrypted rooms), returning the plaintext or null if
|
||||
// verification/decryption fails (the caller drops the message).
|
||||
export function openRoomMessage(
|
||||
f: Frame,
|
||||
policy: Policy,
|
||||
signerPub: Uint8Array | undefined,
|
||||
roomKey: Uint8Array | undefined,
|
||||
): Uint8Array | null {
|
||||
if (policy.signMsgs) {
|
||||
if (!f.sig || !signerPub || !verifyEd25519(f.sig, signingBytes(f), signerPub)) return null;
|
||||
}
|
||||
if (policy.encrypt) {
|
||||
if (!f.nonce || !f.payload || !roomKey) return null;
|
||||
try {
|
||||
return openAEAD(roomKey, f.nonce, f.payload, new TextEncoder().encode(f.subject));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return f.payload ?? new Uint8Array(0);
|
||||
}
|
||||
|
||||
// --- data-plane transport ----------------------------------------------------
|
||||
|
||||
export type MessageHandler = (subject: string, data: Uint8Array) => void;
|
||||
|
||||
// NatsTransport abstracts the NATS data plane so BusClient's logic is testable with
|
||||
// a mock and the concrete WebSocket transport (nats.ws) stays swappable. The browser
|
||||
// transport connects over ws(s):// using a NATS nkey authenticator built from the
|
||||
// user's Ed25519 identity (see busauth.natsAuthenticator).
|
||||
export interface NatsTransport {
|
||||
publish(subject: string, data: Uint8Array): void | Promise<void>;
|
||||
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
unsubscribe(): void | Promise<void>;
|
||||
}
|
||||
|
||||
// --- control plane (signed HTTP to membershipd) ------------------------------
|
||||
|
||||
interface RoomKeyResponse {
|
||||
sealed_key: string; // base64 sealed box of the room key for this peer
|
||||
epoch: number;
|
||||
}
|
||||
|
||||
// PolicyWire is the control-plane JSON shape of a policy (snake_case sign_msgs).
|
||||
interface PolicyWire {
|
||||
encrypt: boolean;
|
||||
persist: boolean;
|
||||
sign_msgs: boolean;
|
||||
}
|
||||
|
||||
// RoomResp is GET /rooms/{id}: the room metadata WITHOUT the id (the caller knows it)
|
||||
// and with the policy nested under snake_case keys.
|
||||
interface RoomResp {
|
||||
subject: string;
|
||||
epoch: number;
|
||||
policy: PolicyWire;
|
||||
}
|
||||
|
||||
interface MemberJSON {
|
||||
endpoint: string;
|
||||
sign_pub: string; // base64
|
||||
}
|
||||
|
||||
// ControlPlane is the signed HTTP client for the membershipd control plane. Every
|
||||
// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no
|
||||
// host so it can target any cluster node.
|
||||
export class ControlPlane {
|
||||
constructor(
|
||||
private baseURL: string,
|
||||
private id: Identity,
|
||||
) {}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const bodyBytes = body === undefined ? new Uint8Array(0) : new TextEncoder().encode(JSON.stringify(body));
|
||||
const headers = signedHeaders(
|
||||
this.id.signPub,
|
||||
this.id.signPriv,
|
||||
method,
|
||||
path,
|
||||
String(Math.floor(Date.now() / 1000)),
|
||||
freshNonce(),
|
||||
bodyBytes,
|
||||
);
|
||||
const init: RequestInit = { method, headers: { ...headers } };
|
||||
if (body !== undefined) {
|
||||
(init.headers as Record<string, string>)["Content-Type"] = "application/json";
|
||||
init.body = bodyBytes;
|
||||
}
|
||||
const resp = await fetch(this.baseURL + path, init);
|
||||
if (!resp.ok) {
|
||||
let msg = `${method} ${path} -> ${resp.status}`;
|
||||
try {
|
||||
const e = await resp.json();
|
||||
if (e?.error) msg = `${e.error} (HTTP ${resp.status})`;
|
||||
} catch {
|
||||
/* keep the generic message */
|
||||
}
|
||||
throw new Error(`control plane: ${msg}`);
|
||||
}
|
||||
return (await resp.json()) as T;
|
||||
}
|
||||
|
||||
// fetchRoom resolves room metadata, mapping the control-plane wire shape
|
||||
// (snake_case policy, no id) to the SDK's Room type.
|
||||
async fetchRoom(roomID: string): Promise<Room> {
|
||||
const r = await this.request<RoomResp>("GET", `/rooms/${roomID}`);
|
||||
return {
|
||||
id: roomID,
|
||||
subject: r.subject,
|
||||
epoch: r.epoch,
|
||||
policy: { encrypt: r.policy.encrypt, persist: r.policy.persist, signMsgs: r.policy.sign_msgs },
|
||||
};
|
||||
}
|
||||
|
||||
// createRoom creates a room owned by this peer. For an encrypted room it mints a
|
||||
// fresh 32-byte room key, seals it to the owner's own X25519 key (sealed box), and
|
||||
// ships it as sealed_key_self so the server can store the owner's copy without ever
|
||||
// seeing the key. Returns the new room id and (for encrypted rooms) the key.
|
||||
async createRoom(subject: string, policy: Policy): Promise<{ roomID: string; key?: Uint8Array }> {
|
||||
const body: Record<string, unknown> = {
|
||||
subject,
|
||||
policy: { encrypt: policy.encrypt, persist: policy.persist, sign_msgs: policy.signMsgs },
|
||||
owner: {
|
||||
endpoint: endpointID(this.id.signPub),
|
||||
sign_pub: bytesToBase64(this.id.signPub),
|
||||
kex_pub: bytesToBase64(this.id.kexPub),
|
||||
},
|
||||
};
|
||||
let key: Uint8Array | undefined;
|
||||
if (policy.encrypt) {
|
||||
key = crypto.getRandomValues(new Uint8Array(32));
|
||||
body.sealed_key_self = bytesToBase64(sealKeyBox(this.id.kexPub, key));
|
||||
}
|
||||
const resp = await this.request<{ room_id: string }>("POST", "/rooms", body);
|
||||
return { roomID: resp.room_id, key };
|
||||
}
|
||||
|
||||
// fetchRoomKey fetches the sealed room key for this peer and opens it with the
|
||||
// user's X25519 private key. The server only ever stores the key sealed for each
|
||||
// member, so it cannot read it.
|
||||
async fetchRoomKey(roomID: string, epoch: number): Promise<{ key: Uint8Array; epoch: number }> {
|
||||
const q = epoch > 0 ? `&epoch=${epoch}` : "";
|
||||
const resp = await this.request<RoomKeyResponse>(
|
||||
"GET",
|
||||
`/rooms/${roomID}/key?endpoint=${endpointID(this.id.signPub)}${q}`,
|
||||
);
|
||||
const sealed = base64ToBytesLocal(resp.sealed_key);
|
||||
const key = openKeyBox(this.id.kexPub, this.id.kexPriv, sealed);
|
||||
if (!key) throw new Error("control plane: failed to open room key");
|
||||
return { key, epoch: resp.epoch };
|
||||
}
|
||||
|
||||
// listMembers returns the room's members keyed by endpoint, so a receiver can find
|
||||
// a sender's signing public key to verify message signatures.
|
||||
async signerKeys(roomID: string): Promise<Map<string, Uint8Array>> {
|
||||
const members = await this.request<MemberJSON[]>("GET", `/rooms/${roomID}/members`);
|
||||
const m = new Map<string, Uint8Array>();
|
||||
for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub));
|
||||
return m;
|
||||
}
|
||||
}
|
||||
|
||||
// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's
|
||||
// surface; identical behavior to crypto.base64ToBytes).
|
||||
function base64ToBytesLocal(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- BusClient ---------------------------------------------------------------
|
||||
|
||||
// BusClient ties the data plane (transport) and control plane together, applying the
|
||||
// room envelope on publish and subscribe. It holds the user's identity in memory and
|
||||
// never sends the private key anywhere.
|
||||
export class BusClient {
|
||||
private endpoint: string;
|
||||
private keyCache = new Map<string, Map<number, Uint8Array>>(); // roomID -> epoch -> K
|
||||
private signCache = new Map<string, Map<string, Uint8Array>>(); // roomID -> endpoint -> signPub
|
||||
|
||||
constructor(
|
||||
private id: Identity,
|
||||
private transport: NatsTransport,
|
||||
private control: ControlPlane,
|
||||
) {
|
||||
this.endpoint = endpointID(id.signPub);
|
||||
}
|
||||
|
||||
private async roomKey(roomID: string, epoch: number): Promise<Uint8Array> {
|
||||
const cached = this.keyCache.get(roomID)?.get(epoch);
|
||||
if (cached) return cached;
|
||||
const { key, epoch: ep } = await this.control.fetchRoomKey(roomID, epoch);
|
||||
let byEpoch = this.keyCache.get(roomID);
|
||||
if (!byEpoch) {
|
||||
byEpoch = new Map();
|
||||
this.keyCache.set(roomID, byEpoch);
|
||||
}
|
||||
byEpoch.set(ep, key);
|
||||
return key;
|
||||
}
|
||||
|
||||
// publish seals plaintext per the room policy and publishes it on the data plane.
|
||||
async publish(roomID: string, plaintext: Uint8Array, opts: { threadID?: string; replyTo?: string; type?: FrameType } = {}): Promise<void> {
|
||||
const room = await this.control.fetchRoom(roomID);
|
||||
const roomKey = room.policy.encrypt ? await this.roomKey(roomID, room.epoch) : undefined;
|
||||
const f = sealRoomMessage({
|
||||
type: opts.type ?? FrameType.PUB,
|
||||
subject: room.subject,
|
||||
sender: this.endpoint,
|
||||
signPriv: this.id.signPriv,
|
||||
policy: room.policy,
|
||||
epoch: room.epoch,
|
||||
plaintext,
|
||||
roomKey,
|
||||
threadID: opts.threadID,
|
||||
replyTo: opts.replyTo,
|
||||
});
|
||||
await this.transport.publish(room.subject, marshal(f));
|
||||
}
|
||||
|
||||
// subscribe delivers decoded, verified, decrypted messages for a room. Messages
|
||||
// that fail signature verification or decryption are dropped silently.
|
||||
async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise<Subscription> {
|
||||
const room = await this.control.fetchRoom(roomID);
|
||||
if (room.policy.signMsgs) await this.loadSigners(roomID);
|
||||
return this.transport.subscribe(room.subject, async (_subject, data) => {
|
||||
const f = unmarshal(data);
|
||||
const signerPub = room.policy.signMsgs ? this.signCache.get(roomID)?.get(f.sender) : undefined;
|
||||
const roomKey = room.policy.encrypt ? await this.roomKey(roomID, f.epoch) : undefined;
|
||||
const plaintext = openRoomMessage(f, room.policy, signerPub, roomKey);
|
||||
if (plaintext) handler(f, plaintext);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadSigners(roomID: string): Promise<void> {
|
||||
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Bus crypto primitives, ported to the browser to match the Go reference
|
||||
// implementation (functions/cybersecurity in fn-registry) byte-for-byte. The bus
|
||||
// is end-to-end encrypted; doing the crypto here is what keeps the user's private
|
||||
// key on the device and out of any server (issue 0001). Parity with Go is enforced
|
||||
// by the vectors in testdata/vectors.json (see vectors.test.ts).
|
||||
//
|
||||
// Primitive map (Go -> here):
|
||||
// EndpointID -> endpointID : base64url(sha256(signPub)), unpadded
|
||||
// SignEd25519 -> signEd25519 : Ed25519 detached signature
|
||||
// verify -> verifyEd25519
|
||||
// SealAEAD/Open -> sealAEAD/openAEAD : ChaCha20-Poly1305 (IETF, 12-byte nonce)
|
||||
// SealKeyBox/Open -> sealKeyBox/openKeyBox : NaCl anonymous sealed box (X25519),
|
||||
// with the nonce derived as sha512(ephPub||recipientPub)[:24]
|
||||
// EXACTLY as Go's nacl/box.SealAnonymous (Go uses SHA-512, not
|
||||
// libsodium's blake2b — matching this is the whole point).
|
||||
|
||||
import { ed25519 } from "@noble/curves/ed25519.js";
|
||||
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { blake2b } from "@noble/hashes/blake2.js";
|
||||
import { concatBytes } from "@noble/hashes/utils.js";
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
// sealedBoxNonce derives the 24-byte nonce for an anonymous sealed box the same way
|
||||
// Go's nacl/box.SealAnonymous (and libsodium's crypto_box_seal) do: BLAKE2b-192 over
|
||||
// ephemeralPub || recipientPub. NOT SHA-512 — matching the exact hash is what makes
|
||||
// a Go-sealed room key openable here.
|
||||
function sealedBoxNonce(ephPub: Uint8Array, recipientPub: Uint8Array): Uint8Array {
|
||||
return blake2b(concatBytes(ephPub, recipientPub), { dkLen: 24 });
|
||||
}
|
||||
|
||||
// --- byte / encoding helpers (browser-safe; no Buffer) -----------------------
|
||||
|
||||
export function bytesToHex(b: Uint8Array): string {
|
||||
let s = "";
|
||||
for (const x of b) s += x.toString(16).padStart(2, "0");
|
||||
return s;
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
if (hex.length % 2 !== 0) throw new Error("hex: odd length");
|
||||
const out = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
// base64 standard (with padding) — matches Go's encoding/json for []byte fields.
|
||||
export function bytesToBase64(b: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const x of b) bin += String.fromCharCode(x);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToBytes(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
// base64url without padding — matches Go's base64.RawURLEncoding (EndpointID).
|
||||
export function bytesToBase64URL(b: Uint8Array): string {
|
||||
return bytesToBase64(b).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
// --- identity / signing ------------------------------------------------------
|
||||
|
||||
// endpointID is the stable, transport-agnostic peer id: base64url(sha256(signPub)).
|
||||
export function endpointID(signPub: Uint8Array): string {
|
||||
return bytesToBase64URL(sha256(signPub));
|
||||
}
|
||||
|
||||
// signEd25519 signs msg with an Ed25519 private key. It accepts the bus/Go 64-byte
|
||||
// private key (seed || pub) OR a bare 32-byte seed; @noble signs from the 32-byte
|
||||
// seed, so we slice the seed out of the 64-byte form.
|
||||
export function signEd25519(priv: Uint8Array, msg: Uint8Array): Uint8Array {
|
||||
const seed = priv.length === 64 ? priv.subarray(0, 32) : priv;
|
||||
return ed25519.sign(msg, seed);
|
||||
}
|
||||
|
||||
export function verifyEd25519(sig: Uint8Array, msg: Uint8Array, pub: Uint8Array): boolean {
|
||||
return ed25519.verify(sig, msg, pub);
|
||||
}
|
||||
|
||||
// --- AEAD (room message content) ---------------------------------------------
|
||||
|
||||
// sealAEAD encrypts plaintext with ChaCha20-Poly1305 (IETF, 12-byte nonce). The
|
||||
// caller supplies the nonce so the operation is testable; in the bus a fresh random
|
||||
// nonce is generated per message and stored alongside the ciphertext.
|
||||
export function sealAEAD(key: Uint8Array, nonce: Uint8Array, plaintext: Uint8Array, aad?: Uint8Array): Uint8Array {
|
||||
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
|
||||
}
|
||||
|
||||
export function openAEAD(key: Uint8Array, nonce: Uint8Array, ciphertext: Uint8Array, aad?: Uint8Array): Uint8Array {
|
||||
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
|
||||
}
|
||||
|
||||
// randomNonce returns a fresh 12-byte AEAD nonce (ChaCha20-Poly1305 IETF size).
|
||||
export function randomNonce(): Uint8Array {
|
||||
return crypto.getRandomValues(new Uint8Array(12));
|
||||
}
|
||||
|
||||
// --- anonymous sealed box (room key distribution) ----------------------------
|
||||
|
||||
// sealKeyBox seals secret to a recipient's X25519 public key as an anonymous NaCl
|
||||
// sealed box, matching Go's nacl/box.SealAnonymous: an ephemeral keypair is created,
|
||||
// the nonce is sha512(ephPub || recipientPub)[:24], and the output is
|
||||
// ephPub(32) || box(secret). The recipient opens it with openKeyBox; the sender is
|
||||
// anonymous (no long-term sender key is revealed).
|
||||
export function sealKeyBox(recipientKexPub: Uint8Array, secret: Uint8Array): Uint8Array {
|
||||
const eph = nacl.box.keyPair();
|
||||
const nonce = sealedBoxNonce(eph.publicKey, recipientKexPub);
|
||||
const boxed = nacl.box(secret, nonce, recipientKexPub, eph.secretKey);
|
||||
return concatBytes(eph.publicKey, boxed);
|
||||
}
|
||||
|
||||
// openKeyBox opens an anonymous sealed box produced by sealKeyBox (or Go's
|
||||
// SealKeyBox). It re-derives the same sha512-based nonce from the embedded ephemeral
|
||||
// public key and the recipient's own public key, then opens the box with the
|
||||
// recipient's private key. Returns null if authentication fails.
|
||||
export function openKeyBox(
|
||||
recipientKexPub: Uint8Array,
|
||||
recipientKexPriv: Uint8Array,
|
||||
sealed: Uint8Array,
|
||||
): Uint8Array | null {
|
||||
if (sealed.length < 32) return null;
|
||||
const ephPub = sealed.subarray(0, 32);
|
||||
const boxed = sealed.subarray(32);
|
||||
const nonce = sealedBoxNonce(ephPub, recipientKexPub);
|
||||
return nacl.box.open(boxed, nonce, ephPub, recipientKexPriv);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Tests for the room envelope (the security-critical core of the client): sealing a
|
||||
// message and opening it back, for the encrypted+signed room and the cleartext room,
|
||||
// plus the failure paths (bad signature, wrong key) that MUST drop the message.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { ModeMatrix, ModeNATS } from "./room.js";
|
||||
import { FrameType } from "./frame.js";
|
||||
import { sealRoomMessage, openRoomMessage } from "./client.js";
|
||||
import { endpointID, hexToBytes } from "./crypto.js";
|
||||
import vectors from "./testdata/vectors.json";
|
||||
|
||||
// A deterministic identity from the vectors, so tests do not depend on randomness
|
||||
// for the keys (the AEAD nonce is still random, which is what we want).
|
||||
const signPriv = hexToBytes(vectors.sign.sign_priv_hex);
|
||||
const signPub = hexToBytes(vectors.sign.sign_pub_hex);
|
||||
const sender = endpointID(signPub);
|
||||
const roomKey = hexToBytes(vectors.aead.key_hex);
|
||||
|
||||
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||
const str = (b: Uint8Array) => new TextDecoder().decode(b);
|
||||
|
||||
describe("room envelope — encrypted + signed (ModeMatrix)", () => {
|
||||
function seal(plaintext: string) {
|
||||
return sealRoomMessage({
|
||||
type: FrameType.PUB,
|
||||
subject: "room.parity",
|
||||
sender,
|
||||
signPriv,
|
||||
policy: ModeMatrix,
|
||||
epoch: 1,
|
||||
plaintext: utf8(plaintext),
|
||||
roomKey,
|
||||
});
|
||||
}
|
||||
|
||||
it("round-trips: seal then open recovers the plaintext", () => {
|
||||
const f = seal("hello e2e");
|
||||
expect(f.nonce && f.nonce.length).toBeTruthy();
|
||||
expect(f.payload && f.payload.length).toBeTruthy();
|
||||
expect(f.sig && f.sig.length).toBeTruthy();
|
||||
const opened = openRoomMessage(f, ModeMatrix, signPub, roomKey);
|
||||
expect(opened).not.toBeNull();
|
||||
expect(str(opened!)).toBe("hello e2e");
|
||||
});
|
||||
|
||||
it("drops a message with a tampered signature", () => {
|
||||
const f = seal("trust me");
|
||||
f.sig![0] ^= 0xff; // corrupt the signature
|
||||
expect(openRoomMessage(f, ModeMatrix, signPub, roomKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("drops a message opened with the wrong room key", () => {
|
||||
const f = seal("secret");
|
||||
const wrongKey = hexToBytes(vectors.keybox.secret_hex); // a different 32-byte key
|
||||
expect(openRoomMessage(f, ModeMatrix, signPub, wrongKey)).toBeNull();
|
||||
});
|
||||
|
||||
it("ciphertext does not contain the plaintext", () => {
|
||||
const f = seal("plaintext-marker");
|
||||
const wire = new TextDecoder("latin1").decode(f.payload!);
|
||||
expect(wire.includes("plaintext-marker")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("room envelope — cleartext (ModeNATS)", () => {
|
||||
it("carries the payload as-is and opens without a key", () => {
|
||||
const f = sealRoomMessage({
|
||||
type: FrameType.PUB,
|
||||
subject: "room.clear",
|
||||
sender,
|
||||
signPriv,
|
||||
policy: ModeNATS,
|
||||
epoch: 0,
|
||||
plaintext: utf8("in the clear"),
|
||||
});
|
||||
expect(f.sig).toBeUndefined();
|
||||
const opened = openRoomMessage(f, ModeNATS, undefined, undefined);
|
||||
expect(str(opened!)).toBe("in the clear");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// The wire format of the unibus message bus, ported from Go pkg/frame. A Frame is
|
||||
// the unit transported over NATS: a cleartext envelope plus an optional AEAD
|
||||
// ciphertext payload, signed end-to-end with Ed25519.
|
||||
//
|
||||
// The signature covers the canonical JSON of the frame with the signature field
|
||||
// cleared, so the marshaler here must reproduce Go's encoding/json BYTE FOR BYTE or
|
||||
// signatures verified by Go peers would fail. That means: struct field order, the
|
||||
// `omitempty` rules, base64-standard encoding of []byte fields, and Go's default
|
||||
// HTML escaping of <, >, & and the U+2028/U+2029 separators inside strings. Parity
|
||||
// is pinned by testdata/vectors.json (vectors.test.ts).
|
||||
|
||||
import {
|
||||
bytesToBase64,
|
||||
base64ToBytes,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
endpointID,
|
||||
} from "./crypto.js";
|
||||
|
||||
export enum FrameType {
|
||||
PUB = 0,
|
||||
INVITE = 1,
|
||||
JOIN = 2,
|
||||
LEAVE = 3,
|
||||
KICK = 4,
|
||||
ACK = 5,
|
||||
REACT = 6,
|
||||
}
|
||||
|
||||
export interface BlobRef {
|
||||
hash: string; // sha256 hex of the blob ciphertext
|
||||
nonce: Uint8Array; // AEAD nonce used to encrypt the blob
|
||||
size: number; // ciphertext size in bytes
|
||||
}
|
||||
|
||||
export interface Frame {
|
||||
type: FrameType;
|
||||
subject: string;
|
||||
sender: string; // endpoint id = endpointID(signPub)
|
||||
msgID: string; // ULID
|
||||
epoch: number; // epoch of the room key used to encrypt
|
||||
threadID?: string; // root message id of the thread (optional)
|
||||
replyTo?: string; // message id this frame replies to / reacts to (optional)
|
||||
nonce?: Uint8Array; // AEAD nonce (encrypted rooms only)
|
||||
payload?: Uint8Array; // AEAD ciphertext (or cleartext if the room is not encrypted)
|
||||
blob?: BlobRef;
|
||||
sig?: Uint8Array; // Ed25519 signature over signingBytes()
|
||||
}
|
||||
|
||||
// Go's encoding/json HTML-escapes these code points inside strings by default. We
|
||||
// replay the exact same set so our canonical bytes match Go's. The two separators
|
||||
// (U+2028 line separator, U+2029 paragraph separator) are built via fromCharCode so
|
||||
// this source file holds no invisible characters while the RegExp still matches the
|
||||
// real code points at runtime.
|
||||
const GO_ESCAPES: ReadonlyArray<[RegExp, string]> = [
|
||||
[/</g, "\\u003c"],
|
||||
[/>/g, "\\u003e"],
|
||||
[/&/g, "\\u0026"],
|
||||
[new RegExp(String.fromCharCode(0x2028), "g"), "\\u2028"],
|
||||
[new RegExp(String.fromCharCode(0x2029), "g"), "\\u2029"],
|
||||
];
|
||||
|
||||
// goJSONStringify serializes obj the way Go's encoding/json does: compact (no
|
||||
// spaces), insertion-ordered keys, and the default HTML escaping above. Apply only
|
||||
// to objects built key-by-key in field order, so the output matches Go's struct
|
||||
// marshaling exactly.
|
||||
function goJSONStringify(obj: Record<string, unknown>): string {
|
||||
let s = JSON.stringify(obj);
|
||||
for (const [re, rep] of GO_ESCAPES) s = s.replace(re, rep);
|
||||
return s;
|
||||
}
|
||||
|
||||
// frameObject builds the plain object with keys inserted in Go struct-declaration
|
||||
// order, applying each field's omitempty rule. includeSig controls whether the
|
||||
// signature field is emitted: false yields the canonical signing-bytes object.
|
||||
function frameObject(f: Frame, includeSig: boolean): Record<string, unknown> {
|
||||
const o: Record<string, unknown> = {};
|
||||
// Always-present fields (no omitempty in Go).
|
||||
o.t = f.type;
|
||||
o.s = f.subject;
|
||||
o.from = f.sender;
|
||||
o.id = f.msgID;
|
||||
o.e = f.epoch;
|
||||
// omitempty fields, in declaration order.
|
||||
if (f.threadID) o.thr = f.threadID;
|
||||
if (f.replyTo) o.re = f.replyTo;
|
||||
if (f.nonce && f.nonce.length) o.n = bytesToBase64(f.nonce);
|
||||
if (f.payload && f.payload.length) o.p = bytesToBase64(f.payload);
|
||||
if (f.blob) o.b = { h: f.blob.hash, n: bytesToBase64(f.blob.nonce), sz: f.blob.size };
|
||||
if (includeSig && f.sig && f.sig.length) o.sig = bytesToBase64(f.sig);
|
||||
return o;
|
||||
}
|
||||
|
||||
// marshal returns the wire bytes of the frame (UTF-8 of the canonical JSON).
|
||||
export function marshal(f: Frame): Uint8Array {
|
||||
return new TextEncoder().encode(goJSONStringify(frameObject(f, true)));
|
||||
}
|
||||
|
||||
// signingBytes returns the canonical bytes that are signed and verified: the frame
|
||||
// JSON with the signature field cleared.
|
||||
export function signingBytes(f: Frame): Uint8Array {
|
||||
return new TextEncoder().encode(goJSONStringify(frameObject(f, false)));
|
||||
}
|
||||
|
||||
// unmarshal parses wire bytes back into a Frame, decoding the base64 []byte fields.
|
||||
export function unmarshal(b: Uint8Array): Frame {
|
||||
const o = JSON.parse(new TextDecoder().decode(b));
|
||||
const f: Frame = {
|
||||
type: o.t ?? 0,
|
||||
subject: o.s ?? "",
|
||||
sender: o.from ?? "",
|
||||
msgID: o.id ?? "",
|
||||
epoch: o.e ?? 0,
|
||||
};
|
||||
if (o.thr) f.threadID = o.thr;
|
||||
if (o.re) f.replyTo = o.re;
|
||||
if (o.n) f.nonce = base64ToBytes(o.n);
|
||||
if (o.p) f.payload = base64ToBytes(o.p);
|
||||
if (o.b) f.blob = { hash: o.b.h, nonce: base64ToBytes(o.b.n), size: o.b.sz };
|
||||
if (o.sig) f.sig = base64ToBytes(o.sig);
|
||||
return f;
|
||||
}
|
||||
|
||||
// signFrame fills f.sig with an Ed25519 signature over signingBytes(f). signPriv is
|
||||
// the 64-byte (seed||pub) or 32-byte seed private key.
|
||||
export function signFrame(f: Frame, signPriv: Uint8Array): Frame {
|
||||
f.sig = signEd25519(signPriv, signingBytes(f));
|
||||
return f;
|
||||
}
|
||||
|
||||
// verifyFrame checks f.sig against signPub over signingBytes(f).
|
||||
export function verifyFrame(f: Frame, signPub: Uint8Array): boolean {
|
||||
if (!f.sig) return false;
|
||||
return verifyEd25519(f.sig, signingBytes(f), signPub);
|
||||
}
|
||||
|
||||
// senderEndpoint derives the canonical sender endpoint id from a signing public key.
|
||||
export function senderEndpoint(signPub: Uint8Array): string {
|
||||
return endpointID(signPub);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Public API of the browser-native bus SDK. The SPA imports from here; the internal
|
||||
// module split (crypto / frame / room / busauth / client / wstransport) stays an
|
||||
// implementation detail. See issue uniweb/0001.
|
||||
|
||||
export * from "./crypto.js";
|
||||
export * from "./frame.js";
|
||||
export * from "./room.js";
|
||||
export * from "./busauth.js";
|
||||
export * from "./client.js";
|
||||
export { WsNatsTransport } from "./wstransport.js";
|
||||
@@ -0,0 +1,168 @@
|
||||
// Live integration smoke against the real unibus cluster. NOT part of the unit
|
||||
// suite (needs network + a running cluster + TLS bypass), so it self-skips unless
|
||||
// BUS_HTTP / BUS_WS are set. Run it explicitly:
|
||||
//
|
||||
// NODE_TLS_REJECT_UNAUTHORIZED=0 \
|
||||
// BUS_HTTP=https://51.91.100.142:8470 BUS_WS=wss://51.91.100.142:8480 \
|
||||
// pnpm exec vitest run src/bus/integration.test.ts
|
||||
//
|
||||
// What it proves WITHOUT a registered user: a fresh random identity is NOT in the
|
||||
// bus allowlist, so both planes must reject it with an AUTHORIZATION error — not a
|
||||
// signature/protocol error. That result confirms the SDK speaks both planes
|
||||
// correctly end-to-end (busauth canonical+signature on HTTP, nkey handshake on the
|
||||
// data plane); only the allowlist gate stops it. Issue uniweb/0001, Phase 3.
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync, existsSync } from "node:fs";
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
||||
import { concatBytes } from "@noble/hashes/utils.js";
|
||||
import { signedHeaders, freshNonce } from "./busauth.js";
|
||||
import { hexToBytes } from "./crypto.js";
|
||||
import { WsNatsTransport } from "./wstransport.js";
|
||||
import { BusClient, ControlPlane, type Identity } from "./client.js";
|
||||
import type { Frame } from "./frame.js";
|
||||
|
||||
const BUS_HTTP = process.env.BUS_HTTP;
|
||||
const BUS_WS = process.env.BUS_WS;
|
||||
const live = !!(BUS_HTTP && BUS_WS);
|
||||
|
||||
// An optional REGISTERED identity (its sign_pub added to the bus allowlist out of
|
||||
// band). When present, the second describe block proves the same SDK that gets
|
||||
// rejected with a fresh identity is ACCEPTED once the identity is allow-listed —
|
||||
// closing the loop that the allowlist is the only gate.
|
||||
const ID_FILE = process.env.BUS_IDENTITY || "/tmp/smoke_identity.json";
|
||||
function registeredIdentity(): Identity | null {
|
||||
if (!existsSync(ID_FILE)) return null;
|
||||
const j = JSON.parse(readFileSync(ID_FILE, "utf8"));
|
||||
return {
|
||||
signPub: hexToBytes(j.signPub),
|
||||
signPriv: hexToBytes(j.signPriv),
|
||||
kexPub: hexToBytes(j.kexPub),
|
||||
kexPriv: hexToBytes(j.kexPriv),
|
||||
};
|
||||
}
|
||||
|
||||
function freshIdentity(): Identity {
|
||||
const seed = crypto.getRandomValues(new Uint8Array(32));
|
||||
const signPub = ed25519.getPublicKey(seed);
|
||||
const signPriv = concatBytes(seed, signPub); // 64-byte Go layout
|
||||
const kexPriv = crypto.getRandomValues(new Uint8Array(32));
|
||||
const kexPub = x25519.getPublicKey(kexPriv);
|
||||
return { signPub, signPriv, kexPub, kexPriv };
|
||||
}
|
||||
|
||||
describe.skipIf(!live)("live cluster smoke", () => {
|
||||
const id = freshIdentity();
|
||||
|
||||
it("control plane: a signed request is processed (rejected by allowlist, not by signature)", async () => {
|
||||
const ts = String(Math.floor(Date.now() / 1000));
|
||||
const path = "/rooms/smoke-probe/members";
|
||||
const headers = signedHeaders(id.signPub, id.signPriv, "GET", path, ts, freshNonce(), new Uint8Array(0));
|
||||
const resp = await fetch(BUS_HTTP + path, { method: "GET", headers });
|
||||
const body = await resp.text();
|
||||
// The server verified our X-Unibus-* signature (busauth canonical + Ed25519 are
|
||||
// correct) and then rejected us for not being in the allowlist. A 401 whose body
|
||||
// is an authorization message — NOT "signature"/"canonical" — is the pass.
|
||||
expect(resp.status).toBe(401);
|
||||
expect(body.toLowerCase()).toContain("unauthorized");
|
||||
expect(body.toLowerCase()).not.toContain("signature");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[control-plane] status=${resp.status} body=${body.trim()}`);
|
||||
});
|
||||
|
||||
it("data plane: nats.ws handshake reaches the nkey authenticator (authorization violation)", async () => {
|
||||
let connected = false;
|
||||
let errMsg = "";
|
||||
try {
|
||||
const t = await WsNatsTransport.connect([BUS_WS!], id);
|
||||
connected = true;
|
||||
await t.close();
|
||||
} catch (e) {
|
||||
errMsg = String((e as Error).message || e).toLowerCase();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[data-plane] connected=${connected} err=${errMsg}`);
|
||||
// A fresh identity is not allow-listed, so the nkey authenticator must refuse the
|
||||
// connection. Reaching an "authorization"/"nkey" rejection proves the WS transport
|
||||
// + nkey signing path work against the real server. (If the user WERE registered,
|
||||
// connected would be true.)
|
||||
expect(connected || /authorization|nkey|permission|violation/.test(errMsg)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const regId = live ? registeredIdentity() : null;
|
||||
|
||||
describe.skipIf(!live || !regId)("live cluster smoke — REGISTERED identity is accepted", () => {
|
||||
const id = regId!;
|
||||
|
||||
it("control plane: a registered identity is authorized (not 401)", async () => {
|
||||
const ts = String(Math.floor(Date.now() / 1000));
|
||||
const path = "/rooms/smoke-probe/members";
|
||||
const headers = signedHeaders(id.signPub, id.signPriv, "GET", path, ts, freshNonce(), new Uint8Array(0));
|
||||
const resp = await fetch(BUS_HTTP + path, { method: "GET", headers });
|
||||
const body = await resp.text();
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[control-plane:registered] status=${resp.status} body=${body.trim()}`);
|
||||
// The allowlist no longer rejects us: the status is anything but 401 (a missing
|
||||
// room yields 404/403, an existing one 200). The point is the identity passed.
|
||||
expect(resp.status).not.toBe(401);
|
||||
});
|
||||
|
||||
it("data plane: a registered identity connects over nats.ws (authenticated)", async () => {
|
||||
let connected = false;
|
||||
let errMsg = "";
|
||||
try {
|
||||
const t = await WsNatsTransport.connect([BUS_WS!], id);
|
||||
connected = true;
|
||||
await t.close();
|
||||
} catch (e) {
|
||||
errMsg = String((e as Error).message || e).toLowerCase();
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[data-plane:registered] connected=${connected} err=${errMsg}`);
|
||||
// Now the nkey authenticator accepts us: the connection succeeds. This is the
|
||||
// full proof that the SDK authenticates on the live data plane end-to-end.
|
||||
expect(connected).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(!live || !regId)("live cluster — end-to-end encrypted round-trip", () => {
|
||||
const id = regId!;
|
||||
|
||||
it("creates an encrypted room, publishes, and receives its own decrypted message", async () => {
|
||||
const control = new ControlPlane(BUS_HTTP!, id);
|
||||
// Encrypted + signed, but EPHEMERAL (no JetStream persistence) to keep the smoke
|
||||
// to core NATS pub/sub. A unique subject avoids colliding with prior runs.
|
||||
const subject = `room.smoke-${id.signPub[0]}-${Math.floor(Date.now() / 1000)}`;
|
||||
const { roomID } = await control.createRoom(subject, { encrypt: true, persist: false, signMsgs: true });
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[round-trip] created room ${roomID} subject=${subject}`);
|
||||
|
||||
// Connect the data plane AFTER creating the room: the per-subject ACL freezes a
|
||||
// peer's publishable/subscribable subjects at connect time, so the room's subject
|
||||
// is in our grant only once we connect post-creation.
|
||||
const transport = await WsNatsTransport.connect([BUS_WS!], id);
|
||||
const bus = new BusClient(id, transport, control);
|
||||
|
||||
const got = new Promise<string>((resolve) => {
|
||||
bus.subscribe(roomID, (_f: Frame, plaintext: Uint8Array) => {
|
||||
resolve(new TextDecoder().decode(plaintext));
|
||||
});
|
||||
});
|
||||
|
||||
// Give the subscription a moment to register on the server before publishing.
|
||||
await new Promise((r) => setTimeout(r, 600));
|
||||
const message = "hello from the browser SDK, end to end";
|
||||
await bus.publish(roomID, new TextEncoder().encode(message));
|
||||
|
||||
const received = await Promise.race([
|
||||
got,
|
||||
new Promise<string>((_r, reject) => setTimeout(() => reject(new Error("timeout waiting for message")), 8000)),
|
||||
]);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[round-trip] received="${received}"`);
|
||||
await transport.close();
|
||||
|
||||
expect(received).toBe(message);
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
// Room policy and metadata, ported from Go pkg/room. The policy decides how a
|
||||
// message is treated on the wire: encrypted (AEAD with the room key), persisted
|
||||
// (durable JetStream history), and/or signed (Ed25519 per message).
|
||||
|
||||
export interface Policy {
|
||||
encrypt: boolean; // payload is AEAD-encrypted with the room key K
|
||||
persist: boolean; // messages are kept in durable history (JetStream)
|
||||
signMsgs: boolean; // each message carries an Ed25519 signature over its canonical bytes
|
||||
}
|
||||
|
||||
// ModeNATS is a cleartext, ephemeral, unsigned room (the raw NATS behavior).
|
||||
export const ModeNATS: Policy = { encrypt: false, persist: false, signMsgs: false };
|
||||
|
||||
// ModeMatrix is the secure default: end-to-end encrypted, persisted, and signed —
|
||||
// the Matrix-like room the bus uses for real conversations.
|
||||
export const ModeMatrix: Policy = { encrypt: true, persist: true, signMsgs: true };
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
subject: string;
|
||||
epoch: number;
|
||||
policy: Policy;
|
||||
}
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"note": "Deterministic cross-language vectors for the unibus protocol. Generated by cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.",
|
||||
"endpoint_id": {
|
||||
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||
"endpoint_id": "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw"
|
||||
},
|
||||
"nkey": {
|
||||
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||
"nkey_public": "UAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3RLKK"
|
||||
},
|
||||
"sign": {
|
||||
"sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||
"sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8",
|
||||
"message_hex": "756e696275732070617269747920766563746f72206d657373616765",
|
||||
"sig_hex": "4cb94c5e3d81ac795e62e089b069c678a3ad3abdf67aed6daf84c023e77378a9c37e2c5b7350d2b129b7985dae132bdfe8b3e2d273d52b522a311131c62ec005"
|
||||
},
|
||||
"aead": {
|
||||
"key_hex": "606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f",
|
||||
"nonce_hex": "808182838485868788898a8b",
|
||||
"aad_hex": "756e696275732d726f6f6d2d3432",
|
||||
"plaintext_hex": "68656c6c6f2066726f6d2074686520627573",
|
||||
"ciphertext_hex": "31a15f343585bd1831a35a43fdc974e87d5d76957284f13a1ffabdba78fe762ab7e4"
|
||||
},
|
||||
"keybox": {
|
||||
"recipient_kex_pub_hex": "79a631eede1bf9c98f12032cdeadd0e7a079398fc786b88cc846ec89af85a51a",
|
||||
"recipient_kex_priv_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f",
|
||||
"secret_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf",
|
||||
"sealed_hex": "70dfe90c477bac85a758c0c420c36d44e84a8e06434e2344e9e5c730a56e71404a592d37d79aa7c7a997c002160bac6a91c96fb0e6898153348eb19a6d9dc53b5677d40b0c0fdfc47c0b00727a61f04f"
|
||||
},
|
||||
"frame": {
|
||||
"type": 0,
|
||||
"subject": "room.parity",
|
||||
"sender": "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw",
|
||||
"msg_id": "01HZY0VECTORFIXEDULID0001",
|
||||
"epoch": 1,
|
||||
"nonce_hex": "808182838485868788898a8b",
|
||||
"payload_hex": "31a15f343585bd1831a35a43fdc974e87d5d76957284f13a1ffabdba78fe762ab7e4",
|
||||
"wire_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSIsInNpZyI6IkZOTDFhak0yZFA2c3J5WENyMmoxOVNCVS9rT29MUEpUR2gzNGpuK3pTMVdrV1JPa1ZhTTlXU042WnFrSW1BUjluSGNHYXo4VnJJL3dSMzAyNWFLbkRRPT0ifQ==",
|
||||
"signing_bytes_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSJ9",
|
||||
"sig_hex": "14d2f56a333674feacaf25c2af68f5f52054fe43a82cf2531a1df88e7fb34b55a45913a455a33d59237a66a90898047d9c77066b3f15ac8ff0477d36e5a2a70d"
|
||||
},
|
||||
"control_request": {
|
||||
"method": "POST",
|
||||
"path": "/rooms",
|
||||
"ts": "1700000000",
|
||||
"nonce": "Zm9vYmFyMTIzNDU2Nzg5MA==",
|
||||
"body_hex": "7b227375626a656374223a22726f6f6d2e706172697479227d",
|
||||
"canonical_hex": "504f53540a2f726f6f6d730a313730303030303030300a5a6d3976596d46794d54497a4e4455324e7a67354d413d3d0a30393038653333663161366261633463363465313938656530613935623532323866383865393337333366323739663038653830336463353931623137643834",
|
||||
"sig_hex": "1802bd9d6b05b027ed43f0eecdcc831f257065e6e7306e7f0cf8c5db5b07ac57802f6c1e37d4bbc7cc6452d812be644817b908982ba64a455c5e287c6a4c2c0d",
|
||||
"sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Cross-language parity tests: the TypeScript bus SDK must reproduce the Go
|
||||
// reference implementation byte-for-byte. The golden vectors in testdata/vectors.json
|
||||
// are generated by unibus `cmd/busvectors`. Any divergence here means a browser
|
||||
// client and a Go/Kotlin peer would not interoperate (issue 0001, Phase 1).
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import vectors from "./testdata/vectors.json";
|
||||
import {
|
||||
hexToBytes,
|
||||
bytesToHex,
|
||||
base64ToBytes,
|
||||
endpointID,
|
||||
signEd25519,
|
||||
verifyEd25519,
|
||||
sealAEAD,
|
||||
openAEAD,
|
||||
openKeyBox,
|
||||
sealKeyBox,
|
||||
} from "./crypto.js";
|
||||
import { Frame, FrameType, marshal, signingBytes, signFrame, verifyFrame } from "./frame.js";
|
||||
|
||||
describe("endpoint id", () => {
|
||||
it("matches Go EndpointID = base64url(sha256(signPub))", () => {
|
||||
const v = vectors.endpoint_id;
|
||||
expect(endpointID(hexToBytes(v.sign_pub_hex))).toBe(v.endpoint_id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ed25519 signing", () => {
|
||||
it("produces the same deterministic signature as Go", () => {
|
||||
const v = vectors.sign;
|
||||
const sig = signEd25519(hexToBytes(v.sign_priv_hex), hexToBytes(v.message_hex));
|
||||
expect(bytesToHex(sig)).toBe(v.sig_hex);
|
||||
});
|
||||
|
||||
it("verifies the Go-produced signature", () => {
|
||||
const v = vectors.sign;
|
||||
const ok = verifyEd25519(hexToBytes(v.sig_hex), hexToBytes(v.message_hex), hexToBytes(v.sign_pub_hex));
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChaCha20-Poly1305 AEAD", () => {
|
||||
it("opens the Go-sealed ciphertext", () => {
|
||||
const v = vectors.aead;
|
||||
const pt = openAEAD(
|
||||
hexToBytes(v.key_hex),
|
||||
hexToBytes(v.nonce_hex),
|
||||
hexToBytes(v.ciphertext_hex),
|
||||
hexToBytes(v.aad_hex),
|
||||
);
|
||||
expect(bytesToHex(pt)).toBe(v.plaintext_hex);
|
||||
});
|
||||
|
||||
it("seals to the same ciphertext as Go with a fixed nonce", () => {
|
||||
const v = vectors.aead;
|
||||
const ct = sealAEAD(
|
||||
hexToBytes(v.key_hex),
|
||||
hexToBytes(v.nonce_hex),
|
||||
hexToBytes(v.plaintext_hex),
|
||||
hexToBytes(v.aad_hex),
|
||||
);
|
||||
expect(bytesToHex(ct)).toBe(v.ciphertext_hex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anonymous sealed box (room key distribution)", () => {
|
||||
it("opens the Go-sealed room key", () => {
|
||||
const v = vectors.keybox;
|
||||
const secret = openKeyBox(
|
||||
hexToBytes(v.recipient_kex_pub_hex),
|
||||
hexToBytes(v.recipient_kex_priv_hex),
|
||||
hexToBytes(v.sealed_hex),
|
||||
);
|
||||
expect(secret).not.toBeNull();
|
||||
expect(bytesToHex(secret!)).toBe(v.secret_hex);
|
||||
});
|
||||
|
||||
it("round-trips a TS-sealed box (seal then open)", () => {
|
||||
const v = vectors.keybox;
|
||||
const pub = hexToBytes(v.recipient_kex_pub_hex);
|
||||
const priv = hexToBytes(v.recipient_kex_priv_hex);
|
||||
const secret = hexToBytes(v.secret_hex);
|
||||
const sealed = sealKeyBox(pub, secret);
|
||||
const opened = openKeyBox(pub, priv, sealed);
|
||||
expect(opened).not.toBeNull();
|
||||
expect(bytesToHex(opened!)).toBe(v.secret_hex);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Frame wire format", () => {
|
||||
function vectorFrame(): Frame {
|
||||
const v = vectors.frame;
|
||||
return {
|
||||
type: v.type as FrameType,
|
||||
subject: v.subject,
|
||||
sender: v.sender,
|
||||
msgID: v.msg_id,
|
||||
epoch: v.epoch,
|
||||
nonce: hexToBytes(v.nonce_hex),
|
||||
payload: hexToBytes(v.payload_hex),
|
||||
};
|
||||
}
|
||||
|
||||
it("produces the same canonical signing bytes as Go", () => {
|
||||
const got = signingBytes(vectorFrame());
|
||||
const want = base64ToBytes(vectors.frame.signing_bytes_b64);
|
||||
expect(bytesToHex(got)).toBe(bytesToHex(want));
|
||||
});
|
||||
|
||||
it("signs the frame to the same Ed25519 signature as Go", () => {
|
||||
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||
expect(bytesToHex(f.sig!)).toBe(vectors.frame.sig_hex);
|
||||
});
|
||||
|
||||
it("marshals the signed frame to the same wire bytes as Go", () => {
|
||||
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||
const got = marshal(f);
|
||||
const want = base64ToBytes(vectors.frame.wire_b64);
|
||||
expect(bytesToHex(got)).toBe(bytesToHex(want));
|
||||
});
|
||||
|
||||
it("verifies the marshaled frame signature against the signer pubkey", () => {
|
||||
const f = signFrame(vectorFrame(), hexToBytes(vectors.sign.sign_priv_hex));
|
||||
expect(verifyFrame(f, hexToBytes(vectors.sign.sign_pub_hex))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
// Concrete NATS-over-WebSocket transport for the browser, built on nats.ws. This is
|
||||
// the thin glue between the BusClient logic (which is transport-agnostic and unit-
|
||||
// tested) and a live NATS server reached over ws(s)://. Because it needs a running
|
||||
// unibus with the WebSocket listener enabled (issue uniweb/0001, Phase 0), it is
|
||||
// exercised by the end-to-end tests in Phase 3, not by unit tests.
|
||||
//
|
||||
// Note: nats.ws 1.30.x is deprecated upstream in favor of @nats-io/nats-core with a
|
||||
// WebSocket transport; migrating is tracked as Phase 3 follow-up. The connection
|
||||
// authenticates with the user's NATS nkey (derived from their Ed25519 identity), so
|
||||
// the private key signs the server nonce in the browser and never leaves it.
|
||||
|
||||
import { connect, type NatsConnection, type Authenticator } from "nats.ws";
|
||||
import type { Identity, NatsTransport, MessageHandler, Subscription } from "./client.js";
|
||||
import { natsAuthenticator } from "./busauth.js";
|
||||
|
||||
export class WsNatsTransport implements NatsTransport {
|
||||
private constructor(private nc: NatsConnection) {}
|
||||
|
||||
// connect opens a WebSocket connection to one of the given ws(s):// servers,
|
||||
// authenticating with the user's nkey identity.
|
||||
static async connect(servers: string[], id: Identity): Promise<WsNatsTransport> {
|
||||
const sign = natsAuthenticator(id.signPub, id.signPriv);
|
||||
// nats.ws's Authenticator returns the nkey + the base64url signature of the
|
||||
// server nonce; our natsAuthenticator produces exactly that shape.
|
||||
const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? "");
|
||||
const nc = await connect({ servers, authenticator });
|
||||
return new WsNatsTransport(nc);
|
||||
}
|
||||
|
||||
publish(subject: string, data: Uint8Array): void {
|
||||
this.nc.publish(subject, data);
|
||||
}
|
||||
|
||||
async subscribe(subject: string, handler: MessageHandler): Promise<Subscription> {
|
||||
const sub = this.nc.subscribe(subject, {
|
||||
callback: (err, msg) => {
|
||||
if (!err) handler(subject, msg.data);
|
||||
},
|
||||
});
|
||||
return { unsubscribe: () => sub.unsubscribe() };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.nc.close();
|
||||
}
|
||||
}
|
||||
@@ -17,5 +17,6 @@
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user