33 Commits

Author SHA1 Message Date
egutierrez 4994ea1483 feat(web): wallet join/recover/login (BIP39 seed identity)
Add the device-local wallet onboarding to the SPA. The user's identity
is derived deterministically from a 12-word BIP39 mnemonic and lives on
the device; the browser never signs, never talks NATS, and never sends
the seed to the server.

Wallet layer (web/src/wallet/):
- derive.ts: deterministic identity from a mnemonic. seed = BIP39 seed,
  then HKDF-SHA256 domain-separated into an Ed25519 signing key
  (info "unibus-sign-v1") and an X25519 key-exchange key (info
  "unibus-kex-v1"). The same mnemonic always yields the same sign_pub,
  which is what makes recovery possible without admin intervention. The
  four halves match cs.Identity on the Go side exactly.
- bip39.ts: thin wrappers over @scure/bip39 (generate, validate,
  normalize) so the checksum logic stays in the audited library.
- crypto.ts: at-rest encryption of the private key with WebCrypto only
  (PBKDF2-SHA256 210k iters -> AES-256-GCM). The password never leaves
  the device and only protects the local key copy.
- store.ts: IndexedDB persistence of the encrypted identity (private key
  encrypted; public halves + handle in the clear for display).
- account.ts: saveAndOpen / unlockAndOpen / localIdentity compose the
  primitives with the gateway session API.

Screens:
- Welcome: choose invite link or recover-with-seed on an empty device.
- Join: generate seed, show it once behind an acknowledge gate, confirm
  3 random words, set a local password, register the PUBLIC key with the
  bus via the invite token, then open the session.
- Recover: paste the 12 words, validate, show the reconstructed sign_pub,
  set a new local password, open the session. No register (the identity
  is already in the allowlist).
- WalletLogin: unlock the device's stored identity with the password.
- AuthShell: shared card/header for all pre-chat screens.
- App.tsx: route between join / welcome / login / recover / chat based on
  the invite link, a live gateway session, and any stored identity.

api.ts/types.ts: add register() and session() against the gateway
contract; vite dev server on :5183.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:50 +02:00
egutierrez 7d93d550d1 feat(webgw): per-user wallet sessions + invite register
Add the gateway backend for the wallet onboarding flow so each browser
session carries its OWN bus identity instead of sharing the single
operator client.

- POST /api/session (session.go): the browser hands its full wallet
  keypair (unlocked from the local encrypted key, over TLS) and the
  gateway spins up a dedicated bus client that acts AS that user. The
  private key lives only in process memory for the life of the session
  and is dropped on logout/shutdown. identityFromHex enforces the exact
  key sizes (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32) that
  match cs.Identity on the Go side.
- POST /api/register (register.go): unauthenticated onboarding gated by
  a one-shot invite token. Validates the two PUBLIC key halves, then
  either consumes a configured --mock-tokens invite (local testing) or
  proxies to the bus POST /register (--register-url, bus >= 0.12.0). The
  handle/role come from the invite, never from the client.
- server.go: sessions move from a token->time map to a sessionStore of
  per-user *session records; auth() now resolves the session and passes
  its gateway to each handler. The legacy operator passphrase login
  (POST /api/login) is kept, bound to the shared operator gateway.
- main.go: build a busTemplate config that wallet sessions clone with
  their own Identity; wire --register-url / --mock-tokens.
- webgw_test.go: identity-size validation, hex-key validation, mock
  token parsing, and single-use register (201 then 409) using a fixed
  browser-derived wallet vector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:33 +02:00
agent 5ea8fa1c20 feat(web): wire the SPA to the live bus via the gateway (drop mock)
Replace the mock data source with a real data layer that talks to the webgw
gateway over REST + SSE. The UI components keep their look and props; only
where the data comes from changed.

- src/api.ts: the single repository layer. fetch wrappers (same-origin cookie)
  for login/logout/me and rooms list/create/join/send, plus streamRoom() which
  opens an EventSource and yields each decrypted message. Wire->UI mappers
  (roomFromWire, messageFromWire).
- src/types.ts: add the gateway wire shapes (MeInfo, RoomWire, MsgWire) next to
  the existing UI types.
- App.tsx: probe /api/me on mount to resume an existing session; otherwise show
  Login. Logout calls the gateway.
- Login.tsx: the password field now unlocks the gateway session (operator
  passphrase); shows a basic error and a loading state. Wallet-per-browser is
  phase 2.
- ChatShell.tsx: load rooms from /api/rooms with loading / empty / error states;
  same Flex layout.
- ChatPanel.tsx: stream messages over SSE for the active room (dedup by id),
  composer sends through the gateway; no optimistic insert (the peer's own echo
  returns over SSE with the real frame id).
- vite.config.ts: dev proxy /api (REST + SSE) -> the gateway on :8481.

mock.ts is left untouched (no longer imported) to avoid churn with the parallel
styling work on master.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:14:19 +02:00
agent fb8a03cf0c feat(webgw): web gateway peer (REST + SSE) for the chat SPA
Add cmd/webgw: a single Go binary that holds the operator's bus identity,
connects to the bus as a real authenticated peer (pkg/client), and exposes a
small REST + SSE API the browser consumes. The browser never signs, never
speaks NATS, and never sees a private key.

Endpoints (all under /api, gated by a session cookie except login):
  POST /api/login            unlock a session with the operator passphrase
  POST /api/logout
  GET  /api/me               operator identity the gateway acts as
  GET  /api/rooms            ListMyRooms
  POST /api/rooms            CreateRoom (default policy: encrypted+persisted+signed)
  POST /api/rooms/{id}/join  Join (fetch room key)
  POST /api/rooms/{id}/send  Publish (sealed + signed by the peer)
  GET  /api/rooms/{id}/stream  SSE of decrypted frames (history then live)

Design notes:
- One fan-out hub per room: a single bus subscription is multiplexed to N SSE
  clients, avoiding the per-(room,endpoint) durable-consumer contention that
  multiple Subscribe calls would cause.
- Posture seam mirrors unibus_admin/clientcheck: empty --ca = plaintext dev,
  non-empty = TLS+nkey on both planes; RefreshSession after a membership change
  only under the secured (ACL) posture.
- Identity loaded from `pass` or a 0600 file, held only in memory.
- Session auth: passphrase compared in constant time; opaque HttpOnly cookie
  so EventSource (which cannot set headers) can authenticate the stream.

TRUST MODEL: room content stays end-to-end encrypted on the bus. The gateway
reads plaintext only because it acts AS the operator's client — a legitimate
member of each room holding the room key. The per-browser wallet (WebCrypto)
that moves decryption into the browser is phase 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:14:08 +02:00
egutierrez b4f3118e85 Merge quick/users-http-admin: HTTP admin-only users API + client methods (report 0014) 2026-06-07 20:46:44 +02:00
egutierrez e9053169da Merge quick/0011-deploy-gaps: live user-add --store kv + clientcheck E2E + runbook fixes (report 0012) 2026-06-07 20:46:44 +02:00
Egutierrez b983e43090 docs(0007): spec encryption-at-rest del control plane (JetStream/SQLite en disco) 2026-06-07 20:34:35 +02:00
egutierrez b379730225 docs(app): document users HTTP admin model, bump 0.10.0
Add a gotcha describing the unified-storage model (the server writes
users to the same store/KV as rooms), the admin-only HTTP surface, and
the CLI-seeds-admin-#0 bootstrap. Bump the version 0.9.0 -> 0.10.0 and
add the capability growth log entry for the new HTTP admin users API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:32:05 +02:00
egutierrez 450ca01baf feat(membership,client): HTTP admin-only users API
Close the last control-plane asymmetry: rooms had a signed HTTP surface
but users were only manageable via the local CLI or direct store access.
Add admin-only HTTP endpoints, symmetric with rooms, executed against the
same privileged store the server already serves (SQLite single-node, the
replicated JetStream KV in cluster) — no new KV connection, no internal
identity, so the admin panel can manage the allowlist by signing as an
admin instead of needing --db / direct KV access.

Endpoints (all behind requireAdmin, on top of the existing
signature+nonce+TLS+enforce middleware):
  - GET  /users                    list the full allowlist (incl. revoked)
  - POST /users                    add {sign_pub, handle, role}
  - POST /users/{signpub}/revoke   revoke (status flip, no hard delete)

requireAdmin is default-deny with no dev relaxation: it allows a request
only when the authenticated signer is confirmed by the store as an active
admin; any other case (no signer, non-admin, revoked, store error) is 403,
fail-closed. The request context now also carries the signer's sign_pub
hex, because the endpoint id is a one-way hash of the key and cannot be
reversed to look the signer up in the allowlist.

Validation/idempotency mirror the CLL: sign_pub must be 64-hex, role must
be admin|member (empty defaults to member), re-adding an existing key is a
409 that leaves the row untouched. The hex check is unified into
membership.ValidateSignPubHex, reused by the CLI and the handlers.

pkg/client gains ListUsers/AddUser/RevokeUser (flat UserInfo type) signed
via doJSON, so the panel plugs in directly.

Tests: non-admin -> 403 on all three endpoints; admin add->list->revoke
roundtrip; validation (400 hex, 400 role, 409 re-add, row untouched); plus
a client test against an embedded membershipd under enforce.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:31:57 +02:00
egutierrez e1a7402ff1 chore: bump unibus to 0.9.0 (live user-add + clientcheck)
New capability membershipd user add --store kv against a live cluster plus
cmd/clientcheck end-to-end verification (issue 0011 gaps, report 0012). Adds
the capability growth log entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez ce72131ddf docs(cluster): correct runbook + wire --internal-id-file into deploy
Corrections learned from the real 0011 deploy:
- Bring up: the "start magnus alone and verify healthz" order deadlocks — a
  lone node of a 3-node cluster has no meta-group quorum and never serves
  healthz until a second node joins. Document a quorum-forming start and that
  a node never self-serves.
- Replication: R1 is an unusable SPOF (all six control-plane buckets on one
  node) and the cold start only converges with the three cold-start fixes;
  go straight to R3 once the cluster forms.
- Add a "user add --store kv" section: the live user-add path that replaces
  stop-seed-restart, with its security model and idempotency/HA/no-delete
  semantics.
- Topology: real IPs, ROUTE_NETWORK=public (no WireGuard mesh exists).
- Chaos test: mark the data-plane client + failover proofs as validated (0012).

Deploy machinery now emits the persisted internal identity: the unit gains
--internal-id-file ${INTERNAL_ID_FILE} and deploy-cluster.sh writes
INTERNAL_ID_FILE into each node's cluster.env, so a fresh deploy enables the
live user-add path on every node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez 3aa5a2c9a9 feat(clientcheck): end-to-end client verification (E2E room + failover)
The 0011 chaos test validated only the control plane (healthz + leader
failover + KV readable with 2/3); it never connected an authenticated bus
client to the data plane. cmd/clientcheck is a reusable verification tool: it
connects with a real identity (nkey + TLS on both planes, multi-node seed
lists), creates an ephemeral E2E room (encrypted + signed, no durable stream),
and either publishes N messages and asserts all come back decrypted (golden)
or publishes a counter for a duration while logging the attached node (loop),
so stopping a node mid-run shows the client fail over to a survivor and keep
receiving with quorum 2/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:56 +02:00
egutierrez 02c2004ebd feat(membershipd): user add/list/revoke --store kv against a live cluster
Closes the most valuable 0011 deploy gap: adding users to the running
cluster's replicated allowlist with no stop-seed-restart. Under enforce the
per-subject ACL confines every bus user to its own rooms, so no ordinary
identity may write the control-plane KV buckets; the only identity the
authenticator grants full JetStream permissions is membershipd's internal
service identity.

- main.go: --internal-id-file persists that identity (load-or-create, 0600)
  instead of a fresh ephemeral key, so the same nkey is available out of
  process. Empty keeps the ephemeral default (single-node/dev unchanged).
- users_kv.go: connectKVStore loads the persisted identity, presents its
  nkey (recognized as internal -> full perms), opens the KV store and
  writes. Defaults assume an on-node loopback invocation; a remote target
  without --ca is refused (allowlist must not travel cleartext, audit N6).
  Prints KV_UNIBUS_users replication (followers_current) after a write.
- users_cli.go: --store kv on add/list/revoke. Re-adding a key is an explicit
  ErrUserExists (no silent overwrite / role flip); revoke is a status flip.
- pkg/client: LoadIdentity (load-only) extracted from LoadOrCreateIdentity,
  preserving its "corrupt file is an error, not silently regenerated" guard.
- kv_useradd_test.go: golden write under enforce, idempotency, unreachable
  endpoint, and remote-without-CA refusal against an embedded node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:41:38 +02:00
egutierrez ff580ac031 Merge quick/cluster-coldstart-fixes: 3-node cluster cold-start fixes + real topology 2026-06-07 18:56:28 +02:00
egutierrez 9fbff79df4 chore(deploy): fill cluster nodes.env with the real 3-node topology
Set magnus's public IP (135.125.201.30) and switch ROUTE_NETWORK to "public":
the three nodes have no WireGuard mesh (homer/datardos do not even have wg
installed), so server-to-server routes go over the public IPs, still protected
by the separate cluster route CA (mutual TLS). KV_REPLICAS is raised to 3 now
that the cluster runs at R3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:28 +02:00
egutierrez 33746d9962 fix(cluster): make the JetStream control-plane survive a cold multi-node start
Bringing up the 3-node cluster from clean stores never converged: every node
looped on `open KV bucket "UNIBUS_rooms" (replicas=1): context deadline exceeded`.
Three independent defects in the clustered bootstrap path, none of which surface
on a single node (where JetStream is ready instantly), caused it:

1. embeddednats: route connection pooling (nats-server 2.10 default pool of 3)
   churned with "duplicate route"/"client closed" reconnects on the small cluster,
   interrupting the meta-group RAFT heartbeats and forcing perpetual leader
   re-elections. Set Cluster.PoolSize = -1 (single route per peer).

2. embeddednats: the cluster nodes are Docker hosts, so NATS advertised the docker
   bridge IPs (172.x / 10.0.x) to peers, which then tried to dial those private,
   mutually-unreachable addresses. Set Cluster.NoAdvertise = true so only the
   explicit public-IP routes are used. Also added a UNIBUS_NATS_DEBUG env toggle
   (off by default) that enables the embedded server's logger and loopback
   monitoring port for debugging the route/meta layer.

3. membership.OpenJetStream: a KV op is a NATS request/reply; on a cold cluster the
   op was published once, before the node had contact with the meta leader, so the
   request was dropped and the single long-context call just blocked until timeout.
   Retry each bucket op with short per-attempt contexts until it succeeds or an
   overall bootstrap budget (120s) is exhausted, so it lands once the meta settles.

With these the cluster forms cleanly, creates the KV buckets, scales R1->R3 in
place, and survives loss of one node (quorum 2/3). Verified on magnus+homer+datardos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:28 +02:00
agent caf005f04b feat(web): frontend v1 — login (handle+contraseña), sidebar rooms+buscador, chat estilo Element
SPA React 19 + Vite + Mantine v9 en modo oscuro (acento índigo), datos mock para
iterar el diseño antes de cablear el gateway. Login con identidad + contraseña
(la contraseña desbloqueará la identidad Ed25519 cifrada en el dispositivo).
Sidebar: avatar de usuario, buscador (rooms/usuarios/mensajes) y lista de rooms
con candado E2E / hash cleartext / badges de no leídos. Panel de chat estilo
Element (avatar+nombre+hora+texto) con composer interactivo.
2026-06-07 17:57:50 +02:00
agent 9787c218ac chore: remove experimental frontends (web, android, playground, mobile)
Limpieza de los frontends de prueba (SPA React, app Kotlin, gateway playground,
binding gomobile) tras la fase de exploración. El bus (cmd/membershipd + pkg/*)
queda intacto y verde. Empezamos un frontend web nuevo desde cero, construido
de forma incremental. Todo lo borrado permanece en el historial git por si hay
que recuperar algo.
2026-06-07 17:38:07 +02:00
egutierrez 926b8e96af chore(0006): bump unibus to 0.8.0, close issue 0006 (cluster hardening + wiring)
All seven phases (0006a–0006g) merged: blockers N3 (replicated nonce) and N2
($JS.API.> KV leak) closed, decentralized KV store wired (--store kv), homogeneous
cluster posture enforced (N1), RefreshSession in all clients (N4), the lows
(secret out of argv, migrate guard, R1/CA docs), and the 3-node deploy material.

Full suite + every audit-0008 attack regression green; govulncheck 0 reachable.
See report 0009.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:33:03 +02:00
egutierrez ae39e35fb4 Merge issue/0006g-deploy: cluster deploy material (magnus+homer+datardos, R3 HA) 2026-06-07 17:31:13 +02:00
egutierrez 48a3d6be33 docs(0006g): cluster deploy material for magnus+homer+datardos (R3 HA)
Parameterized, NO-VPS-touched material to bring up unibus as a 3-node cluster.
The authoring agent ran none of it on a host; every remote-changing step is
marked HUMAN and deploy-cluster.sh defaults to a dry run.

deploy/cluster/:
- nodes.env — topology (cluster name, ports, per-node rows). Public IPs known
  (homer 141.94.69.66, datardos 51.91.100.142) pre-filled; magnus public IP and
  all WireGuard IPs are <PLACEHOLDER> for the human; scripts refuse to run while
  any remain.
- generate-cluster-certs.sh — mints a SEPARATE cluster route CA + a route cert per
  node (server+clientAuth, mutual routes) and a data-plane server cert per node
  signed by the reused client CA (../tls/ca.*); SAN = public + WG + hostname.
- membershipd-cluster.service — one unit, parameterized per node via
  /opt/unibus/cluster.env: enforce + per-subject ACL + TLS + --store kv,
  --cluster-pass-file (secret out of argv), Restart=always.
- deploy-cluster.sh — cross-build linux/amd64, generate each node's cluster.env
  (routes to the other two on the WG mesh, no userinfo), rsync + install (only
  with --yes); staggered start is manual.
- README.md — runbook: prerequisites, loopback bootstrap to seed the first admin
  into the KV (works around the user-CLI/KV chicken-and-egg), staggered bring-up,
  verify posture+quorum, scale R1->R3 in place, and the chaos test (left to 0003f
  on the real VPS).
- .gitignore — out/, build/, secrets/, *.key never committed.

bash -n passes on both scripts; go build/test unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:31:13 +02:00
egutierrez 24ff45ca7e Merge issue/0006f-lows: cluster secret out of argv + migrate guard + docs (audit 0008 lows) 2026-06-07 17:24:46 +02:00
egutierrez b8201a82cd fix(0006f): cluster secret out of argv, migrate-to-kv TLS guard, R1/CA docs (audit 0008 lows)
Low-severity cluster hardening from audit 0008:

- Route secret out of argv (N1-low): --cluster-pass and a nats://user:pass@host in
  --routes are visible in ps/journald. New --cluster-pass-file and the
  UNIBUS_CLUSTER_PASS env var (precedence file > env > flag); the resolved secret
  guards the route layer and is injected into bare --routes entries
  (injectRouteCreds), so peers can be listed as nats://host:6250 with no secret in
  argv. The legacy --cluster-pass stays for dev/compat.
- migrate-to-kv confidentiality (N6): refuse a remote --nats-url without --ca (the
  allowlist would travel cleartext); loopback targets are exempt (isLoopbackURL).
- Docs (N1 route CA, N3 DoS): deploy/README gains a Clustering section — use a
  SEPARATE cluster CA for routes (not the client CA), keep the secret out of argv,
  run migrate-to-kv loopback/TLS only, and R1 is a SPOF of auth (not HA); R3
  quorum is real HA. The generated cert material lives in deploy/cluster/ (0006g).

Tests:
- TestResolveClusterPass (file > env > flag precedence; missing file errors),
- TestInjectRouteCreds (injects only into userinfo-less routes; preserves overrides),
- TestIsLoopbackURL (loopback vs remote vs malformed).

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:24:46 +02:00
egutierrez 3a33656cac Merge issue/0006e-refresh: RefreshSession in all clients (audit 0008 N4) 2026-06-07 17:21:14 +02:00
egutierrez 2f5b372a80 fix(0006e): call RefreshSession after membership changes in all clients (audit 0008 N4)
A secured bus freezes per-subject permissions at connect time, so a peer that
creates or joins a room after connecting cannot pub/sub on it until it reconnects
(RefreshSession). No client called it, so under enforce+ACL the demos failed
closed — pushing the operator to disable the ACL (a security regression at the
operator's discretion).

Wire the membership-change contract into every client:
- cmd/worker: RefreshSession after CreateRoom, before publishing.
- cmd/chat (simple): RefreshSession after CreateRoom+Join, before Subscribe.
- cmd/chat (encrypted demo): A refreshes after CreateRoom; B refreshes after the
  invite+join, both before pub/sub.
- local_files/bridge (gateway): RefreshSession after CreateRoom+Join, before Subscribe.
- mobile: new Session.RefreshSession wrapper + the contract documented for callers.

Contract (documented on the wrappers): after ANY membership change, call
RefreshSession BEFORE pub/sub on the new room (it drops active subs, so it must
precede Subscribe). On an unsecured/dev bus it is a harmless reconnect.

Test:
- TestClientCreateRoomRefreshPublishFlow: end-to-end under enforce+ACL, a peer
  creates a room, refreshes, invites a second peer who joins+refreshes+subscribes,
  and the publish is received — no manual intervention, the ACL stays on.

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:21:14 +02:00
egutierrez 32bec75665 Merge issue/0006d-posture: homogeneous cluster posture + /healthz posture (audit 0008 N1) 2026-06-07 17:17:37 +02:00
egutierrez 9b96537aa6 fix(0006d): enforce homogeneous cluster posture + publish posture on /healthz (audit 0008 N1)
A cluster is only as secure as its weakest node: the data plane forwards every
subject between nodes, so one node running without enforced auth lets an
unauthenticated peer Subscribe(">") on it and harvest the traffic forwarded from
the ACL'd nodes.

- validateClusterConfig now takes the auth mode and REFUSES to join a cluster
  unless --bus-auth enforce, regardless of bind (a clustered node is a production
  node; there is no safe dev cluster without auth). This binary therefore cannot
  BE the weak node.
- Server.Posture {enforce,acl,tls,cluster,store} is published on /healthz (non
  secret operational metadata, probe stays unauthenticated) so a monitor or peer
  can detect a cluster member not running enforce+ACL+TLS — covering a peer that
  runs a tampered/old binary outside this node's control.

Tests:
- TestAttack0008_N1: a clustered node with --bus-auth off is refused; the same
  node with enforce + full route security is allowed.
- TestClusterConfigPolicy: extended with off/soft clustered cases (refused) and
  the mode parameter throughout.
- TestHealthExposesPosture: /healthz returns the posture booleans + store backend.

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:17:37 +02:00
egutierrez 18ee7c469b Merge issue/0006c-kv-store: wire decentralized control-plane KV store (--store kv) 2026-06-07 17:14:20 +02:00
egutierrez e9ad719424 feat(0006c): wire the decentralized control-plane KV store (--store kv)
0003 built the JetStream KV store (jetstreamStore) but the binary never selected
it: membership.Open (SQLite) was hardcoded and OpenJetStream was only reached by
migrate-to-kv. This completes the wiring so a node actually serves its control
plane from the replicated KV.

- New flag --store kv|sqlite (default sqlite). kv opens the JetStream KV control
  plane over the privileged internal connection; sqlite is the unchanged baseline
  (branch-by-abstraction: the full suite's SQLite paths are untouched).
- Bootstrap cycle resolved with storeHolder: the authenticator consults the holder
  (fail-closed until set), so it can be built before the KV store exists. The KV
  store opens after NATS is up and is published into the holder. The only client
  that can connect in that window is the internal identity, which bypasses the
  store by key. In SQLite mode the store is set before StartServer, so the window
  does not exist.
- needJS now covers --store kv as well as --cluster-name; the JetStream client is
  shared by the KV store and the replicated nonce bucket.
- feature_flags.json: decentralized wiring documented as complete, realized via
  --store kv (opt-in per deploy; default stays sqlite).

Fail-closed preserved: jetstreamStore.IsAuthorized already denies on any backend
error; the holder denies while unset.

Tests:
- TestStoreHolderFailClosed: empty holder denies; serves after set.
- TestKVStoreBootstrapUnderEnforce: end-to-end decentralized boot — KV-seeded user
  authenticates over nkey under enforce; outsider denied.
- TestKVStoreDecentralizedConsistency: a room/user created on one node's KV store
  is visible to another's (ends the per-node SQLite divergence, audit 0008 N5).

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:14:20 +02:00
egutierrez d1e1a478f8 Merge issue/0006b-kv-acl: scope JetStream ACL per-room (audit 0008 N2) 2026-06-07 17:08:54 +02:00
egutierrez cacf608fde fix(0006b): scope JetStream ACL per-room, close $JS.API.> KV leak (audit 0008 N2)
The client-infra grant was {"_INBOX.>", "$JS.API.>"}. The broad "$JS.API.>" let
any registered peer drive the whole JetStream API and read the control-plane KV
buckets (KV_UNIBUS_users/rooms/members/room_keys) and the object store directly
over NATS, bypassing the HTTP authorization (requireMember + own-endpoint
checks): a full leak of the allowlist, room graph and sealed-key metadata once the
decentralized control plane is active.

Fix: replace the broad grant with a CLOSED, per-room allow set.
- clientInfraSubjects shrinks to {"_INBOX.>", "$JS.API.INFO"} ($JS.API.INFO is
  account counters only — no room/user/key contents).
- SubjectACLFor now grants, per room the peer belongs to, the room subject plus
  the minimal JetStream API subjects of THAT room's stream (jsSubjectsFor:
  STREAM.*, CONSUMER.*, $JS.ACK scoped to UNIBUS_<roomID>).
- Because KV_UNIBUS_* and OBJ_UNIBUS_* are never a room stream, they fall outside
  the closed allow set and are denied by default. Clients reach blobs over the
  HTTP control plane, not the NATS object store, so OBJ needs no client grant.

roomStreamName mirrors pkg/client.streamName so the authorizer and the producer
never drift.

Tests:
- TestAttack0008_N2: eve (registered, member of no room) cannot bind the KV users
  bucket nor subscribe $KV.UNIBUS_users.> (permissions violation); golden: the
  room owner can still drive her OWN room stream's JetStream API; edge: eve cannot
  reach a foreign room's stream.
- TestReaudit_H4 residual note updated: the $JS.API.> leak it deferred is closed.

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:08:54 +02:00
egutierrez a9c245d468 Merge issue/0006a-replicated-nonce: wire replicated nonce store (audit 0008 N3) 2026-06-07 17:02:19 +02:00
egutierrez 8b6a01d280 fix(0006a): wire replicated nonce store on clustered nodes (audit 0008 N3)
membershipd never called Server.UseReplicatedNonces, so every node kept a
per-process anti-replay cache and a signed request accepted on node A could be
replayed to node B (200+200). This wires the shared JetStream KV nonce bucket on
any clustered node, closing the cross-node replay hole.

Bootstrap: under enforce the service needs JetStream on its own embedded server,
but the data plane only accepts allowlisted clients. Resolved with an ephemeral
internal service identity the authenticator recognizes and grants full
permissions (NewNkeyAuthenticatorACLInternal), connected over the in-process
transport (no TLS/CA needed for the self-connection).

Hard rule: --cluster-name != "" means the replicated nonce bucket is mandatory;
if it cannot be created the node refuses to start (wireReplicatedNonces returns a
fatal error) rather than run insecurely. Standalone nodes keep the in-memory
cache unchanged (branch-by-abstraction: no JetStream dependency added).

Changes:
- busauth: NewNkeyAuthenticatorACLInternal + fullPermissions for the internal id.
- cmd/membershipd: connectInternalJS (in-process, privileged) / connectExternalJS;
  wireReplicatedNonces helper; main wires it when clustered; --kv-replicas flag.

Tests (regression of audit 0008 N3):
- TestAttack0008_N3: 2 clustered nodes share the bucket, cross-node replay -> 401.
- TestAttack0008_N3_StandaloneKeepsLocalCache: standalone needs no JetStream,
  same-node replay still 401.
- TestAttack0008_N3_ClusteredRequiresJetStream: clustered + no JetStream -> fatal.
- TestInternalConnPrivilegedUnderEnforce / ...OutsiderRejected: the privileged
  self-connection works under enforce and no other identity can claim it.

CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:02:19 +02:00
103 changed files with 7312 additions and 4019 deletions
+4
View File
@@ -12,5 +12,9 @@ worker.id
/membershipd /membershipd
/worker /worker
/chat /chat
/webgw
*.exe *.exe
registry.db registry.db
# Local session infra (machine-specific absolute paths; never distributed).
.mcp.json
-12
View File
@@ -1,12 +0,0 @@
.gradle/
build/
local.properties
*.iml
.idea/
captures/
.cxx/
# The gomobile binding is a build artifact (~24 MB). Regenerate it from ../mobile
# with `gomobile bind` (see README.md); it is not versioned.
app/libs/*.aar
app/libs/*.jar
-83
View File
@@ -1,83 +0,0 @@
# unibus · app Android
Cliente móvil nativo de unibus. La app no habla con un gateway: embebe un **peer
real** del bus a través del binding gomobile `mobile/unibus.go`, de modo que el
cifrado extremo a extremo corre **en el dispositivo**. Cada teléfono es un peer
de primera clase del bus, igual que cualquier peer Go.
## Arquitectura
```
Kotlin/Compose UI ──> BusViewModel ──> com.unibus.core.mobile.Session (.aar)
│ (NATS data plane + E2E crypto, en Go)
membershipd (control plane HTTP :8470)
NATS (data plane :4250)
```
- `BusViewModel` traduce intents de UI en llamadas al binding. Las llamadas de red
(`newSession`, `createRoom`, `join`, `publish`) corren en `Dispatchers.IO`.
- Los frames entrantes llegan por `FrameListener.onFrame` en una goroutine NATS
(hilo JNI); se publican en un `StateFlow` (thread-safe) que Compose recolecta en
el hilo principal.
## Requisitos
- Android SDK (compileSdk 34), NDK (para regenerar el `.aar`), JDK 17.
- El binding `app/libs/unibus.aar` (no versionado: es un artefacto de ~24 MB).
## 1. Generar el binding (.aar)
Desde la raíz del repo de la app (`projects/message_bus/apps/unibus`):
```bash
export ANDROID_HOME=$HOME/android-sdk
export ANDROID_NDK_HOME=$HOME/android-sdk/ndk/26.3.11579264
mkdir -p android/app/libs
gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \
-o android/app/libs/unibus.aar ./mobile
```
Esto produce `unibus.aar` con la clase estática `com.unibus.core.mobile.Mobile`
(`generateIdentity`, `newSession`) y los tipos `Session` y `FrameListener`.
## 2. Compilar el APK
```bash
cd android
export JAVA_HOME=$HOME/android-sdk/jdk-17/jdk-17.0.19+10
export ANDROID_HOME=$HOME/android-sdk
./gradlew assembleDebug
# APK: app/build/outputs/apk/debug/app-debug.apk
```
`local.properties` apunta a `sdk.dir`; ajústalo si tu SDK está en otra ruta.
## 3. Arrancar el bus y probar en el emulador
```bash
# 1. En el PC: control plane + NATS embebido (HTTP :8470, NATS :4250)
cd projects/message_bus/apps/unibus && go run ./cmd/membershipd
# 2. Emulador Pixel_API34
$ANDROID_HOME/emulator/emulator -avd Pixel_API34 &
# 3. Instalar + lanzar
adb install -r app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n com.unibus.app/.MainActivity
```
En la pantalla de conexión, desde el emulador el host del PC es `10.0.2.2`:
- **Host (control plane):** `http://10.0.2.2:8470`
- **NATS (data plane):** `nats://10.0.2.2:4250`
Para un teléfono físico en la misma LAN, usa la IP LAN del PC en lugar de
`10.0.2.2`.
## Notas
- La identidad del peer se guarda en `filesDir/peer.id` (claves privadas
Ed25519 + X25519). No se sincroniza ni se respalda.
- Una room creada en modo "cifrar (E2E)" usa la política Matrix (cifrada,
persistida, firmada); en modo normal usa NATS cleartext.
-66
View File
@@ -1,66 +0,0 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}
android {
namespace = "com.unibus.app"
compileSdk = 34
defaultConfig {
applicationId = "com.unibus.app"
minSdk = 21
targetSdk = 34
versionCode = 1
versionName = "0.1.0"
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
// The unibus gomobile binding: a real bus peer that does NATS + E2E crypto
// on the device. All protocol logic lives here, shared with every other peer.
implementation(files("libs/unibus.aar"))
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.activity:activity-compose:1.9.2")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
debugImplementation("androidx.compose.ui:ui-tooling")
}
-4
View File
@@ -1,4 +0,0 @@
# gomobile generates JNI-bound classes under com.unibus.core.mobile and go.*.
# They are reached from native code, so keep them intact even when minifying.
-keep class com.unibus.core.mobile.** { *; }
-keep class go.** { *; }
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Unibus">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,162 +0,0 @@
package com.unibus.app
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.unibus.core.mobile.FrameListener
import com.unibus.core.mobile.Mobile
import com.unibus.core.mobile.Session
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
/** One chat message shown in the UI. */
data class ChatMessage(
val sender: String,
val text: String,
val mine: Boolean,
val ts: Long,
)
/** The whole observable UI state of the app. */
data class BusState(
val connecting: Boolean = false,
val connected: Boolean = false,
val endpointId: String = "",
val roomId: String = "",
val roomSubject: String = "",
val status: String = "",
val error: String? = null,
val messages: List<ChatMessage> = emptyList(),
)
/**
* BusViewModel drives a real unibus peer on the device through the gomobile
* binding. The binding performs NATS transport and end-to-end crypto natively;
* this class only translates UI intents into binding calls and exposes the
* incoming frames as observable state.
*
* Threading: every binding call that touches the network (newSession, createRoom,
* join, publish) runs off the main thread on Dispatchers.IO to avoid
* NetworkOnMainThreadException. Incoming frames arrive on a JNI-attached NATS
* goroutine via [onFrame]; we only append to a thread-safe StateFlow there, and
* Compose collects that flow on the main thread.
*/
class BusViewModel(app: Application) : AndroidViewModel(app), FrameListener {
private val _state = MutableStateFlow(BusState())
val state: StateFlow<BusState> = _state.asStateFlow()
private var session: Session? = null
private var myEndpoint: String = ""
private val idPath: String
get() = File(getApplication<Application>().filesDir, "peer.id").absolutePath
override fun onFrame(roomID: String, sender: String, msgID: String, text: String) {
_state.update {
it.copy(
messages = it.messages + ChatMessage(
sender = sender,
text = text,
mine = sender == myEndpoint,
ts = System.currentTimeMillis(),
),
)
}
}
fun connect(host: String, nats: String, peerName: String) {
if (_state.value.connecting) return
_state.update { it.copy(connecting = true, error = null, status = "Conectando…") }
viewModelScope.launch(Dispatchers.IO) {
try {
val s = Mobile.newSession(idPath, nats.trim(), host.trim())
session = s
myEndpoint = s.endpointID()
_state.update {
it.copy(
connecting = false,
connected = true,
endpointId = myEndpoint,
status = "Conectado como $peerName",
)
}
} catch (e: Exception) {
_state.update {
it.copy(connecting = false, connected = false, error = e.message ?: "error desconocido")
}
}
}
}
fun createRoom(subject: String, encrypted: Boolean) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val mode = if (encrypted) "matrix" else "nats"
val roomId = s.createRoom(subject.trim(), mode)
s.subscribe(roomId, this@BusViewModel)
_state.update {
it.copy(
roomId = roomId,
roomSubject = subject.trim(),
messages = emptyList(),
status = "Room creada",
)
}
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "error al crear room") }
}
}
}
fun joinRoom(roomId: String) {
val s = session ?: return
viewModelScope.launch(Dispatchers.IO) {
try {
val rid = roomId.trim()
s.join(rid)
s.subscribe(rid, this@BusViewModel)
_state.update {
it.copy(roomId = rid, roomSubject = "(unida)", messages = emptyList(), status = "Unido a la room")
}
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "error al unirse") }
}
}
}
fun publish(text: String) {
val s = session ?: return
val room = _state.value.roomId
if (room.isEmpty() || text.isBlank()) return
viewModelScope.launch(Dispatchers.IO) {
try {
s.publish(room, text)
} catch (e: Exception) {
_state.update { it.copy(error = e.message ?: "error al publicar") }
}
}
}
/** card returns this peer's shareable public identity (no secret). */
fun card(): String = try {
session?.card() ?: ""
} catch (_: Exception) {
""
}
fun clearError() = _state.update { it.copy(error = null) }
override fun onCleared() {
try {
session?.close()
} catch (_: Exception) {
}
session = null
}
}
@@ -1,307 +0,0 @@
package com.unibus.app
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class MainActivity : ComponentActivity() {
private val vm: BusViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) {
UnibusApp(vm)
}
}
}
}
}
@Composable
fun UnibusApp(vm: BusViewModel) {
val state by vm.state.collectAsState()
if (!state.connected) {
ConnectScreen(
connecting = state.connecting,
error = state.error,
onConnect = { host, nats, name -> vm.connect(host, nats, name) },
)
} else {
ChatScreen(state = state, vm = vm)
}
}
@Composable
fun ConnectScreen(
connecting: Boolean,
error: String?,
onConnect: (String, String, String) -> Unit,
) {
var host by rememberSaveable { mutableStateOf("http://10.0.2.2:8470") }
var nats by rememberSaveable { mutableStateOf("nats://10.0.2.2:4250") }
var name by rememberSaveable { mutableStateOf("android") }
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
verticalArrangement = Arrangement.Center,
) {
Text("unibus", style = MaterialTheme.typography.headlineMedium)
Text(
"chat cifrado extremo a extremo sobre NATS",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host (control plane)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = nats,
onValueChange = { nats = it },
label = { Text("NATS (data plane)") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Identidad") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (error != null) {
Spacer(Modifier.height(12.dp))
Text(error, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = { onConnect(host, nats, name) },
enabled = !connecting,
modifier = Modifier.fillMaxWidth(),
) {
if (connecting) {
CircularProgressIndicator(modifier = Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(if (connecting) "Conectando…" else "Conectar")
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChatScreen(state: BusState, vm: BusViewModel) {
var subject by rememberSaveable { mutableStateOf("room.general") }
var encrypt by rememberSaveable { mutableStateOf(false) }
var joinId by rememberSaveable { mutableStateOf("") }
var draft by rememberSaveable { mutableStateOf("") }
val listState = rememberLazyListState()
LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text("unibus", style = MaterialTheme.typography.titleMedium)
Text(
state.status.ifEmpty { state.endpointId.take(12) + "" },
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
)
},
) { inner ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(inner)
.padding(horizontal = 12.dp),
) {
// Room controls.
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
Column(Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = subject,
onValueChange = { subject = it },
label = { Text("subject") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Button(onClick = { vm.createRoom(subject, encrypt) }) {
Icon(Icons.Filled.Add, contentDescription = "crear")
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = encrypt, onCheckedChange = { encrypt = it })
Spacer(Modifier.width(8.dp))
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.height(16.dp))
Text("cifrar (E2E)", style = MaterialTheme.typography.bodySmall)
}
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = joinId,
onValueChange = { joinId = it },
label = { Text("unirse por room id") },
singleLine = true,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
OutlinedButton(onClick = { if (joinId.isNotBlank()) vm.joinRoom(joinId) }) {
Text("Unir")
}
}
if (state.roomId.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
"room: ${state.roomSubject} · ${state.roomId}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
if (state.error != null) {
Text(
state.error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
)
}
// Messages.
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
itemsIndexed(state.messages, key = { i, m -> "${m.ts}-$i" }) { _, m ->
MessageBubble(m)
}
}
// Composer.
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
placeholder = { Text("Mensaje…") },
singleLine = true,
enabled = state.roomId.isNotEmpty(),
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
IconButton(
onClick = {
vm.publish(draft)
draft = ""
},
enabled = state.roomId.isNotEmpty() && draft.isNotBlank(),
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "enviar")
}
}
}
}
}
private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
@Composable
fun MessageBubble(m: ChatMessage) {
val align = if (m.mine) Alignment.End else Alignment.Start
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) {
Card(
modifier = Modifier.fillMaxWidth(0.8f),
) {
Column(Modifier.padding(8.dp)) {
if (!m.mine) {
Text(
m.sender.take(12) + "",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
Text(m.text, style = MaterialTheme.typography.bodyMedium)
Text(
timeFmt.format(Date(m.ts)),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">unibus</string>
</resources>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- A minimal Material3 base theme; the real UI styling is driven by Compose
Material3 (MaterialTheme) at runtime. -->
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar" />
</resources>
-8
View File
@@ -1,8 +0,0 @@
// Top-level build file. Plugin versions are declared here and applied in the
// module build scripts. AGP 8.5 + Kotlin 2.0 (with the dedicated Compose
// compiler plugin) target the locally installed SDK (compileSdk 34).
plugins {
id("com.android.application") version "8.5.2" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
}
-5
View File
@@ -1,5 +0,0 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-252
View File
@@ -1,252 +0,0 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
-94
View File
@@ -1,94 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
-23
View File
@@ -1,23 +0,0 @@
pluginManagement {
repositories {
google {
content {
includeGroupByRegex("com\\.android.*")
includeGroupByRegex("com\\.google.*")
includeGroupByRegex("androidx.*")
}
}
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "unibus"
include(":app")
+86 -1
View File
@@ -2,7 +2,7 @@
name: unibus name: unibus
lang: go lang: go
domain: infra domain: infra
version: 0.7.0 version: 0.10.0
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo." description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
tags: [service, messaging, nats, e2e] tags: [service, messaging, nats, e2e]
uses_functions: uses_functions:
@@ -122,6 +122,21 @@ Para apuntar a un NATS externo en producción: `--nats-url nats://host:4222` en
las rutas GET de lectura. Confía en la red interna. Las rutas mutantes las rutas GET de lectura. Confía en la red interna. Las rutas mutantes
(`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los (`/rooms`, `/invite`, `/rekey`) sí exigen firma Ed25519 del owner sobre los
bytes canónicos de la request. Endurecer es fase posterior. bytes canónicos de la request. Endurecer es fase posterior.
- **Gestión de usuarios: storage unificado, alta por dos vías.** El allowlist de
usuarios vive en el MISMO store que las rooms (`pkg/membership.Store`): SQLite en
single-node, JetStream KV replicado (`UNIBUS_users`) en cluster. El `Server` ya
tiene ese store privilegiado abierto (es quien sirve el KV en cada nodo), así que
expone `GET/POST /users` y `POST /users/{signpub}/revoke` como API HTTP admin-only,
simétrica con las rutas de rooms: el panel de administración firma como admin y el
server ejecuta la mutación contra el mismo store. El panel NO necesita `--db`, ni la
identidad interna, ni correr en un nodo del cluster; funciona idéntico en single-node
y cluster. La autorización es default-deny: solo un firmante que el store confirma como
`role == "admin"` activo pasa, cualquier otro recibe 403 (encima de la firma+nonce+TLS
ya existentes). La CLI `membershipd user add --store kv` sigue existiendo SOLO para
sembrar el admin #0 (bootstrap del huevo-gallina: sin un admin sembrado no hay quién
firme el primer `POST /users`); a partir de ahí toda la gestión es HTTP admin-only. El
alta es idempotente igual que la CLI: re-alta de una clave ya registrada = 409, sin
sobrescribir ni elevar rol; el revoke es un flip de status (sin hard-delete), auditable.
- **Identidad = secreto crítico.** El archivo de identidad (`worker.id`, - **Identidad = secreto crítico.** El archivo de identidad (`worker.id`,
`chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600. `chat.id`) contiene las claves privadas (Ed25519 + X25519). Se escribe 0600.
Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH. Perderlo = mensajes ilegibles, sin recuperación. Trátalo como una clave SSH.
@@ -154,6 +169,76 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
## Capability growth log ## Capability growth log
- v0.10.0 (2026-06-07) — API HTTP admin-only de gestión de usuarios, cerrando la
última asimetría del control plane: las rooms tenían superficie HTTP firmada
(`POST /rooms`, etc.) pero los users solo se gestionaban por CLI local o acceso
directo al store. Se añaden `GET /users` (lista completa, incluidos revocados),
`POST /users` (alta `{sign_pub, handle, role}`: valida hex de 64 chars + role en
`{admin, member}`, 409 idempotente que no sobrescribe ni eleva rol) y
`POST /users/{signpub}/revoke` (flip de status, sin hard-delete). Los tres pasan por
un helper `requireAdmin` default-deny que confirma contra el store que el firmante
autenticado es un user `role == "admin"` activo (el endpoint id es un hash one-way de
la clave, así que el contexto lleva ahora también el `sign_pub` hex del firmante para
resolver `GetUser`); cualquier otro firmante recibe 403, encima de la firma+nonce+TLS+
enforce ya heredadas del middleware. NO se abre conexión KV nueva ni se usa la identidad
interna: el server escribe vía su `s.store` privilegiado, el MISMO que las rooms (SQLite
single-node, KV `UNIBUS_users` en cluster). `pkg/client` gana `ListUsers/AddUser/RevokeUser`
(tipo plano `UserInfo`) firmando como admin, así la pestaña Users del panel deja de
necesitar `--db`/acceso KV directo. La CLI `membershipd user add --store kv` queda SOLO
para sembrar el admin #0 (bootstrap). La validación de `sign_pub` se unifica en
`membership.ValidateSignPubHex`, reusada por la CLI y los handlers. Tests nuevos:
no-admin → 403 en los tres endpoints, roundtrip admin add→list→revoke, y validación
(hex inválido → 400, role inválido → 400, re-alta → 409), más un test de cliente contra
un membershipd embebido. Cambios 100% aditivos: el comportamiento single-node y de las
rutas de rooms no cambia; vet/build/test verdes.
- v0.9.0 (2026-06-07) — cierre de los gaps que el despliegue del cluster (report
0011) dejó abiertos (report 0012). (GAP A) Nueva capability `membershipd user
add|list|revoke --store kv`: alta/baja de usuarios contra el KV replicado del
cluster EN MARCHA, sin el procedimiento de parar-sembrar-rearrancar. Usa la
conexión interna privilegiada — el daemon persiste su identidad de servicio con
`--internal-id-file` (cada nodo genera/carga la suya, 0600 junto a las claves TLS)
y la CLI, ejecutada por loopback en un nodo, presenta esa nkey que el
autenticador reconoce con permisos plenos de JetStream; ninguna identidad de
usuario normal puede tocar los buckets `KV_UNIBUS_*` bajo la ACL por-subject. El
alta es idempotente (re-alta de la misma clave = `ErrUserExists` explícito, sin
sobrescribir ni elevar rol), commitea con quórum 2/3 (HA, imprime
`followers_current`) y rechaza un destino remoto sin `--ca` (igual que
`migrate-to-kv`). (GAP B) Nuevo `cmd/clientcheck`: verificación end-to-end real
con un cliente autenticado (identidad operator, nkey+TLS+https) que crea una room
E2E, publica y recibe descifrado contra el cluster vivo, incluido un nodo parado a
media transmisión donde el cliente hace failover a un superviviente y sigue
recibiendo con cero pérdida (quórum 2/3) — el plano de datos que el chaos test del
0011 nunca probó. (GAP C) Runbook `deploy/cluster/README.md` corregido: el orden
de arranque "magnus solo y verifica healthz" deadlockeaba (un nodo solo no tiene
quórum del meta-group y nunca sirve healthz); se documenta el arranque por quórum,
que R1 es un SPOF inservible (ir directo a R3) y la nueva vía de alta con el
cluster vivo. La plantilla de deploy (unit + `deploy-cluster.sh`) emite ya
`INTERNAL_ID_FILE` y el flag. Verificado contra los 3 VPS reales (magnus + homer +
datardos); posture enforce+ACL+TLS+R3 intacta.
- v0.8.0 (2026-06-07) — completar y endurecer el cluster (issue 0006, fases
0006a0006g) que cierra los bloqueantes de la auditoría dedicada del cluster
(report 0008) y cablea el control plane descentralizado que 0003 dejó a medias.
(0006a) Se cablea el nonce replicado en el binario: un nodo con `--cluster-name`
usa el bucket JetStream KV compartido obligatoriamente (fail-fast si no se crea),
cerrando el replay cross-node (N3); el "ciclo bootstrap" se resuelve con una
identidad interna efímera que el authenticator reconoce (full perms) y una
conexión in-process privilegiada. (0006b) Se cierra la fuga del control plane
por `$JS.API.>` (N2): la ACL pasa a un allow-set cerrado por-room (JS API solo de
los streams `UNIBUS_<room>` del peer), dejando `KV_UNIBUS_*`/`OBJ_*` fuera del
set y, por tanto, denegados. (0006c) Se cablea el store KV descentralizado
(`--store kv|sqlite`, default sqlite = baseline idéntico) con un `storeHolder`
fail-closed que rompe el ciclo bootstrap del authenticator. (0006d) Posture
homogénea: un nodo rechaza unirse al cluster sin `enforce`, y `/healthz` publica
la posture (N1). (0006e) Todos los clientes llaman `RefreshSession` tras cambios
de membresía (N4), de modo que la ACL es usable bajo enforce sin desactivarla.
(0006f) Bajos: secreto de cluster fuera de argv (`--cluster-pass-file`/env +
inyección en routes), `migrate-to-kv` rechaza target remoto sin `--ca`, y docs
de CA separada para routes + R1 SPOF vs R3 HA. (0006g) Material de deploy del
cluster de 3 nodos (magnus+homer+datardos) en `deploy/cluster/` (certs, unit,
script de despliegue dry-run, runbook) — sin tocar ningún VPS. Toda la
regresión de auditorías previas + los ataques 0008 siguen verdes; govulncheck 0
alcanzables. Branch-by-abstraction: con `--store sqlite` el single-node sigue
idéntico y desplegable en todo momento.
- v0.7.0 (2026-06-07) — hardening de seguridad 2 (issue 0005, fases 0005a0005e) - v0.7.0 (2026-06-07) — hardening de seguridad 2 (issue 0005, fases 0005a0005e)
que cierra los hallazgos nuevos de la re-auditoría red-team (report 0006) y que cierra los hallazgos nuevos de la re-auditoría red-team (report 0006) y
lleva el veredicto de exposición pública a "sí-con-condiciones". (0005a) Bump de lleva el veredicto de exposición pública a "sí-con-condiciones". (0005a) Bump de
+15
View File
@@ -69,6 +69,12 @@ func runSimple(natsURL, ctrlURL, roomSub, idFile, caFile string) {
if err := c.Join(roomID); err != nil { if err := c.Join(roomID); err != nil {
log.Fatalf("join: %v", err) log.Fatalf("join: %v", err)
} }
// Membership-change contract (issue 0006e): refresh so the just-created room's
// subject is subscribable under enforce+ACL (permissions are frozen at connect
// time). Must run BEFORE Subscribe — RefreshSession drops active subscriptions.
if err := c.RefreshSession(); err != nil {
log.Fatalf("refresh session after create room: %v", err)
}
sub, err := c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { sub, err := c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
fmt.Printf("[%s] %s: %s\n", f.Subject, shortID(f.Sender), string(plaintext)) fmt.Printf("[%s] %s: %s\n", f.Subject, shortID(f.Sender), string(plaintext))
}) })
@@ -122,12 +128,21 @@ func runEncryptedDemo(natsURL, ctrlURL, caFile string) {
must(err, "A create room") must(err, "A create room")
fmt.Printf(" room.test -> %s (E2E, persisted, signed)\n", roomID) fmt.Printf(" room.test -> %s (E2E, persisted, signed)\n", roomID)
// Membership-change contract (issue 0006e): A only became a member of this room
// after connecting, so refresh to gain its subject + per-room JetStream API
// under enforce+ACL before publishing.
must(a.RefreshSession(), "A refresh after create room")
// A invites B (seals K to B's X25519 key). // A invites B (seals K to B's X25519 key).
must(a.Invite(roomID, b.Endpoint()), "A invite B") must(a.Invite(roomID, b.Endpoint()), "A invite B")
// B joins (fetches + decrypts K). // B joins (fetches + decrypts K).
must(b.Join(roomID), "B join") must(b.Join(roomID), "B join")
// B became a member via the invite above; refresh so B can subscribe to the
// room's subject under enforce+ACL (before subscribing — refresh drops subs).
must(b.RefreshSession(), "B refresh after join")
// B subscribes; capture received plaintexts. // B subscribes; capture received plaintexts.
recv := make(chan string, 4) recv := make(chan string, 4)
subB, err := b.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { subB, err := b.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
+260
View File
@@ -0,0 +1,260 @@
// Command clientcheck is an end-to-end verification client for a live unibus
// cluster (issue 0011 GAP B). The 0011 chaos test validated only the control
// plane (healthz + meta/stream-leader failover + KV readable with 2/3); it never
// connected an authenticated bus client (nkey + TLS) to create a room and
// publish/subscribe through it, least of all across a node loss. clientcheck does
// exactly that with a real identity (the operator), so the data-plane end-to-end
// path — connect, create an E2E room, publish, receive decrypted — is exercised
// against the running cluster, including while a node is stopped.
//
// It is a reusable tool, not a throwaway script: point it at the cluster's CA,
// an identity file, and the NATS + control-plane seed lists.
//
// # golden: connect, create an E2E room, publish N, confirm N decrypted back
// clientcheck --ca ca.crt --identity-file operator.id \
// --nats-seeds nats://A:4250,nats://B:4250,nats://C:4250 \
// --ctrl-seeds https://A:8470,https://B:8470,https://C:8470 --messages 5
//
// # loop: publish a counter every interval for the duration, logging the node
// # it is attached to — stop a node mid-run (systemctl stop membershipd-cluster)
// # and watch it fail over to a survivor and keep receiving (quorum 2/3).
// clientcheck ... --mode loop --duration 45s --interval 1s
package main
import (
"crypto/rand"
"encoding/hex"
"flag"
"fmt"
"log"
"sort"
"strings"
"sync"
"time"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
func main() {
var (
caPath = flag.String("ca", "", "bus CA cert pinning TLS on both planes (required for a secured cluster)")
idFile = flag.String("identity-file", "", "path to the client identity JSON (e.g. `pass show unibus/operator-identity` written 0600) (required)")
natsSeeds = flag.String("nats-seeds", "", "comma-separated NATS urls of the cluster nodes (required)")
ctrlSeeds = flag.String("ctrl-seeds", "", "comma-separated control-plane https urls of the cluster nodes (required)")
subject = flag.String("subject", "test.gapcheck", "test room subject PREFIX; a random token is appended so runs never collide with real rooms")
messages = flag.Int("messages", 5, "golden mode: number of messages to publish and expect back")
mode = flag.String("mode", "golden", "golden (publish N, verify N decrypted) | loop (publish a counter for --duration, for failover testing)")
duration = flag.Duration("duration", 30*time.Second, "loop mode: how long to keep publishing")
interval = flag.Duration("interval", 1*time.Second, "loop mode: delay between published messages")
)
flag.Parse()
if *idFile == "" || *natsSeeds == "" || *ctrlSeeds == "" {
log.Fatalf("clientcheck: --identity-file, --nats-seeds and --ctrl-seeds are required")
}
id, err := client.LoadIdentity(*idFile)
if err != nil {
log.Fatalf("clientcheck: load identity: %v", err)
}
natsList := splitCSV(*natsSeeds)
ctrlList := splitCSV(*ctrlSeeds)
if len(natsList) == 0 || len(ctrlList) == 0 {
log.Fatalf("clientcheck: empty --nats-seeds or --ctrl-seeds")
}
// Build the secure client options: nkey on the data plane, TLS pinned to the
// bus CA on both planes, and the FULL seed lists so nats.go fails over to a
// surviving node when the attached one dies (the failover this tool verifies).
opts := client.Options{
NatsServers: natsList[1:],
CtrlURLs: ctrlList[1:],
}
if *caPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(*caPath)
if err != nil {
log.Fatalf("clientcheck: load CA: %v", err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
for _, u := range ctrlList {
if !strings.HasPrefix(u, "https://") {
log.Fatalf("clientcheck: control URL %q must be https:// when --ca is set", u)
}
}
}
c, err := client.NewWithOptions(natsList[0], ctrlList[0], id, opts)
if err != nil {
log.Fatalf("clientcheck: connect: %v", err)
}
defer c.Close()
log.Printf("connected: endpoint=%s nats=%s", c.Endpoint().ID, c.ConnectedServer())
// Create an EPHEMERAL E2E room (encrypted + signed, NOT persisted): the test
// stays end-to-end encrypted (the cluster requires encryption on a public
// bind) while leaving no durable JetStream stream behind. The random subject
// token guarantees the room is unique and never a real room.
rnd := make([]byte, 8)
if _, err := rand.Read(rnd); err != nil {
log.Fatalf("clientcheck: random: %v", err)
}
subj := fmt.Sprintf("%s.%s", *subject, hex.EncodeToString(rnd))
policy := room.Policy{Encrypt: true, Persist: false, SignMsgs: true}
roomID, err := c.CreateRoom(subj, policy)
if err != nil {
log.Fatalf("clientcheck: create room: %v", err)
}
log.Printf("created E2E room: id=%s subject=%s (encrypt=%v sign=%v persist=%v)", roomID, subj, policy.Encrypt, policy.SignMsgs, policy.Persist)
// Under the per-subject ACL, NATS freezes permissions at connect time, so the
// just-created room's subject is not yet publishable/subscribable on the live
// connection. RefreshSession reconnects so the authenticator re-derives the
// ACL (now including this room) — the post-0006 contract every client follows
// after a membership change.
if err := c.RefreshSession(); err != nil {
log.Fatalf("clientcheck: refresh session: %v", err)
}
switch *mode {
case "golden":
runGolden(c, roomID, *messages)
case "loop":
runLoop(c, roomID, *duration, *interval)
default:
log.Fatalf("clientcheck: --mode must be golden or loop, got %q", *mode)
}
}
// runGolden subscribes, publishes n messages, and asserts all n come back
// decrypted. Exits non-zero if any are missing.
func runGolden(c *client.Client, roomID string, n int) {
var mu sync.Mutex
got := map[string]bool{}
sub, err := c.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) {
mu.Lock()
got[string(plaintext)] = true
mu.Unlock()
})
if err != nil {
log.Fatalf("clientcheck: subscribe: %v", err)
}
defer sub.Unsubscribe()
time.Sleep(300 * time.Millisecond) // let the subscription settle
want := make([]string, n)
for i := 0; i < n; i++ {
msg := fmt.Sprintf("gapcheck-e2e-%d", i)
want[i] = msg
if err := c.Publish(roomID, []byte(msg)); err != nil {
log.Fatalf("clientcheck: publish %d: %v", i, err)
}
}
log.Printf("published %d messages to %s; waiting for decrypted echoes...", n, roomID)
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
mu.Lock()
have := len(got)
mu.Unlock()
if have >= n {
break
}
time.Sleep(100 * time.Millisecond)
}
mu.Lock()
defer mu.Unlock()
missing := 0
for _, w := range want {
if !got[w] {
missing++
log.Printf(" MISSING: %q", w)
}
}
log.Printf("connected node at finish: %s", c.ConnectedServer())
if missing > 0 {
log.Fatalf("GOLDEN FAIL: %d/%d messages not received decrypted", missing, n)
}
log.Printf("GOLDEN OK: all %d messages received and decrypted end-to-end", n)
}
// runLoop publishes a numbered message every interval for the duration and logs
// the count received plus the node currently attached, so an operator stopping a
// cluster node mid-run sees the client fail over to a survivor and keep receiving
// (quorum 2/3). It is the live failover-with-a-connected-client test the 0011
// chaos run never performed.
func runLoop(c *client.Client, roomID string, duration, interval time.Duration) {
var mu sync.Mutex
received := 0
servers := map[string]int{} // node -> #ticks observed attached
sub, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) {
mu.Lock()
received++
mu.Unlock()
})
if err != nil {
log.Fatalf("clientcheck: subscribe: %v", err)
}
defer sub.Unsubscribe()
time.Sleep(300 * time.Millisecond)
log.Printf("loop: publishing every %s for %s — stop a node now to test failover", interval, duration)
end := time.Now().Add(duration)
sent := 0
for time.Now().Before(end) {
msg := fmt.Sprintf("gapcheck-loop-%d", sent)
err := c.Publish(roomID, []byte(msg))
sent++
mu.Lock()
recv := received
mu.Unlock()
node := c.ConnectedServer()
up := c.IsConnected()
if node != "" {
mu.Lock()
servers[node]++
mu.Unlock()
}
pubStatus := "ok"
if err != nil {
pubStatus = "ERR:" + err.Error()
}
log.Printf(" t=%2ds sent=%d recv=%d up=%v node=%s publish=%s",
sent, sent, recv, up, node, pubStatus)
time.Sleep(interval)
}
mu.Lock()
defer mu.Unlock()
log.Printf("loop done: sent=%d received=%d", sent, received)
nodes := make([]string, 0, len(servers))
for n := range servers {
nodes = append(nodes, n)
}
sort.Strings(nodes)
for _, n := range nodes {
log.Printf(" attached to %s for %d ticks", n, servers[n])
}
if len(servers) > 1 {
log.Printf("FAILOVER OBSERVED: client was attached to %d distinct nodes across the run", len(servers))
}
if received == 0 {
log.Fatalf("LOOP FAIL: received 0 messages")
}
log.Printf("LOOP OK: client kept receiving across the run (received=%d)", received)
}
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+221
View File
@@ -0,0 +1,221 @@
package main
// Regression for audit report 0008, vector N3: the binary must wire the
// replicated nonce store on a clustered node so a signed request accepted on one
// node cannot be replayed to another. The auditor's ephemeral attack showed the
// OLD binary never called UseReplicatedNonces (each node kept a per-process
// cache), so a captured request replayed to a second node with 200+200. These
// tests drive the SAME helper the binary uses (wireReplicatedNonces) so they
// prove the WIRING, not just the underlying API.
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// signed008 builds a transport-signed control-plane request with a caller-chosen
// ts+nonce, so a test can reuse the exact same signed bytes against two nodes to
// exercise replay.
func signed008(t *testing.T, baseURL, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request {
t.Helper()
canonical := membership.CanonicalRequest(method, path, strconv.FormatInt(ts, 10), nonce, body)
sig := cs.SignEd25519(id.SignPriv, canonical)
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, baseURL+path, rdr)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("X-Unibus-Pub", hex.EncodeToString(id.SignPub))
req.Header.Set("X-Unibus-Ts", strconv.FormatInt(ts, 10))
req.Header.Set("X-Unibus-Nonce", nonce)
req.Header.Set("X-Unibus-Sig", base64.StdEncoding.EncodeToString(sig))
return req
}
func randNonce(t *testing.T) string {
t.Helper()
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
t.Fatalf("nonce: %v", err)
}
return base64.StdEncoding.EncodeToString(raw)
}
// TestAttack0008_N3 is the blocker regression: two clustered membershipd nodes
// wired through wireReplicatedNonces share a JetStream KV nonce bucket, so a
// request accepted on node A is rejected (401) when replayed to node B. Before
// the fix the binary never wired this and the replay returned 200.
func TestAttack0008_N3(t *testing.T) {
// One NATS+JetStream backing the shared nonce bucket (no client auth needed:
// the test drives the membership.Server's nonce store directly via HTTP).
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t),
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
nc, err := nats.Connect(ns.ClientURL())
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatalf("jetstream: %v", err)
}
// Shared control-plane state (stand-in for the replicated store) + two nodes.
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
alice, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("identity: %v", err)
}
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil {
t.Fatalf("add alice: %v", err)
}
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
// Each node is wired EXACTLY as the binary wires a clustered node.
mkNode := func() *httptest.Server {
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
if err := wireReplicatedNonces(srv, js, true /*clustered*/, 1); err != nil {
t.Fatalf("wireReplicatedNonces: %v", err)
}
return httptest.NewServer(srv)
}
nodeA := mkNode()
t.Cleanup(nodeA.Close)
nodeB := mkNode()
t.Cleanup(nodeB.Close)
ts := time.Now().Unix()
nonce := randNonce(t)
path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms"
// Golden: alice's signed request is accepted on node A.
respA, err := http.DefaultClient.Do(signed008(t, nodeA.URL, "GET", path, nil, alice, ts, nonce))
if err != nil {
t.Fatalf("do A: %v", err)
}
respA.Body.Close()
if respA.StatusCode != http.StatusOK {
t.Fatalf("node A first use: status %d, want 200", respA.StatusCode)
}
// Error path (the attack): replay the SAME signed bytes to node B → 401.
respB, err := http.DefaultClient.Do(signed008(t, nodeB.URL, "GET", path, nil, alice, ts, nonce))
if err != nil {
t.Fatalf("do B: %v", err)
}
respB.Body.Close()
if respB.StatusCode != http.StatusUnauthorized {
t.Fatalf("cross-node replay to node B: status %d, want 401 (replayed nonce must be rejected)", respB.StatusCode)
}
}
// TestAttack0008_N3_StandaloneKeepsLocalCache is the edge: a NON-clustered node
// must NOT require JetStream — wireReplicatedNonces is a no-op and the node keeps
// its in-memory cache, which still rejects a same-node replay (the single-node
// guarantee is unchanged). This proves the fix does not add a JetStream
// dependency to standalone deployments.
func TestAttack0008_N3_StandaloneKeepsLocalCache(t *testing.T) {
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
alice, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("identity: %v", err)
}
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil {
t.Fatalf("add alice: %v", err)
}
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
// Standalone: clustered=false, js=nil. Must succeed (no JetStream needed).
if err := wireReplicatedNonces(srv, nil, false /*clustered*/, 1); err != nil {
t.Fatalf("standalone wireReplicatedNonces must be a no-op, got: %v", err)
}
node := httptest.NewServer(srv)
t.Cleanup(node.Close)
ts := time.Now().Unix()
nonce := randNonce(t)
path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms"
resp1, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce))
if err != nil {
t.Fatalf("do 1: %v", err)
}
resp1.Body.Close()
if resp1.StatusCode != http.StatusOK {
t.Fatalf("first use: status %d, want 200", resp1.StatusCode)
}
// Same-node replay is still rejected by the in-memory cache.
resp2, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce))
if err != nil {
t.Fatalf("do 2: %v", err)
}
resp2.Body.Close()
if resp2.StatusCode != http.StatusUnauthorized {
t.Fatalf("same-node replay: status %d, want 401", resp2.StatusCode)
}
}
// TestAttack0008_N3_ClusteredRequiresJetStream proves the hard rule: a clustered
// node with NO JetStream available refuses (error), so the binary fails fast
// instead of silently running with a per-process cache.
func TestAttack0008_N3_ClusteredRequiresJetStream(t *testing.T) {
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
if err := wireReplicatedNonces(srv, nil, true /*clustered*/, 1); err == nil {
t.Fatalf("clustered node with no JetStream must fail, got nil")
}
}
+88 -1
View File
@@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"net" "net"
"net/url"
"os"
"strings" "strings"
"github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/membership"
@@ -21,6 +23,74 @@ func splitRoutes(csv string) []string {
return out return out
} }
// resolveClusterPass resolves the cluster route secret WITHOUT leaking it through
// argv (audit 0008 N1-low: --cluster-pass in argv is visible in ps/journald).
// Precedence: --cluster-pass-file (read + trim the file), then the env var
// UNIBUS_CLUSTER_PASS, then the legacy --cluster-pass flag (argv-visible, kept for
// dev/compat). env is injected (os.Getenv result) so the function stays testable.
// It returns the secret and a short source label for logging (never the secret).
func resolveClusterPass(passFlag, passFile, env string) (secret, source string, err error) {
if passFile != "" {
b, rerr := os.ReadFile(passFile)
if rerr != nil {
return "", "", fmt.Errorf("read --cluster-pass-file %q: %w", passFile, rerr)
}
return strings.TrimSpace(string(b)), "file", nil
}
if env != "" {
return env, "env", nil
}
if passFlag != "" {
return passFlag, "flag", nil
}
return "", "none", nil
}
// injectRouteCreds rewrites each route URL that carries NO userinfo to embed
// user:pass, so the cluster secret is supplied once (via file/env) instead of
// repeated in every --routes argv entry where ps/journald would expose it. A route
// that already carries userinfo is left untouched (operator override). With an
// empty user it is a no-op. A malformed route URL is an error (configuration bug)
// rather than a silently dropped peer.
func injectRouteCreds(routes []string, user, pass string) ([]string, error) {
if user == "" {
return routes, nil
}
out := make([]string, 0, len(routes))
for _, r := range routes {
u, err := url.Parse(r)
if err != nil {
return nil, fmt.Errorf("parse route %q: %w", r, err)
}
if u.User == nil {
u.User = url.UserPassword(user, pass)
}
out = append(out, u.String())
}
return out, nil
}
// isLoopbackURL reports whether a NATS url targets this host only (loopback). Used
// to guard migrate-to-kv (audit 0008 N6): pushing the allowlist to a REMOTE NATS
// without TLS would send handles/roles/sign-pubs in cleartext, so a remote target
// must be TLS-pinned (--ca). A url we cannot classify is treated as NON-loopback
// (conservative: it then requires --ca).
func isLoopbackURL(natsURL string) bool {
u, err := url.Parse(natsURL)
if err != nil {
return false
}
host := u.Hostname()
switch host {
case "localhost":
return true
case "":
return false
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// isLoopbackBind reports whether the --bind value keeps the service reachable // isLoopbackBind reports whether the --bind value keeps the service reachable
// only from this host. An empty bind means "all interfaces" (public), and a // only from this host. An empty bind means "all interfaces" (public), and a
// hostname we cannot resolve to a loopback literal is treated as public — the // hostname we cannot resolve to a loopback literal is treated as public — the
@@ -83,7 +153,17 @@ func validateBootConfig(bind string, mode membership.AuthMode, tlsCert, tlsKey s
// The three route-TLS paths are all-or-nothing (mutual TLS needs the node cert, // The three route-TLS paths are all-or-nothing (mutual TLS needs the node cert,
// its key, and the CA together), independent of the bind, so a partial TLS // its key, and the CA together), independent of the bind, so a partial TLS
// config never silently degrades to plaintext routes. // config never silently degrades to plaintext routes.
func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA string) error { //
// Homogeneous posture (issue 0006d, audit 0008 N1): a cluster is only as secure
// as its weakest node — the data plane forwards every subject between nodes, so a
// single node running without enforced auth lets an unauthenticated peer
// Subscribe(">") on it and harvest the traffic forwarded from the ACL'd nodes.
// This node therefore REFUSES to join a cluster unless it runs --bus-auth enforce,
// regardless of bind: a clustered node is a production node, and there is no safe
// "dev cluster without auth". (A peer running a tampered binary is out of this
// node's control; /healthz exposes each node's posture so a monitor can detect
// one that is not enforce+ACL — see Server.Posture.)
func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA string, mode membership.AuthMode) error {
rtAny := rtCert != "" || rtKey != "" || rtCA != "" rtAny := rtCert != "" || rtKey != "" || rtCA != ""
rtAll := rtCert != "" && rtKey != "" && rtCA != "" rtAll := rtCert != "" && rtKey != "" && rtCA != ""
if rtAny && !rtAll { if rtAny && !rtAll {
@@ -93,6 +173,13 @@ func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA st
if clusterName == "" { if clusterName == "" {
return nil // standalone: no route layer to secure return nil // standalone: no route layer to secure
} }
// A clustered node MUST enforce auth (homogeneous posture). Checked before the
// loopback shortcut so even a loopback cluster cannot form without enforce.
if mode != membership.AuthEnforce {
return fmt.Errorf(
"refusing to start: cluster %q requires --bus-auth enforce; a cluster node without enforced auth+ACL lets an unauthenticated peer harvest the traffic forwarded from the other nodes (audit 0008 N1) — every node must run the same enforce+ACL+TLS posture",
clusterName)
}
if isLoopbackBind(bind) { if isLoopbackBind(bind) {
return nil // loopback cluster is dev-only and unreachable from outside return nil // loopback cluster is dev-only and unreachable from outside
} }
+44 -19
View File
@@ -108,31 +108,40 @@ func TestBootConfigPolicy(t *testing.T) {
// route-TLS flags are all-or-nothing regardless of bind. // route-TLS flags are all-or-nothing regardless of bind.
func TestClusterConfigPolicy(t *testing.T) { func TestClusterConfigPolicy(t *testing.T) {
const c, k, ca = "node.crt", "node.key", "ca.crt" const c, k, ca = "node.crt", "node.key", "ca.crt"
en := membership.AuthEnforce
off := membership.AuthOff
soft := membership.AuthSoft
cases := []struct { cases := []struct {
name string name string
clusterName, bind string clusterName, bind string
user, pass string user, pass string
rtCert, rtKey, rtCA string rtCert, rtKey, rtCA string
wantErr bool mode membership.AuthMode
wantErr bool
}{ }{
// Standalone (no cluster name) is always allowed, even on a public bind. // Standalone (no cluster name) is always allowed, even on a public bind and
{"standalone-public", "", "0.0.0.0", "", "", "", "", "", false}, // without enforce — the cluster posture rule does not apply to a single node.
// Loopback dev cluster: unguarded (unreachable from outside). {"standalone-public-off", "", "0.0.0.0", "", "", "", "", "", off, false},
{"loopback-cluster-bare", "unibus", "127.0.0.1", "", "", "", "", "", false}, // Loopback dev cluster WITH enforce: allowed (unreachable from outside).
// Golden: full public HA config. {"loopback-cluster-enforce", "unibus", "127.0.0.1", "", "", "", "", "", en, false},
{"public-full", "unibus", "0.0.0.0", "u", "p", c, k, ca, false}, // Golden: full public HA config under enforce.
// Error: public cluster without a route secret. {"public-full-enforce", "unibus", "0.0.0.0", "u", "p", c, k, ca, en, false},
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, true}, // N1 (audit 0008): a clustered node WITHOUT enforce is refused — even on
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, true}, // loopback — so no weak node can join the cluster.
{"cluster-off-refused", "unibus", "127.0.0.1", "", "", "", "", "", off, true},
{"cluster-soft-refused", "unibus", "0.0.0.0", "u", "p", c, k, ca, soft, true},
// Error: public cluster without a route secret (enforce on, fails on secret).
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, en, true},
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, en, true},
// Error: public cluster without mutual route TLS. // Error: public cluster without mutual route TLS.
{"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", true}, {"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", en, true},
// Error: partial route-TLS flags trip regardless of bind. // Error: partial route-TLS flags trip regardless of bind/mode.
{"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", true}, {"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", en, true},
{"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", true}, {"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", off, true},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA) err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA, tc.mode)
if tc.wantErr && err == nil { if tc.wantErr && err == nil {
t.Fatalf("cluster config %+v should be refused", tc) t.Fatalf("cluster config %+v should be refused", tc)
} }
@@ -143,6 +152,22 @@ func TestClusterConfigPolicy(t *testing.T) {
} }
} }
// TestAttack0008_N1 is the regression for audit 0008 N1 scenario 2: a node
// configured to join a cluster while NOT enforcing auth (the weak node that lets
// an unauthenticated peer harvest the cluster's forwarded traffic) must be refused
// at startup. The homogeneous-posture rule makes this binary unable to BE that
// weak node.
func TestAttack0008_N1(t *testing.T) {
// Weak node: clustered but --bus-auth off -> refused.
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthOff); err == nil {
t.Fatalf("a clustered node without enforce must be refused (audit 0008 N1)")
}
// Same node WITH enforce + full route security -> allowed.
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthEnforce); err != nil {
t.Fatalf("a clustered enforce node with full route security must be allowed, got: %v", err)
}
}
func TestSplitRoutes(t *testing.T) { func TestSplitRoutes(t *testing.T) {
cases := []struct { cases := []struct {
in string in string
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"fmt"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
server "github.com/nats-io/nats-server/v2/server"
)
// connectInternalJS opens a privileged JetStream client from membershipd to its
// OWN embedded NATS server. This is the resolution of the "bootstrap cycle"
// (issue 0006a/c): the service needs JetStream to create the replicated nonce
// bucket and the control-plane KV, but under enforce the data plane only accepts
// allowlisted clients confined to their rooms. The connection therefore
// authenticates with the process's ephemeral internal identity — the identity the
// authenticator was built to recognize (NewNkeyAuthenticatorACLInternal) and
// grant full permissions — without ever appearing in the user allowlist.
//
// It uses the in-process transport (nats.InProcessServer), a Go pipe inside the
// process, so it bypasses TLS entirely: no CA wiring is needed for this
// self-connection even when the public data plane is TLS-only. useNkey mirrors
// whether the embedded server enforces auth: under enforce the internal identity
// presents its nkey; without enforce the server accepts an unauthenticated
// in-process client and the nkey is omitted.
//
// The caller owns the returned connection and must Close it on shutdown (after
// the JetStream context is no longer used).
func connectInternalJS(ns *server.Server, internalID cs.Identity, useNkey bool) (*nats.Conn, jetstream.JetStream, error) {
opts := []nats.Option{
nats.Name("membershipd-internal"),
nats.InProcessServer(ns),
}
if useNkey {
pub, sign, err := busauth.ClientNkey(internalID.SignPriv)
if err != nil {
return nil, nil, fmt.Errorf("internal nkey: %w", err)
}
opts = append(opts, nats.Nkey(pub, sign))
}
// The URL is ignored for an in-process connection; the InProcessServer option
// supplies the transport.
nc, err := nats.Connect("", opts...)
if err != nil {
return nil, nil, fmt.Errorf("connect internal nats: %w", err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, nil, fmt.Errorf("internal jetstream: %w", err)
}
return nc, js, nil
}
// connectExternalJS opens a JetStream client to an EXTERNAL NATS the operator
// runs (membershipd started with --nats-url). Unlike the embedded path there is
// no in-process transport and no internal identity: the external server enforces
// its own auth, so membershipd connects as a plain client (optionally TLS-pinned
// to the bus CA). It is best-effort and intended for an operator-managed cluster;
// the standard unibus deploy uses the embedded server (connectInternalJS).
func connectExternalJS(natsURL, caPath string) (*nats.Conn, jetstream.JetStream, error) {
opts := []nats.Option{nats.Name("membershipd-internal")}
if caPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
if err != nil {
return nil, nil, fmt.Errorf("load CA %q: %w", caPath, err)
}
opts = append(opts, nats.Secure(tlsCfg))
}
nc, err := nats.Connect(natsURL, opts...)
if err != nil {
return nil, nil, fmt.Errorf("connect external nats %q: %w", natsURL, err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, nil, fmt.Errorf("external jetstream: %w", err)
}
return nc, js, nil
}
+119
View File
@@ -0,0 +1,119 @@
package main
// Bootstrap test for issue 0006a/c: under enforce, membershipd must still reach
// JetStream on its OWN embedded server to create the nonce/KV buckets. It does so
// with an ephemeral internal identity the authenticator grants full permissions
// (NewNkeyAuthenticatorACLInternal). These tests prove that privileged
// self-connection works AND that no other identity can claim it.
import (
"context"
"encoding/hex"
"net"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
func icFreePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("free port: %v", err)
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// TestInternalConnPrivilegedUnderEnforce: with an enforce authenticator that
// authorizes NO bus user, the internal identity still connects in-process and has
// full permissions — it creates a KV bucket and round-trips a value. This is the
// resolution of the bootstrap cycle the audit flagged as the reason the KV store
// was never wired.
func TestInternalConnPrivilegedUnderEnforce(t *testing.T) {
internalID, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("internal identity: %v", err)
}
internalPubHex := hex.EncodeToString(internalID.SignPub)
// Authenticator: no bus user is authorized; only the internal identity passes.
auth := busauth.NewNkeyAuthenticatorACLInternal(
func(string) bool { return false },
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
internalPubHex,
)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
nc, js, err := connectInternalJS(ns, internalID, true /*useNkey*/)
if err != nil {
t.Fatalf("connectInternalJS: %v", err)
}
t.Cleanup(nc.Close)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "KV_UNIBUS_test", Replicas: 1})
if err != nil {
t.Fatalf("internal conn could not create KV bucket (full perms expected): %v", err)
}
if _, err := kv.Put(ctx, "k", []byte("v")); err != nil {
t.Fatalf("kv put: %v", err)
}
e, err := kv.Get(ctx, "k")
if err != nil || string(e.Value()) != "v" {
t.Fatalf("kv get: val=%q err=%v", e, err)
}
}
// TestInternalConnOutsiderRejected: an identity that is neither the internal one
// nor an allowlisted bus user cannot connect — proving the internal bypass is
// scoped to the exact internal key, not a blanket hole.
func TestInternalConnOutsiderRejected(t *testing.T) {
internalID, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("internal identity: %v", err)
}
auth := busauth.NewNkeyAuthenticatorACLInternal(
func(string) bool { return false },
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
hex.EncodeToString(internalID.SignPub),
)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
outsider, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("outsider identity: %v", err)
}
pub, sign, err := busauth.ClientNkey(outsider.SignPriv)
if err != nil {
t.Fatalf("outsider nkey: %v", err)
}
conn, err := nats.Connect(ns.ClientURL(),
nats.Nkey(pub, sign),
nats.MaxReconnects(0),
nats.Timeout(2*time.Second),
)
if err == nil {
conn.Close()
t.Fatalf("outsider (unauthorized, non-internal) must be rejected, but connected")
}
}
+154
View File
@@ -0,0 +1,154 @@
package main
// Wiring tests for issue 0006c: --store kv selects the replicated JetStream KV
// control plane, the authenticator serves from it through the storeHolder, and a
// new node sees state created by another (the divergence that per-node SQLite
// caused — audit 0008 N5 — is gone). Branch-by-abstraction is verified elsewhere
// (the SQLite default path is the unchanged baseline covered by the existing
// suite).
import (
"encoding/hex"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// TestKVStoreBootstrapUnderEnforce drives the exact decentralized boot the binary
// performs: build the authenticator over an empty holder, start NATS, open the
// privileged internal connection, open the KV store, publish it into the holder,
// then a real bus user (seeded into the KV store) authenticates over nkey. This
// proves the bootstrap cycle is broken correctly — the KV-backed control plane
// authorizes live clients under enforce.
func TestKVStoreBootstrapUnderEnforce(t *testing.T) {
internalID, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("internal identity: %v", err)
}
holder := &storeHolder{}
auth := busauth.NewNkeyAuthenticatorACLInternal(
holder.IsAuthorized,
busauth.PermissionsFromSubjects(holder.subjectACL),
hex.EncodeToString(internalID.SignPub),
)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
// Privileged internal connection opens the KV store while the holder still
// denies every normal client.
intNC, js, err := connectInternalJS(ns, internalID, true)
if err != nil {
t.Fatalf("connectInternalJS: %v", err)
}
t.Cleanup(intNC.Close)
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
if err != nil {
t.Fatalf("open kv store: %v", err)
}
holder.set(kvStore)
// Seed a bus user into the KV control plane.
alice, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("alice: %v", err)
}
if err := kvStore.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleMember); err != nil {
t.Fatalf("seed alice: %v", err)
}
// alice authenticates over nkey — authorized via the KV store through the holder.
pub, sign, err := busauth.ClientNkey(alice.SignPriv)
if err != nil {
t.Fatalf("alice nkey: %v", err)
}
aliceNC, err := nats.Connect(ns.ClientURL(), nats.Nkey(pub, sign), nats.MaxReconnects(0), nats.Timeout(2*time.Second))
if err != nil {
t.Fatalf("alice (KV-authorized) must connect under enforce: %v", err)
}
aliceNC.Close()
// An outsider not in the KV store is denied (fail closed).
outsider, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("outsider: %v", err)
}
opub, osign, err := busauth.ClientNkey(outsider.SignPriv)
if err != nil {
t.Fatalf("outsider nkey: %v", err)
}
if oc, err := nats.Connect(ns.ClientURL(), nats.Nkey(opub, osign), nats.MaxReconnects(0), nats.Timeout(2*time.Second)); err == nil {
oc.Close()
t.Fatalf("an outsider absent from the KV store must be rejected")
}
}
// TestKVStoreDecentralizedConsistency: a room/user created via one node's KV store
// is immediately visible to another node's KV store over the same JetStream — the
// shared, replicated control plane that ends the per-node SQLite divergence.
func TestKVStoreDecentralizedConsistency(t *testing.T) {
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t),
})
if err != nil {
t.Fatalf("nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
open := func() membership.Store {
nc, err := nats.Connect(ns.ClientURL())
if err != nil {
t.Fatalf("connect: %v", err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatalf("jetstream: %v", err)
}
st, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
if err != nil {
t.Fatalf("open kv: %v", err)
}
return st
}
nodeA := open()
nodeB := open()
owner, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("owner: %v", err)
}
ownerPub := hex.EncodeToString(owner.SignPub)
if err := nodeA.AddUser(ownerPub, "owner", membership.RoleAdmin); err != nil {
t.Fatalf("nodeA add user: %v", err)
}
if err := nodeA.CreateRoom(
membership.RoomInfo{RoomID: "ROOMX", Subject: "room.shared.x", OwnerEndpoint: "owner-ep"},
owner.SignPub, owner.KexPub, nil,
); err != nil {
t.Fatalf("nodeA create room: %v", err)
}
// nodeB (a different connection, same buckets) sees both immediately.
if !nodeB.IsAuthorized(ownerPub) {
t.Fatalf("nodeB must see the user created on nodeA (decentralized state divergence)")
}
got, err := nodeB.GetRoom("ROOMX")
if err != nil {
t.Fatalf("nodeB must see the room created on nodeA: %v", err)
}
if got.Subject != "room.shared.x" {
t.Fatalf("nodeB read wrong room subject: %q", got.Subject)
}
}
+152
View File
@@ -0,0 +1,152 @@
package main
// Integration tests for issue 0011 GAP A: `membershipd user add --store kv`
// adds users to a RUNNING cluster's replicated allowlist via the privileged
// internal connection, instead of the stop-seed-restart procedure the 0011
// deploy required. These exercise the real connectKVStore path (load the
// persisted internal identity from a file, present its nkey, open the KV store,
// write the user) against an embedded enforce node, plus the idempotency and
// error semantics the DoD calls for. Multi-node replication and node-down quorum
// are validated against the live cluster (report 0012).
import (
"encoding/hex"
"errors"
"path/filepath"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership"
)
// startEnforceKVNode boots a single embedded enforce node whose authenticator
// recognizes internalPubHex as the privileged internal identity, bootstraps the
// KV control-plane store over the in-process internal connection, and publishes
// it into the holder — the exact sequence main.go performs for --store kv. It
// returns the client URL the CLI connects to.
func startEnforceKVNode(t *testing.T, internalID cs.Identity) string {
t.Helper()
holder := &storeHolder{}
auth := busauth.NewNkeyAuthenticatorACLInternal(
holder.IsAuthorized,
busauth.PermissionsFromSubjects(holder.subjectACL),
hex.EncodeToString(internalID.SignPub),
)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("start enforce node: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
intNC, js, err := connectInternalJS(ns, internalID, true)
if err != nil {
t.Fatalf("bootstrap internal connection: %v", err)
}
t.Cleanup(intNC.Close)
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
if err != nil {
t.Fatalf("bootstrap KV store: %v", err)
}
holder.set(kvStore)
return ns.ClientURL()
}
// TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI
// connection (real connectKVStore, loading the internal identity from a file and
// presenting its nkey) writes a user into the live KV allowlist, the user is
// authorized afterward, and re-adding the same key is an explicit ErrUserExists
// with no corruption (the unchanged row is still authorized).
func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600
if err != nil {
t.Fatalf("persist internal identity: %v", err)
}
url := startEnforceKVNode(t, internalID)
// Golden: connect as the privileged internal identity (loopback, no TLS) and
// add a new user, exactly as `user add --store kv` does.
kv, err := connectKVStore(url, idFile, "", 1)
if err != nil {
t.Fatalf("connectKVStore (privileged): %v", err)
}
defer kv.Close()
newUser, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("new user identity: %v", err)
}
pub := hex.EncodeToString(newUser.SignPub)
if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil {
t.Fatalf("add user to live KV: %v", err)
}
if !kv.store.IsAuthorized(pub) {
t.Fatalf("user added to KV must be authorized")
}
// Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists.
err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember)
if !errors.Is(err, membership.ErrUserExists) {
t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err)
}
// A different handle/role with the SAME key is also rejected — the row is not
// silently overwritten (no role flip).
if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) {
t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err)
}
u, err := kv.store.GetUser(pub)
if err != nil {
t.Fatalf("get user: %v", err)
}
if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive {
t.Fatalf("idempotent re-add corrupted the row: %+v", u)
}
}
// TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable
// internal identity file fails loudly (missing file, empty path) rather than
// silently connecting unprivileged.
func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) {
if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil {
t.Fatalf("empty --internal-id-file must be an error")
}
missing := filepath.Join(t.TempDir(), "nope.id")
if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil {
t.Fatalf("missing internal identity file must be an error")
}
}
// TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv
// at a dead endpoint yields a clear, handled error (no crash, no silent success).
func TestUserAddStoreKV_UnreachableKV(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
t.Fatalf("persist internal identity: %v", err)
}
// A loopback port with nothing listening: connect must fail fast and wrapped.
_, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1)
if err == nil {
t.Fatalf("connecting to a dead endpoint must error")
}
}
// TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca
// is refused so the allowlist write never travels in cleartext (audit 0008 N6,
// same guard as migrate-to-kv).
func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) {
idFile := filepath.Join(t.TempDir(), "internal.id")
if _, err := client.LoadOrCreateIdentity(idFile); err != nil {
t.Fatalf("persist internal identity: %v", err)
}
_, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1)
if err == nil {
t.Fatalf("remote target without --ca must be refused")
}
}
+75
View File
@@ -0,0 +1,75 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestResolveClusterPass verifies the secret resolution precedence
// (file > env > flag) that keeps the cluster password out of argv (issue 0006f).
func TestResolveClusterPass(t *testing.T) {
// file wins over env and flag, and is trimmed.
f := filepath.Join(t.TempDir(), "pass")
if err := os.WriteFile(f, []byte("filesecret\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if got, src, err := resolveClusterPass("flagsecret", f, "envsecret"); err != nil || got != "filesecret" || src != "file" {
t.Fatalf("file precedence: got %q src %q err %v", got, src, err)
}
// env wins over flag when no file.
if got, src, err := resolveClusterPass("flagsecret", "", "envsecret"); err != nil || got != "envsecret" || src != "env" {
t.Fatalf("env precedence: got %q src %q err %v", got, src, err)
}
// flag is the last resort.
if got, src, err := resolveClusterPass("flagsecret", "", ""); err != nil || got != "flagsecret" || src != "flag" {
t.Fatalf("flag fallback: got %q src %q err %v", got, src, err)
}
// none set.
if got, src, err := resolveClusterPass("", "", ""); err != nil || got != "" || src != "none" {
t.Fatalf("none: got %q src %q err %v", got, src, err)
}
// missing file is an error.
if _, _, err := resolveClusterPass("", filepath.Join(t.TempDir(), "nope"), ""); err == nil {
t.Fatalf("missing file must error")
}
}
// TestInjectRouteCreds verifies the secret is injected only into routes that omit
// userinfo, so --routes argv need not carry the password (issue 0006f).
func TestInjectRouteCreds(t *testing.T) {
in := []string{"nats://10.0.0.2:6250", "nats://override:pw@10.0.0.3:6250"}
out, err := injectRouteCreds(in, "user", "secret")
if err != nil {
t.Fatalf("inject: %v", err)
}
if !strings.Contains(out[0], "user:secret@10.0.0.2:6250") {
t.Fatalf("creds not injected into bare route: %q", out[0])
}
if !strings.Contains(out[1], "override:pw@10.0.0.3:6250") {
t.Fatalf("existing userinfo must be preserved: %q", out[1])
}
// empty user is a no-op.
noop, err := injectRouteCreds(in, "", "")
if err != nil || noop[0] != in[0] {
t.Fatalf("empty user must be a no-op: %v %q", err, noop[0])
}
}
// TestIsLoopbackURL guards migrate-to-kv against pushing the allowlist cleartext
// to a remote NATS (issue 0006f, audit 0008 N6).
func TestIsLoopbackURL(t *testing.T) {
loop := []string{"nats://127.0.0.1:4250", "nats://localhost:4250", "nats://[::1]:4250"}
for _, u := range loop {
if !isLoopbackURL(u) {
t.Fatalf("%q should be loopback", u)
}
}
remote := []string{"nats://10.0.0.2:4250", "nats://bus.example.com:4250", "::not-a-url"}
for _, u := range remote {
if isLoopbackURL(u) {
t.Fatalf("%q should NOT be loopback", u)
}
}
}
+177 -14
View File
@@ -7,6 +7,7 @@ package main
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/hex"
"flag" "flag"
"log" "log"
"net/http" "net/http"
@@ -15,10 +16,15 @@ import (
"syscall" "syscall"
"time" "time"
cs "fn-registry/functions/cybersecurity"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
server "github.com/nats-io/nats-server/v2/server" server "github.com/nats-io/nats-server/v2/server"
"github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/membership"
) )
@@ -58,10 +64,37 @@ func main() {
clusterPort = flag.Int("cluster-port", 6250, "route listener port for server-to-server cluster traffic") clusterPort = flag.Int("cluster-port", 6250, "route listener port for server-to-server cluster traffic")
routesCSV = flag.String("routes", "", "comma-separated nats-route URLs of the OTHER nodes, e.g. nats://user:pass@10.0.0.2:6250") routesCSV = flag.String("routes", "", "comma-separated nats-route URLs of the OTHER nodes, e.g. nats://user:pass@10.0.0.2:6250")
clusterUser = flag.String("cluster-user", "", "shared route secret username (gates the route listener)") clusterUser = flag.String("cluster-user", "", "shared route secret username (gates the route listener)")
clusterPass = flag.String("cluster-pass", "", "shared route secret password") clusterPass = flag.String("cluster-pass", "", "shared route secret password (argv-visible — prefer --cluster-pass-file or UNIBUS_CLUSTER_PASS)")
// Secret out of argv (issue 0006f, audit 0008 N1-low): a password in
// --cluster-pass / --routes is visible in ps/journald. Prefer a file or the
// UNIBUS_CLUSTER_PASS env var; routes may then omit userinfo and the secret
// is injected from here.
clusterPassFile = flag.String("cluster-pass-file", "", "path to a file holding the cluster route password (preferred over --cluster-pass; keeps the secret out of argv)")
routeTLSCert = flag.String("route-tls-cert", "", "this node's route certificate (CA-signed); enables mutual route TLS with --route-tls-key/--route-tls-ca") routeTLSCert = flag.String("route-tls-cert", "", "this node's route certificate (CA-signed); enables mutual route TLS with --route-tls-key/--route-tls-ca")
routeTLSKey = flag.String("route-tls-key", "", "this node's route private key") routeTLSKey = flag.String("route-tls-key", "", "this node's route private key")
routeTLSCA = flag.String("route-tls-ca", "", "bus CA that signs every node's route certificate (deploy/tls/ca.crt)") routeTLSCA = flag.String("route-tls-ca", "", "bus CA that signs every node's route certificate (deploy/tls/ca.crt)")
// Replicated control plane (issue 0006a/c): the JetStream replication factor
// for the shared nonce bucket (and, with --store kv, the control-plane KV).
// 1 for a 1-2 node rollout, 3 for real HA quorum (raise in place with
// `nats stream update --replicas 3` when the third node joins).
kvReplicas = flag.Int("kv-replicas", 1, "JetStream replication factor for the shared nonce/KV buckets (1..3)")
caFile = flag.String("ca", "", "bus CA cert; only used to pin TLS on the internal JetStream connection to an EXTERNAL --nats-url (the embedded server uses an in-process connection that needs no CA)")
// Control-plane store backend (issue 0006c, feature flag decentralized):
// "sqlite" (default) keeps the local single-node SQLite control plane;
// "kv" puts rooms/members/keys/users in replicated JetStream KV so any node
// in the cluster serves the same state.
storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)")
// Persisted internal service identity (issue 0011 gaps, GAP A): when set, the
// privileged internal identity used to manage JetStream is LOADED from this
// file (generated and persisted on first start) instead of being a fresh
// ephemeral key each boot. Persisting it is what lets `membershipd user add
// --store kv` write the replicated allowlist of a LIVE cluster: that CLI,
// run over loopback on a node, loads the SAME identity and presents the nkey
// this node's authenticator already grants full permissions. Empty keeps the
// ephemeral-per-process behavior (single-node/dev default, unchanged). The
// file holds a private key: it is written 0600 and belongs next to the node's
// TLS keys (deploy keeps it under secrets/, gitignored).
internalIDFile = flag.String("internal-id-file", "", "path to a persisted internal service identity (JSON); enables `membershipd user add --store kv` against the live cluster. Empty = ephemeral per-process identity (dev default)")
) )
flag.Parse() flag.Parse()
@@ -69,6 +102,17 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("%v", err) log.Fatalf("%v", err)
} }
if *storeBackend != "sqlite" && *storeBackend != "kv" {
log.Fatalf("--store must be \"sqlite\" or \"kv\", got %q", *storeBackend)
}
// Resolve the cluster route secret out of argv (file/env preferred). The
// resolved value (not *clusterPass) is what guards the route layer and is
// injected into peer route URLs below.
clusterPassResolved, passSource, err := resolveClusterPass(*clusterPass, *clusterPassFile, os.Getenv("UNIBUS_CLUSTER_PASS"))
if err != nil {
log.Fatalf("%v", err)
}
// Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands // Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands
// --bus-auth enforce. This makes an insecure public startup impossible rather // --bus-auth enforce. This makes an insecure public startup impossible rather
@@ -78,21 +122,74 @@ func main() {
} }
// Cluster route guard (issue 0003a): a public cluster needs a route secret // Cluster route guard (issue 0003a): a public cluster needs a route secret
// and mutual route TLS, and the route-TLS flags are all-or-nothing. // and mutual route TLS, and the route-TLS flags are all-or-nothing.
if err := validateClusterConfig(*clusterName, *bind, *clusterUser, *clusterPass, *routeTLSCert, *routeTLSKey, *routeTLSCA); err != nil { if err := validateClusterConfig(*clusterName, *bind, *clusterUser, clusterPassResolved, *routeTLSCert, *routeTLSKey, *routeTLSCA, authMode); err != nil {
log.Fatalf("%v", err) log.Fatalf("%v", err)
} }
log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[membershipd] ") log.SetPrefix("[membershipd] ")
// Control plane store first: the NATS authenticator consults IsAuthorized, so // A clustered node shares its control plane with peers, so it needs a JetStream
// the store must exist before the embedded server starts. // client to manage the replicated nonce bucket (issue 0006a). --store kv (issue
store, err := membership.Open(*dbPath) // 0006c) also needs JetStream, for the control-plane KV itself. A standalone
if err != nil { // single-node SQLite deployment needs none of this and keeps the in-process,
log.Fatalf("open membership store: %v", err) // in-memory behavior unchanged.
clustered := *clusterName != ""
decentralized := *storeBackend == "kv"
needJS := clustered || decentralized
enforce := authMode == membership.AuthEnforce
// Internal service identity (issue 0006a): when the embedded data plane enforces
// auth, membershipd must still connect to its OWN server to manage JetStream.
// It does so with this ephemeral identity, which the authenticator is built to
// recognize and grant full permissions (it never enters the user allowlist). It
// is only generated when actually needed (JetStream required AND enforce on AND
// the server is embedded), so a standalone or non-enforce node is unchanged.
var internalID cs.Identity
var internalPubHex string
if needJS && enforce && *natsURL == "" {
if *internalIDFile != "" {
// Persisted identity: load it, generating + writing it (0600) on first
// start. A stable internal key is what `user add --store kv` presents to
// add users to a live cluster (GAP A); rotate it by deleting the file and
// restarting.
internalID, err = client.LoadOrCreateIdentity(*internalIDFile)
if err != nil {
log.Fatalf("load internal service identity %q: %v", *internalIDFile, err)
}
log.Printf("internal service identity: persisted (%s)", *internalIDFile)
} else {
internalID, err = cs.GenerateIdentity()
if err != nil {
log.Fatalf("generate internal identity: %v", err)
}
}
internalPubHex = hex.EncodeToString(internalID.SignPub)
} }
defer store.Close()
log.Printf("membership store: %s", *dbPath) // The authenticator consults the store through a holder so it can be built
// before the store exists: with --store kv the JetStream KV store opens only
// after NATS is up (the bootstrap cycle). In the default SQLite path the store
// is opened and set into the holder right here, before the server starts, so
// behavior is identical to the pre-0006c baseline. `store` is the final store
// used by the HTTP server (set below for the KV path).
holder := &storeHolder{}
var store membership.Store
if !decentralized {
store, err = membership.Open(*dbPath)
if err != nil {
log.Fatalf("open membership store: %v", err)
}
holder.set(store)
log.Printf("membership store: sqlite %s", *dbPath)
}
// Close whichever store ends up final (SQLite closes its file; the JetStream KV
// store's Close is a no-op — its NATS connection is closed separately).
defer func() {
if store != nil {
store.Close()
}
}()
blobs, err := blobstore.New(*storeDir) blobs, err := blobstore.New(*storeDir)
if err != nil { if err != nil {
@@ -118,14 +215,21 @@ func main() {
} }
// Cluster (issue 0003a): with a cluster name, join the route layer for HA. // Cluster (issue 0003a): with a cluster name, join the route layer for HA.
if *clusterName != "" { if *clusterName != "" {
// Inject the resolved secret into peer route URLs that omit userinfo, so
// the password need not appear in --routes argv (issue 0006f).
routes, rerr := injectRouteCreds(splitRoutes(*routesCSV), *clusterUser, clusterPassResolved)
if rerr != nil {
log.Fatalf("%v", rerr)
}
cc := &embeddednats.ClusterConfig{ cc := &embeddednats.ClusterConfig{
Name: *clusterName, Name: *clusterName,
Host: *bind, Host: *bind,
Port: *clusterPort, Port: *clusterPort,
Routes: splitRoutes(*routesCSV), Routes: routes,
Username: *clusterUser, Username: *clusterUser,
Password: *clusterPass, Password: clusterPassResolved,
} }
log.Printf("cluster route secret source: %s", passSource)
if *routeTLSCert != "" { if *routeTLSCert != "" {
rtls, err := busauth.RouteTLSConfig(*routeTLSCert, *routeTLSKey, *routeTLSCA) rtls, err := busauth.RouteTLSConfig(*routeTLSCert, *routeTLSKey, *routeTLSCA)
if err != nil { if err != nil {
@@ -145,9 +249,10 @@ func main() {
// Subscribe(">") and harvest every room's subject and JetStream activity. // Subscribe(">") and harvest every room's subject and JetStream activity.
// NATS freezes permissions at connect time, so a peer that joins a room // NATS freezes permissions at connect time, so a peer that joins a room
// after connecting must client.RefreshSession to gain that room's subject. // after connecting must client.RefreshSession to gain that room's subject.
cfg.Auth = busauth.NewNkeyAuthenticatorACL( cfg.Auth = busauth.NewNkeyAuthenticatorACLInternal(
store.IsAuthorized, holder.IsAuthorized,
busauth.PermissionsFromSubjects(membership.SubjectACLFor(store)), busauth.PermissionsFromSubjects(holder.subjectACL),
internalPubHex,
) )
log.Printf("NATS nkey authentication: ON (enforce, per-subject ACL)") log.Printf("NATS nkey authentication: ON (enforce, per-subject ACL)")
} }
@@ -172,6 +277,38 @@ func main() {
log.Printf("using external NATS: %s", natsClientURL) log.Printf("using external NATS: %s", natsClientURL)
} }
// JetStream client + decentralized store (issue 0006a/c). needJS is set for a
// clustered node (shared nonce bucket) and for --store kv (the KV control
// plane). Open the privileged JetStream client first (in-process for the
// embedded server, a plain client for external NATS), then — for --store kv —
// open the replicated KV store and publish it into the holder so the
// authenticator and HTTP server serve from it. The privileged connection is the
// only client that can connect in this window (the holder still denies everyone
// else; the internal identity bypasses the store).
var js jetstream.JetStream
if needJS {
var internalNC *nats.Conn
if *natsURL == "" {
internalNC, js, err = connectInternalJS(ns, internalID, enforce)
} else {
internalNC, js, err = connectExternalJS(natsClientURL, *caFile)
}
if err != nil {
log.Fatalf("internal JetStream connection (required by --cluster-name/--store kv): %v", err)
}
defer internalNC.Close()
if decentralized {
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: *kvReplicas})
if err != nil {
log.Fatalf("open decentralized control-plane KV store: %v", err)
}
store = kvStore
holder.set(store)
log.Printf("membership store: jetstream KV (replicas=%d)", *kvReplicas)
}
}
srv := membership.NewServer(store, blobs, authMode) srv := membership.NewServer(store, blobs, authMode)
// On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS // On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS
// has no per-subject ACL, so cleartext content would be readable by any // has no per-subject ACL, so cleartext content would be readable by any
@@ -181,6 +318,32 @@ func main() {
srv.RequireEncryptedRooms = true srv.RequireEncryptedRooms = true
log.Printf("cleartext rooms: DISABLED (public bind requires end-to-end encryption)") log.Printf("cleartext rooms: DISABLED (public bind requires end-to-end encryption)")
} }
// Publish this node's posture on /healthz so a monitor (or a peer) can detect a
// cluster member not running the homogeneous enforce+ACL+TLS posture (audit
// 0008 N1). enforce implies the per-subject ACL in this binary (they are wired
// together above).
srv.Posture = membership.Posture{
Enforce: enforce,
ACL: enforce,
TLS: *tlsCert != "",
Cluster: clustered,
Store: *storeBackend,
}
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
// share its nonce store across the cluster, or a request accepted on one node
// can be replayed to another. HARD requirement: if the bucket cannot be created
// the node refuses to start rather than run with a per-process cache that leaves
// the replay hole open.
if needJS {
if err := wireReplicatedNonces(srv, js, clustered, *kvReplicas); err != nil {
log.Fatalf("%v", err)
}
if clustered {
log.Printf("anti-replay: replicated nonce bucket \"KV_UNIBUS_nonces\" (replicas=%d) — cluster-safe", *kvReplicas)
}
}
log.Printf("control-plane auth: %s", authMode) log.Printf("control-plane auth: %s", authMode)
addr := *bind + ":" + *httpPort addr := *bind + ":" + *httpPort
httpSrv := &http.Server{ httpSrv := &http.Server{
+8
View File
@@ -33,6 +33,14 @@ func runMigrateCLI(args []string) {
fmt.Fprintln(os.Stderr, "membershipd migrate-to-kv: --nats-url is required (the cluster to write the KV buckets into)") fmt.Fprintln(os.Stderr, "membershipd migrate-to-kv: --nats-url is required (the cluster to write the KV buckets into)")
os.Exit(2) os.Exit(2)
} }
// Confidentiality guard (issue 0006f, audit 0008 N6): the migration writes the
// allowlist (handles, roles, signing pubkeys) into the KV. Against a REMOTE NATS
// without TLS that metadata would travel in cleartext, so a remote target MUST
// be TLS-pinned with --ca. A loopback target is local-only and exempt.
if !isLoopbackURL(*natsURL) && *ca == "" {
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: refusing to migrate to remote %q without --ca; the allowlist (handles/roles/sign pubs) would travel in cleartext — pin TLS with --ca, or run against a loopback nats-url\n", *natsURL)
os.Exit(2)
}
// Back up the SQLite database first so a botched migration can be undone. // Back up the SQLite database first so a botched migration can be undone.
var backupPath string var backupPath string
+60
View File
@@ -0,0 +1,60 @@
package main
import (
"fmt"
"sync"
"github.com/enmanuel/unibus/pkg/membership"
)
// storeHolder is a concurrency-safe slot for the control-plane store, used to
// break the decentralized bootstrap cycle (issue 0006c): the NATS authenticator
// must be built BEFORE the embedded server starts, but the JetStream KV store can
// only be opened AFTER NATS is up (it needs a JetStream client). The authenticator
// therefore consults the holder instead of a concrete store.
//
// Fail-closed by construction: until the store is set, IsAuthorized denies and
// SubjectACL errors, so any client connecting in the startup window is rejected.
// The only connection expected in that window is membershipd's own internal
// service identity, which the authenticator recognizes by key and lets through
// without consulting the store at all. In the SQLite (default) path the store is
// set before StartServer, so the window does not exist and behavior is identical
// to the pre-0006c baseline.
type storeHolder struct {
mu sync.RWMutex
s membership.Store
}
func (h *storeHolder) set(s membership.Store) {
h.mu.Lock()
h.s = s
h.mu.Unlock()
}
func (h *storeHolder) get() membership.Store {
h.mu.RLock()
defer h.mu.RUnlock()
return h.s
}
// IsAuthorized reports whether signPubHex is an active bus user, denying while the
// store is not yet set (fail closed). It is the predicate the nkey authenticator
// uses for every connecting client.
func (h *storeHolder) IsAuthorized(signPubHex string) bool {
s := h.get()
if s == nil {
return false
}
return s.IsAuthorized(signPubHex)
}
// subjectACL derives the per-subject permissions for signPubHex via the live
// store, erroring (so the caller fails closed and denies the connection) while the
// store is not yet set.
func (h *storeHolder) subjectACL(signPubHex string) ([]string, error) {
s := h.get()
if s == nil {
return nil, fmt.Errorf("control-plane store not ready")
}
return membership.SubjectACLFor(s)(signPubHex)
}
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"encoding/hex"
"path/filepath"
"testing"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/membership"
)
// TestStoreHolderFailClosed: an empty holder denies everything (the bootstrap
// window before the store is set), and starts serving once a store is published.
func TestStoreHolderFailClosed(t *testing.T) {
h := &storeHolder{}
// Empty: deny + error (fail closed).
if h.IsAuthorized("anything") {
t.Fatalf("empty holder must deny IsAuthorized")
}
if _, err := h.subjectACL("anything"); err == nil {
t.Fatalf("empty holder must error from subjectACL (fail closed)")
}
// After set: serves from the real store.
store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
id, err := cs.GenerateIdentity()
if err != nil {
t.Fatalf("identity: %v", err)
}
pub := hex.EncodeToString(id.SignPub)
if err := store.AddUser(pub, "alice", membership.RoleMember); err != nil {
t.Fatalf("add user: %v", err)
}
h.set(store)
if !h.IsAuthorized(pub) {
t.Fatalf("after set, an active user must be authorized")
}
if _, err := h.subjectACL(pub); err != nil {
t.Fatalf("after set, subjectACL must succeed: %v", err)
}
if h.IsAuthorized("deadbeef") {
t.Fatalf("a non-user must not be authorized")
}
}
+83 -16
View File
@@ -1,7 +1,7 @@
package main package main
import ( import (
"encoding/hex" "errors"
"flag" "flag"
"fmt" "fmt"
"os" "os"
@@ -50,13 +50,26 @@ commands:
list List all registered users list List all registered users
revoke Revoke a user (denies access on both planes immediately) revoke Revoke a user (denies access on both planes immediately)
store backends (--store):
sqlite local SQLite database (default; seeds the first admin offline)
kv the RUNNING cluster's replicated JetStream KV allowlist, via the
privileged internal connection — add users with the cluster live,
no stop-seed-restart needed (run over loopback/SSH on a node)
examples: examples:
membershipd user add --handle alice --sign-pub <64-hex> --role admin membershipd user add --handle alice --sign-pub <64-hex> --role admin
membershipd user list membershipd user add --store kv --handle bob --sign-pub <64-hex> --role member
membershipd user list --store kv
membershipd user revoke <64-hex> membershipd user revoke <64-hex>
common flags: common flags:
--db <path> SQLite database path (default ./local_files/unibus.db) --db <path> SQLite database path (--store sqlite; default ./local_files/unibus.db)
--store kv flags (defaults assume an on-node invocation):
--nats-url <url> cluster NATS (default nats://127.0.0.1:4250)
--internal-id-file <path> persisted internal service identity (default /opt/unibus/secrets/internal.id)
--ca <path> CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt)
--kv-replicas <n> KV replication factor, match the cluster (default 3)
`) `)
} }
@@ -76,16 +89,56 @@ func openStore(path string) membership.Store {
// validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in // validateSignPubHex ensures the key is exactly a 32-byte Ed25519 public key in
// hex (64 hex chars). Catching this here turns a silent "authorized nobody" into // hex (64 hex chars). Catching this here turns a silent "authorized nobody" into
// an explicit error at seed time. // an explicit error at seed time. It delegates to membership.ValidateSignPubHex
// so the CLI and the HTTP user-management handlers share one rule.
func validateSignPubHex(signPub string) error { func validateSignPubHex(signPub string) error {
b, err := hex.DecodeString(signPub) return membership.ValidateSignPubHex(signPub)
if err != nil { }
return fmt.Errorf("sign-pub is not valid hex: %w", err)
// kvFlags holds the connection flags shared by the --store kv path of the user
// subcommands. registerKVFlags wires them onto a flag set so add and list expose
// an identical interface.
type kvFlags struct {
store *string
natsURL *string
internalID *string
ca *string
replicas *int
}
func registerKVFlags(fs *flag.FlagSet) kvFlags {
return kvFlags{
store: fs.String("store", "sqlite", "user store backend: sqlite (local DB) | kv (the live cluster's replicated allowlist)"),
natsURL: fs.String("nats-url", defaultClusterNatsURL, "cluster NATS url for --store kv"),
internalID: fs.String("internal-id-file", defaultInternalIDFile, "persisted internal service identity for --store kv"),
ca: fs.String("ca", defaultClusterCAFile, "CA cert pinning TLS on the --store kv NATS connection"),
replicas: fs.Int("kv-replicas", 3, "KV replication factor for --store kv (match the cluster)"),
} }
if len(b) != 32 { }
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
// resolveStore returns the membership store for the chosen backend plus a cleanup
// func. For --store kv it opens the privileged connection to the live cluster; for
// sqlite it opens the local file. It exits the process with a clear message on any
// failure (a dead NATS, a missing identity file), so a broken --store kv add fails
// loudly instead of silently — Error case of the GAP A DoD. The returned *kvConn
// is non-nil only for the kv backend (so the caller can report replication).
func resolveStore(cmd string, kf kvFlags, dbPath string) (membership.Store, *kvConn, func()) {
switch *kf.store {
case "sqlite":
store := openStore(dbPath)
return store, nil, func() { store.Close() }
case "kv":
kv, err := connectKVStore(*kf.natsURL, *kf.internalID, *kf.ca, *kf.replicas)
if err != nil {
fmt.Fprintf(os.Stderr, "membershipd %s: --store kv: %v\n", cmd, err)
os.Exit(1)
}
return kv.store, kv, kv.Close
default:
fmt.Fprintf(os.Stderr, "membershipd %s: --store must be \"sqlite\" or \"kv\", got %q\n", cmd, *kf.store)
os.Exit(2)
return nil, nil, func() {}
} }
return nil
} }
func userAdd(args []string) { func userAdd(args []string) {
@@ -94,6 +147,7 @@ func userAdd(args []string) {
signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)") signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)")
role := fs.String("role", membership.RoleMember, "role: admin or member") role := fs.String("role", membership.RoleMember, "role: admin or member")
dbPath := fs.String("db", defaultDBPath, "SQLite database path") dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
_ = fs.Parse(args) _ = fs.Parse(args)
if *handle == "" || *signPub == "" { if *handle == "" || *signPub == "" {
@@ -105,23 +159,35 @@ func userAdd(args []string) {
os.Exit(2) os.Exit(2)
} }
store := openStore(*dbPath) store, kv, closeStore := resolveStore("user add", kf, *dbPath)
defer store.Close() defer closeStore()
if err := store.AddUser(*signPub, *handle, *role); err != nil { if err := store.AddUser(*signPub, *handle, *role); err != nil {
if errors.Is(err, membership.ErrUserExists) {
// Idempotency contract (GAP A): re-adding the same key is an EXPLICIT,
// non-destructive error — the existing row is left untouched (no silent
// upsert that could flip a role or clobber status, which would corrupt the
// allowlist). To replace a user, `user revoke <key>` then add again.
fmt.Fprintf(os.Stderr, "membershipd user add: user %s already registered (unchanged); revoke it first to replace\n", *signPub)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err) fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err)
os.Exit(1) os.Exit(1)
} }
fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role) fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role)
if kv != nil {
reportKVReplication(kv.js)
}
} }
func userList(args []string) { func userList(args []string) {
fs := flag.NewFlagSet("user list", flag.ExitOnError) fs := flag.NewFlagSet("user list", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path") dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
_ = fs.Parse(args) _ = fs.Parse(args)
store := openStore(*dbPath) store, _, closeStore := resolveStore("user list", kf, *dbPath)
defer store.Close() defer closeStore()
users, err := store.ListUsers() users, err := store.ListUsers()
if err != nil { if err != nil {
@@ -143,6 +209,7 @@ func userList(args []string) {
func userRevoke(args []string) { func userRevoke(args []string) {
fs := flag.NewFlagSet("user revoke", flag.ExitOnError) fs := flag.NewFlagSet("user revoke", flag.ExitOnError)
dbPath := fs.String("db", defaultDBPath, "SQLite database path") dbPath := fs.String("db", defaultDBPath, "SQLite database path")
kf := registerKVFlags(fs)
// Go's flag package stops at the first non-flag argument, so `revoke <key> // Go's flag package stops at the first non-flag argument, so `revoke <key>
// --db path` would otherwise leave --db unparsed. Pull a leading positional // --db path` would otherwise leave --db unparsed. Pull a leading positional
@@ -167,8 +234,8 @@ func userRevoke(args []string) {
os.Exit(2) os.Exit(2)
} }
store := openStore(*dbPath) store, _, closeStore := resolveStore("user revoke", kf, *dbPath)
defer store.Close() defer closeStore()
if err := store.RevokeUser(signPub); err != nil { if err := store.RevokeUser(signPub); err != nil {
fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err) fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err)
+151
View File
@@ -0,0 +1,151 @@
package main
import (
"context"
"fmt"
"os"
"time"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// users_kv.go is the `--store kv` half of the user administration CLI (issue 0011
// gaps, GAP A): adding and listing bus users directly against the RUNNING
// cluster's replicated JetStream KV allowlist, with no need to stop the cluster,
// seed a standalone node, and restart (the procedure the 0011 deploy required).
//
// The mechanism is the cluster's own privileged internal connection. Under
// enforce every bus user is confined by the per-subject ACL to the JetStream API
// of its own rooms, so no ordinary identity may touch the control-plane buckets
// (KV_UNIBUS_*). The ONLY identity the authenticator grants full JetStream
// permissions is membershipd's internal service identity. By persisting that
// identity to a file (membershipd --internal-id-file) the same key becomes
// available to this CLI, which presents it as its NATS nkey and is therefore
// recognized as the privileged internal client and allowed to read/write the KV.
//
// Intended invocation is over loopback on a cluster node (SSH): the data-plane
// TLS certificate's SAN covers 127.0.0.1/localhost and the internal identity file
// lives 0600 next to the node's TLS keys. Using the file requires root on the
// node, which already implies full control of that node — so co-locating it adds
// no practical exposure beyond what the TLS server key and cluster password
// already represent.
// defaultClusterNatsURL is the node-local NATS listener. The CLI is meant to run
// on a cluster node over SSH, talking to that node's own embedded server.
const defaultClusterNatsURL = "nats://127.0.0.1:4250"
// Deploy-default paths for the privileged identity and the data-plane CA, so an
// on-node invocation needs only --handle/--sign-pub/--role. Override for other
// layouts.
const (
defaultInternalIDFile = "/opt/unibus/secrets/internal.id"
defaultClusterCAFile = "/opt/unibus/tls/ca.crt"
)
// kvConn bundles the privileged NATS connection to a live cluster and the
// KV-backed control-plane store opened over it. Close releases both.
type kvConn struct {
nc *nats.Conn
js jetstream.JetStream
store membership.Store
}
func (k *kvConn) Close() {
if k == nil {
return
}
if k.store != nil {
_ = k.store.Close()
}
if k.nc != nil {
k.nc.Close()
}
}
// connectKVStore opens the privileged internal connection to the cluster's NATS
// and the JetStream KV control-plane store on top of it. internalIDFile is the
// membershipd-persisted internal service identity whose nkey the authenticator
// grants full permissions; caPath pins the data-plane TLS (empty only for a
// non-TLS dev cluster). A non-loopback target without --ca is refused, mirroring
// migrate-to-kv (audit 0008 N6): the allowlist write must not travel in cleartext.
func connectKVStore(natsURL, internalIDFile, caPath string, replicas int) (*kvConn, error) {
if internalIDFile == "" {
return nil, fmt.Errorf("--internal-id-file is required for --store kv (the privileged identity membershipd persists with --internal-id-file)")
}
// Confidentiality guard: a remote NATS without TLS would expose the allowlist
// (handles/roles/sign-pubs) and the privileged nkey handshake in cleartext.
if !isLoopbackURL(natsURL) && caPath == "" {
return nil, fmt.Errorf("refusing to connect to remote %q without --ca: the allowlist write would travel in cleartext — pin TLS with --ca, or run over a loopback --nats-url on a node", natsURL)
}
id, err := client.LoadIdentity(internalIDFile)
if err != nil {
return nil, fmt.Errorf("load internal identity: %w", err)
}
nkeyPub, nkeySign, err := busauth.ClientNkey(id.SignPriv)
if err != nil {
return nil, fmt.Errorf("derive nkey from internal identity: %w", err)
}
opts := []nats.Option{
nats.Name("membershipd-user-cli"),
nats.Nkey(nkeyPub, nkeySign),
}
if caPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
if err != nil {
return nil, fmt.Errorf("load CA %q: %w", caPath, err)
}
opts = append(opts, nats.Secure(tlsCfg))
}
nc, err := nats.Connect(natsURL, opts...)
if err != nil {
return nil, fmt.Errorf("connect cluster NATS %q: %w", natsURL, err)
}
js, err := jetstream.New(nc)
if err != nil {
nc.Close()
return nil, fmt.Errorf("jetstream: %w", err)
}
store, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: replicas})
if err != nil {
nc.Close()
return nil, fmt.Errorf("open KV control-plane store: %w", err)
}
return &kvConn{nc: nc, js: js, store: store}, nil
}
// reportKVReplication prints the replication status of the allowlist bucket
// stream (KV_UNIBUS_users) right after a write, so the operator sees the add
// landed on a quorum and replicated to the followers — executable evidence that
// the live-cluster add is HA, not single-node. Best-effort: a read failure is a
// note, not an error (the write itself already succeeded).
func reportKVReplication(js jetstream.JetStream) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
st, err := js.Stream(ctx, "KV_UNIBUS_users")
if err != nil {
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
return
}
info, err := st.Info(ctx)
if err != nil {
fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err)
return
}
if info.Cluster == nil {
fmt.Printf("KV_UNIBUS_users: standalone (R1, no cluster replication); msgs=%d\n", info.State.Msgs)
return
}
current := 0
for _, r := range info.Cluster.Replicas {
if r.Current {
current++
}
}
fmt.Printf("KV_UNIBUS_users: leader=%s followers_current=%d/%d msgs=%d\n",
info.Cluster.Leader, current, len(info.Cluster.Replicas), info.State.Msgs)
}
+40
View File
@@ -0,0 +1,40 @@
package main
import (
"fmt"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go/jetstream"
)
// wireReplicatedNonces applies the cluster anti-replay policy to srv. It is the
// single piece of wiring the binary uses to decide whether a node must share its
// nonce store, extracted so a regression test exercises the EXACT decision the
// running binary makes (issue 0006a, audit 0008 N3).
//
// Policy:
// - A clustered node (clustered == true) MUST use the shared JetStream KV nonce
// bucket. Every node sees the same bucket, so a request accepted on one node
// cannot be replayed to another whose per-process cache never saw the nonce.
// A missing JetStream context, or a failure to create the bucket, is a FATAL
// configuration error returned to the caller — a clustered node running with a
// per-process nonce cache is precisely the replay hole the audit flagged, so
// it must refuse to start rather than serve insecurely.
// - A standalone node (clustered == false) keeps the in-memory cache that
// NewServer installed: there is no second node to replay to, so the shared
// bucket would only add a JetStream dependency for no security gain.
//
// replicas is the nonce bucket's replication factor (R1..R3). Returns nil when no
// action is required (standalone).
func wireReplicatedNonces(srv *membership.Server, js jetstream.JetStream, clustered bool, replicas int) error {
if !clustered {
return nil // standalone: the in-memory nonce cache is sufficient and safe
}
if js == nil {
return fmt.Errorf("clustered node requires JetStream for the shared nonce bucket, but none is available")
}
if err := srv.UseReplicatedNonces(js, replicas); err != nil {
return fmt.Errorf("replicated nonces: %w", err)
}
return nil
}
+246
View File
@@ -0,0 +1,246 @@
package main
import (
"encoding/hex"
"fmt"
"strings"
"sync"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// gateway is the live web gateway: it owns the operator's identity and a single
// connected unibus client, and turns the bus's crypto-bearing API into the plain
// REST/SSE surface the browser consumes. The browser never signs, never speaks
// NATS, and never sees a private key — the gateway is the legitimate room member
// that seals/opens payloads on the browser's behalf.
//
// TRUST MODEL: content stays end-to-end encrypted on the wire. The gateway can
// read plaintext because it acts AS the operator's client — a real member of
// each room, holding the room key K like any peer. It is the same trust a native
// desktop client has. In the wallet phase (per-browser WebCrypto identity) the
// decryption can move into the browser; today, for the single-operator MVP, the
// gateway decrypts server-side and pushes cleartext over a loopback/authenticated
// SSE channel.
type gateway struct {
id cs.Identity
endpoint string
cli *client.Client
refreshACL bool // call RefreshSession after a membership change (needed under a per-subject ACL bus)
mu sync.Mutex
hubs map[string]*roomHub // roomID -> live fan-out of decrypted frames to SSE clients
}
// gatewayConfig wires a live gateway.
type gatewayConfig struct {
Identity cs.Identity
NatsURL string
CtrlURL string
CtrlURLs []string
NatsURLs []string
CAPath string // bus CA; empty => plaintext dev connection (matches a loopback membershipd)
}
// newGateway connects the unibus client with the operator identity following the
// same posture seam every peer uses: a non-empty CA path means TLS + nkey, empty
// means plaintext dev. When a CA is configured the bus is assumed to enforce a
// per-subject ACL, so membership changes trigger a session refresh.
func newGateway(cfg gatewayConfig) (*gateway, error) {
opts := client.Options{
CtrlURLs: cfg.CtrlURLs,
NatsServers: cfg.NatsURLs,
}
if cfg.CAPath != "" {
tlsCfg, err := busauth.LoadCATLSConfig(cfg.CAPath)
if err != nil {
return nil, fmt.Errorf("webgw: load bus CA %q: %w", cfg.CAPath, err)
}
opts.UseNkey = true
opts.TLS = tlsCfg
opts.CtrlTLS = tlsCfg
}
cli, err := client.NewWithOptions(cfg.NatsURL, cfg.CtrlURL, cfg.Identity, opts)
if err != nil {
return nil, fmt.Errorf("webgw: connect bus client: %w", err)
}
return &gateway{
id: cfg.Identity,
endpoint: frame.EndpointID(cfg.Identity.SignPub),
cli: cli,
refreshACL: cfg.CAPath != "",
hubs: map[string]*roomHub{},
}, nil
}
// Close stops every hub and releases the bus client connection.
func (g *gateway) Close() error {
g.mu.Lock()
for _, h := range g.hubs {
h.stop()
}
g.hubs = map[string]*roomHub{}
g.mu.Unlock()
if g.cli != nil {
return g.cli.Close()
}
return nil
}
// ---- wire types (browser-facing JSON) ------------------------------------
// meInfo is what GET /api/me returns: the operator identity the gateway acts as.
type meInfo struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
}
// roomWire is the browser view of a room. It deliberately omits messages: those
// stream over SSE (GET /api/rooms/{id}/stream), not in the room list.
type roomWire struct {
ID string `json:"id"`
Subject string `json:"subject"`
Name string `json:"name"`
Epoch int `json:"epoch"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
Role string `json:"role"`
}
// createRoomReq is the POST /api/rooms body. Encrypt/Persist/SignMsgs are
// pointers so an omitted field falls back to the chat default rather than to the
// Go zero value (false). The common case — the browser sending only {subject,
// encrypted} — maps encrypted onto all three (the Matrix-like chat policy).
type createRoomReq struct {
Subject string `json:"subject"`
Encrypted *bool `json:"encrypted,omitempty"`
Encrypt *bool `json:"encrypt,omitempty"`
Persist *bool `json:"persist,omitempty"`
SignMsgs *bool `json:"sign_msgs,omitempty"`
}
// policy resolves the requested policy. A bare {subject} defaults to the
// Matrix-like chat room (encrypted + persisted + signed) so a created room keeps
// durable, end-to-end-encrypted, authored history. Callers can override any leg.
func (r createRoomReq) policy() room.Policy {
enc, per, sig := true, true, true
if r.Encrypted != nil {
enc, per, sig = *r.Encrypted, *r.Encrypted, *r.Encrypted
}
if r.Encrypt != nil {
enc = *r.Encrypt
}
if r.Persist != nil {
per = *r.Persist
}
if r.SignMsgs != nil {
sig = *r.SignMsgs
}
return room.Policy{Encrypt: enc, Persist: per, SignMsgs: sig}
}
// sendReq is the POST /api/rooms/{id}/send body.
type sendReq struct {
Body string `json:"body"`
}
// msgWire is one decrypted message pushed over SSE.
type msgWire struct {
ID string `json:"id"`
Sender string `json:"sender"`
Body string `json:"body"`
TS int64 `json:"ts"` // epoch ms (decoded from the frame's ULID id)
Mine bool `json:"mine"`
}
// ---- operations -----------------------------------------------------------
func (g *gateway) me() meInfo {
return meInfo{Endpoint: g.endpoint, SignPub: hex.EncodeToString(g.id.SignPub)}
}
// subjectName derives a short, human-friendly room name from its bus subject by
// dropping the leading namespace segment (room., test., proc., agent.). It is a
// display nicety only; the canonical identity stays the subject/room id.
func subjectName(subject string) string {
for _, p := range []string{"room.", "test.", "proc.", "agent.", "rpc."} {
if strings.HasPrefix(subject, p) {
return strings.TrimPrefix(subject, p)
}
}
return subject
}
func (g *gateway) listRooms() ([]roomWire, error) {
rooms, err := g.cli.ListMyRooms()
if err != nil {
return nil, err
}
out := make([]roomWire, 0, len(rooms))
for _, rm := range rooms {
out = append(out, roomWire{
ID: rm.RoomID,
Subject: rm.Subject,
Name: subjectName(rm.Subject),
Epoch: rm.Epoch,
Encrypt: rm.Policy.Encrypt,
Persist: rm.Policy.Persist,
SignMsgs: rm.Policy.SignMsgs,
Role: rm.Role,
})
}
return out, nil
}
func (g *gateway) createRoom(req createRoomReq) (roomWire, error) {
subject := strings.TrimSpace(req.Subject)
if subject == "" {
return roomWire{}, fmt.Errorf("webgw: subject required")
}
p := req.policy()
roomID, err := g.cli.CreateRoom(subject, p)
if err != nil {
return roomWire{}, err
}
// Under a per-subject ACL the operator's frozen NATS permissions do not yet
// cover the new room's subject; refresh so subsequent data-plane use works. On
// a plaintext/non-ACL dev bus this is unnecessary and would needlessly drop any
// live SSE subscriptions, so it is gated on the secured posture.
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return roomWire{
ID: roomID,
Subject: subject,
Name: subjectName(subject),
Epoch: 1,
Encrypt: p.Encrypt,
Persist: p.Persist,
SignMsgs: p.SignMsgs,
Role: "owner",
}, nil
}
// join resolves room metadata and (for encrypted rooms) fetches the room key so
// the gateway can later open payloads. Idempotent.
func (g *gateway) join(roomID string) error {
if err := g.cli.Join(roomID); err != nil {
return err
}
if g.refreshACL {
_ = g.cli.RefreshSession()
}
return nil
}
// send publishes plaintext to a room. The unibus client seals it with the room
// key (encrypted rooms) and signs it (signed rooms) before it leaves the process.
func (g *gateway) send(roomID, body string) error {
return g.cli.Publish(roomID, []byte(body))
}
+140
View File
@@ -0,0 +1,140 @@
package main
import (
"sync"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/oklog/ulid/v2"
)
// roomHub multiplexes ONE unibus room subscription to MANY SSE clients. The
// unibus client derives a per-(room, endpoint) durable consumer name, so a
// second Subscribe for the same room from the same operator would contend for
// the same durable (load-balanced delivery) rather than each browser receiving
// every message. The hub holds a single subscription per room and fans each
// decrypted frame out to every connected browser, which also means the gateway
// opens at most one bus subscription per room regardless of how many tabs watch
// it.
type roomHub struct {
roomID string
myEndpoint string
sub *client.Sub
mu sync.Mutex
clients map[chan msgWire]struct{}
}
// frameTS decodes the millisecond timestamp embedded in a frame's ULID id. A
// malformed id (should not happen for bus-produced frames) yields 0, which the
// browser renders without crashing.
func frameTS(msgID string) int64 {
id, err := ulid.Parse(msgID)
if err != nil {
return 0
}
return int64(id.Time())
}
// newRoomHub opens the single bus subscription for roomID and starts fanning
// decrypted frames out to registered clients. The room must already be joined
// (so the gateway holds the room key) before this is called.
func newRoomHub(cli *client.Client, roomID, myEndpoint string) (*roomHub, error) {
h := &roomHub{
roomID: roomID,
myEndpoint: myEndpoint,
clients: map[chan msgWire]struct{}{},
}
sub, err := cli.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
m := msgWire{
ID: f.MsgID,
Sender: f.Sender,
Body: string(plaintext),
TS: frameTS(f.MsgID),
Mine: f.Sender == myEndpoint,
}
h.broadcast(m)
})
if err != nil {
return nil, err
}
h.sub = sub
return h, nil
}
// broadcast delivers a message to every registered client without blocking the
// NATS delivery goroutine: a client whose buffer is full (a stalled browser)
// drops this frame rather than stalling the whole room.
func (h *roomHub) broadcast(m msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.clients {
select {
case ch <- m:
default:
}
}
}
// add registers a new SSE client channel.
func (h *roomHub) add(ch chan msgWire) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[ch] = struct{}{}
}
// stop unsubscribes from the bus. Local delivery ends; for a persisted room the
// durable consumer's ack position stays on the server, so a later subscription
// with the same operator resumes from where it left off.
func (h *roomHub) stop() {
if h.sub != nil {
_ = h.sub.Unsubscribe()
}
}
// openStream joins the room (idempotent; fetches the room key for encrypted
// rooms), attaches an SSE client to the room's hub (creating it on first watcher),
// and returns the client's message channel plus a cleanup func. The cleanup
// detaches the client and, when it was the last watcher, tears down the room's
// single bus subscription.
func (g *gateway) openStream(roomID string) (chan msgWire, func(), error) {
if err := g.join(roomID); err != nil {
return nil, nil, err
}
g.mu.Lock()
h := g.hubs[roomID]
if h == nil {
var err error
h, err = newRoomHub(g.cli, roomID, g.endpoint)
if err != nil {
g.mu.Unlock()
return nil, nil, err
}
g.hubs[roomID] = h
}
g.mu.Unlock()
// Buffer so a brief render hitch in the browser does not drop live frames; a
// sustained stall still drops (broadcast is non-blocking) rather than wedging
// the room.
ch := make(chan msgWire, 64)
h.add(ch)
// cleanup takes g.mu before h.mu (the single, consistent lock order) so a
// concurrent openStream that re-creates the hub cannot race the teardown.
cleanup := func() {
g.mu.Lock()
defer g.mu.Unlock()
h.mu.Lock()
delete(h.clients, ch)
empty := len(h.clients) == 0
h.mu.Unlock()
if empty {
if cur := g.hubs[roomID]; cur == h {
delete(g.hubs, roomID)
h.stop()
}
}
}
return ch, cleanup, nil
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"os"
"os/exec"
cs "fn-registry/functions/cybersecurity"
)
// identityJSON mirrors the on-disk / pass-stored identity format shared across
// the unibus tooling: the four keypair halves, each std-base64. It is the SAME
// shape the bus client persists (pkg/client identity file) and the operator's
// `pass` entry unibus/operator-identity, so the web gateway loads the operator's
// identity without a divergent serialization. Kept in lockstep with
// unibus_admin/internal/admin/identity.go.
type identityJSON struct {
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// decodeIdentity turns the JSON identity bytes into a cs.Identity. The private
// halves stay only in memory; this never writes them anywhere.
func decodeIdentity(raw []byte) (cs.Identity, error) {
var f identityJSON
if err := json.Unmarshal(raw, &f); err != nil {
return cs.Identity{}, fmt.Errorf("webgw: parse identity json: %w", err)
}
dec := base64.StdEncoding.DecodeString
signPub, err := dec(f.SignPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_pub: %w", err)
}
signPriv, err := dec(f.SignPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode sign_priv: %w", err)
}
kexPub, err := dec(f.KexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_pub: %w", err)
}
kexPriv, err := dec(f.KexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: decode kex_priv: %w", err)
}
if len(signPub) != 32 || len(signPriv) != 64 || len(kexPub) != 32 || len(kexPriv) != 32 {
return cs.Identity{}, fmt.Errorf("webgw: identity has wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d)",
len(signPub), len(signPriv), len(kexPub), len(kexPriv))
}
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
}
// loadIdentityFromFile reads a 0600 identity JSON file (the same format the bus
// client writes) and decodes it. Used on a deploy host where `pass` is not
// available and the operator identity is delivered as a protected file.
func loadIdentityFromFile(path string) (cs.Identity, error) {
raw, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: read identity file %q: %w", path, err)
}
return decodeIdentity(raw)
}
// loadIdentityFromPass shells out to `pass show <entry>` and decodes the JSON
// identity it returns. The secret is held only in memory; this process never
// writes it to disk or argv. Used in local operator workflows where the GNU
// password store holds unibus/operator-identity.
func loadIdentityFromPass(entry string) (cs.Identity, error) {
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return cs.Identity{}, fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
return decodeIdentity(out)
}
// loadPassValue returns the first line of a `pass show <entry>` for non-identity
// secrets (e.g. the unlock passphrase). Empty entry yields an empty string and
// no error, so callers can treat "no pass entry configured" as "not set".
func loadPassValue(entry string) (string, error) {
if entry == "" {
return "", nil
}
out, err := exec.Command("pass", "show", entry).Output()
if err != nil {
return "", fmt.Errorf("webgw: pass show %q: %w", entry, err)
}
s := string(out)
for i := 0; i < len(s); i++ {
if s[i] == '\n' || s[i] == '\r' {
return s[:i], nil
}
}
return s, nil
}
+199
View File
@@ -0,0 +1,199 @@
// Command webgw is the web gateway for the unibus chat SPA. It is a single Go
// binary that holds the operator's bus identity, connects to the bus as a real
// authenticated peer (pkg/client), and exposes a small REST + SSE API the
// browser consumes. The browser never signs, never speaks NATS, and never sees a
// private key: it authenticates to the gateway with a passphrase and thereafter
// holds only an opaque session cookie.
//
// TRUST MODEL (MVP, single operator): room content stays end-to-end encrypted on
// the bus. The gateway can read plaintext because it acts AS the operator's
// client — a legitimate member of each room holding the room key. Decryption
// happens server-side in this process; cleartext then crosses an authenticated
// (loopback or TLS-fronted) SSE channel to the browser. The wallet phase (issue:
// per-browser WebCrypto identity) can move decryption into the browser; see the
// report for the FASE 2 plan.
//
// # local dev against a loopback membershipd (plaintext), operator from pass:
// webgw --identity-pass unibus/operator-identity \
// --ctrl-url http://127.0.0.1:8470 --nats-url nats://127.0.0.1:4250
//
// # secured cluster (TLS + nkey on both planes), identity from a 0600 file:
// webgw --ca ca.crt --identity-file operator.id \
// --ctrl-url https://node-a:8470 --nats-url nats://node-a:4250 \
// --ctrl-urls https://node-b:8470,https://node-c:8470 \
// --nats-urls nats://node-b:4250,nats://node-c:4250
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
cs "fn-registry/functions/cybersecurity"
)
func main() {
var (
bind = flag.String("bind", "127.0.0.1", "interface to bind the gateway HTTP server to (loopback by default)")
port = flag.String("port", "8481", "gateway HTTP port")
ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "primary unibus control-plane base URL")
ctrlURLs = flag.String("ctrl-urls", "", "comma-separated ADDITIONAL control-plane base URLs (cluster failover)")
natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "primary NATS URL")
natsURLs = flag.String("nats-urls", "", "comma-separated ADDITIONAL NATS seed URLs (cluster failover)")
caPath = flag.String("ca", "", "bus CA cert path; set to talk TLS+nkey to a secured bus (empty = plaintext dev)")
identityFile = flag.String("identity-file", "", "path to the operator identity JSON file (0600). Mutually exclusive with --identity-pass")
identityPass = flag.String("identity-pass", "", "pass(1) entry holding the operator identity JSON, e.g. unibus/operator-identity")
unlockPass = flag.String("unlock-pass", "", "literal passphrase the browser must send to unlock a LEGACY operator session (dev). Prefer --unlock-pass-entry")
unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the operator unlock passphrase (used when --unlock-pass is empty)")
registerURL = flag.String("register-url", "", "bus POST /register URL for wallet onboarding. Empty = derive from --ctrl-url (<ctrl-url>/register)")
mockTokens = flag.String("mock-tokens", "", "DEV ONLY: comma-separated one-shot invite tokens for local testing, 'token=handle:role'. Empty in production (real invites come from the bus). Example: demo=demo:member")
webDir = flag.String("web-dir", "", "OPTIONAL path to the built SPA (web/dist) to serve. Empty = API only (use vite dev server)")
)
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[webgw] ")
id, err := loadIdentity(*identityFile, *identityPass)
if err != nil {
log.Fatalf("%v", err)
}
unlock := *unlockPass
if unlock == "" {
unlock, err = loadPassValue(*unlockEntry)
if err != nil {
log.Fatalf("resolve unlock passphrase: %v", err)
}
}
if unlock == "" {
log.Fatalf("an unlock passphrase is required: set --unlock-pass or a non-empty --unlock-pass-entry (default unibus/admin-panel-password)")
}
resolvedWebDir := resolveWebDir(*webDir)
// busTemplate is the connection config every bus client uses. The operator
// gateway uses it as-is; each wallet session clones it and overrides Identity
// with the logged-in user's keypair.
busTemplate := gatewayConfig{
Identity: id,
NatsURL: *natsURL,
CtrlURL: *ctrlURL,
CtrlURLs: splitCSV(*ctrlURLs),
NatsURLs: splitCSV(*natsURLs),
CAPath: *caPath,
}
gw, err := newGateway(busTemplate)
if err != nil {
log.Fatalf("%v", err)
}
defer gw.Close()
// Wallet onboarding backend: POST /api/register targets the bus's /register
// (added by the user-accounts work). When --register-url is empty we derive it
// from --ctrl-url; --mock-tokens supplies one-shot invites for local testing
// before that endpoint is deployed.
regURL := *registerURL
if regURL == "" {
regURL = strings.TrimRight(*ctrlURL, "/") + "/register"
}
registrar := newRegistrar(regURL, *mockTokens)
log.Printf("operator endpoint: %s", gw.endpoint)
log.Printf("control plane: %s (+%d failover)", *ctrlURL, len(splitCSV(*ctrlURLs)))
tls := "OFF (plaintext dev)"
if *caPath != "" {
tls = "ON (CA " + *caPath + ")"
}
log.Printf("bus TLS+nkey: %s", tls)
if resolvedWebDir != "" {
log.Printf("serving SPA from: %s", resolvedWebDir)
} else {
log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy")
}
log.Printf("wallet register: %s (mock tokens: %d)", regURL, mockTokenCount(*mockTokens))
srv := newServer(gw, busTemplate, registrar, unlock, resolvedWebDir)
addr := *bind + ":" + *port
httpSrv := &http.Server{
Addr: addr,
Handler: srv,
// No global write timeout: SSE streams are long-lived. Header timeout still
// bounds slowloris on the request line/headers.
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
log.Printf("web gateway: http://%s", addr)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("http server: %v", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(ctx)
log.Printf("bye")
}
// loadIdentity resolves the operator identity from exactly one of --identity-file
// or --identity-pass.
func loadIdentity(file, passEntry string) (cs.Identity, error) {
switch {
case file != "" && passEntry != "":
return cs.Identity{}, errFlag("set only one of --identity-file or --identity-pass")
case file != "":
return loadIdentityFromFile(file)
case passEntry != "":
return loadIdentityFromPass(passEntry)
default:
return cs.Identity{}, errFlag("an identity is required: pass --identity-file <path> or --identity-pass <entry>")
}
}
// resolveWebDir validates the --web-dir flag. An empty flag means API-only. A
// non-empty dir is kept only if it actually holds an index.html, so a typo logs
// "API only" rather than serving 404s.
func resolveWebDir(dir string) string {
if dir == "" {
return ""
}
abs, err := filepath.Abs(dir)
if err != nil {
log.Printf("WARN --web-dir %q: %v; serving API only", dir, err)
return ""
}
if !statFile(filepath.Join(abs, "index.html")) {
log.Printf("WARN --web-dir %q has no index.html; serving API only", abs)
return ""
}
return abs
}
type flagErr string
func (e flagErr) Error() string { return string(e) }
func errFlag(s string) error { return flagErr("webgw: " + s) }
func splitCSV(s string) []string {
var out []string
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
// appears here — registration only publishes the public identity. The handle and
// role are NOT accepted from the client; they are fixed by the invite the token
// belongs to (no privilege escalation).
type registerReq struct {
Token string `json:"token"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// registerResp is what we return to the browser on success. The bus's /register
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
// gateway echoes the configured pair so the SPA can greet the new user.
type registerResp struct {
Handle string `json:"handle"`
Role string `json:"role"`
}
// registrar fulfils POST /api/register. It targets the bus's POST /register
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
// is rolled out, a built-in mock validates against a configured set of one-shot
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
// first; anything else is proxied to the real bus when --register-url is set.
type registrar struct {
mu sync.Mutex
registerURL string // bus POST /register; empty => mock-only
httpc *http.Client // for proxying to the bus
mockTokens map[string]*mockToken // configured one-shot invites for local testing
}
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
// handle+role and can be consumed exactly once.
type mockToken struct {
handle string
role string
used bool
}
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
// and configures the optional proxy target.
func newRegistrar(registerURL, mockSpec string) *registrar {
r := &registrar{
registerURL: strings.TrimSpace(registerURL),
httpc: &http.Client{Timeout: 10 * time.Second},
mockTokens: map[string]*mockToken{},
}
for _, part := range strings.Split(mockSpec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// tok=handle:role (role optional, defaults to member)
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
tok := strings.TrimSpace(part[:eq])
hr := strings.TrimSpace(part[eq+1:])
handle, role := hr, "member"
if c := strings.IndexByte(hr, ':'); c >= 0 {
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
}
if tok != "" && handle != "" {
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
}
}
return r
}
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
// startup log line).
func mockTokenCount(spec string) int {
n := 0
for _, part := range strings.Split(spec, ",") {
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
n++
}
}
return n
}
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
func validHexKey(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleRegister validates the keys and consumes the token. Order of resolution:
// 1. strict validation of the public keys (defends both mock and proxy paths);
// 2. mock token (one-shot) if configured;
// 3. proxy to the bus /register if --register-url is set;
// 4. otherwise reject with a clear error.
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if !decode(w, r, &req) {
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "token required")
return
}
if !validHexKey(req.SignPub) {
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
return
}
if !validHexKey(req.KexPub) {
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
return
}
reg := s.registrar
// 2) mock one-shot token.
reg.mu.Lock()
mt, isMock := reg.mockTokens[req.Token]
if isMock {
if mt.used {
reg.mu.Unlock()
writeErr(w, http.StatusConflict, "invite already used")
return
}
mt.used = true
handle, role := mt.handle, mt.role
reg.mu.Unlock()
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
return
}
reg.mu.Unlock()
// 3) proxy to the real bus /register when configured.
if reg.registerURL != "" {
s.proxyRegister(w, req)
return
}
// 4) no mock match, no proxy target.
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
}
// proxyRegister forwards the registration to the bus's POST /register. The bus
// validates the invite (existence, not-used, not-expired) and adds the public
// identity to the allowlist with the invite's handle+role. This is unsigned by
// design: the TOKEN authorizes the call, not an admin signature.
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
body, _ := json.Marshal(req)
resp, err := s.registrar.httpc.Post(
s.registrar.registerURL,
"application/json",
bytes.NewReader(body),
)
if err != nil {
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// On success, try to pass through the bus's handle/role if it returned them;
// otherwise a bare 201 is still success.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var rr registerResp
_ = json.Unmarshal(raw, &rr)
writeJSON(w, http.StatusCreated, rr)
return
}
// Forward the bus's error verbatim where possible.
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
}
writeErr(w, resp.StatusCode, msg)
}
+327
View File
@@ -0,0 +1,327 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// sessionCookie is the name of the gateway's session cookie. The browser sends
// it automatically on same-origin fetches AND on EventSource (SSE) connections —
// EventSource cannot set custom headers, so a cookie is the only way to
// authenticate the stream. It is HttpOnly so page JS can never read the token.
const sessionCookie = "unibus_session"
// server is the gateway's HTTP surface: a small REST/SSE API under /api plus an
// optional static file server for the built SPA.
//
// Two ways to get a session:
// - POST /api/session — the WALLET model. The browser hands its own bus
// identity (unlocked from its local encrypted key) and the gateway connects a
// dedicated bus client AS that user. Per-user, the primary path.
// - POST /api/login — the legacy operator passphrase. Binds the session to the
// single shared operator gateway. Kept for backward compatibility.
// - POST /api/register — the WALLET onboarding. Unauthenticated (the invite
// token authorizes), it consumes a token and publishes the new user's PUBLIC
// identity to the bus allowlist.
type server struct {
operatorGW *gateway // shared operator client (legacy passphrase login)
busTemplate gatewayConfig // bus connection config; Identity is overridden per user session
registrar *registrar // POST /api/register backend (mock + proxy)
unlock string // passphrase that unlocks an operator session (constant-time compare)
webDir string // optional path to the built SPA (web/dist); empty = API only
mux *http.ServeMux
sessions *sessionStore
}
func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server {
s := &server{
operatorGW: operatorGW,
busTemplate: busTemplate,
registrar: registrar,
unlock: unlock,
webDir: webDir,
mux: http.NewServeMux(),
sessions: newSessionStore(),
}
s.routes()
return s
}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
func (s *server) routes() {
// Liveness, unauthenticated (systemd / deploy smoke).
s.mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
// Unauthenticated onboarding / auth routes.
s.mux.HandleFunc("POST /api/register", s.handleRegister) // invite token authorizes
s.mux.HandleFunc("POST /api/session", s.handleSession) // wallet: per-user identity
s.mux.HandleFunc("POST /api/login", s.handleLogin) // legacy operator passphrase
// Session-gated routes.
s.mux.HandleFunc("POST /api/logout", s.auth(s.handleLogout))
s.mux.HandleFunc("GET /api/me", s.auth(s.handleMe))
s.mux.HandleFunc("GET /api/rooms", s.auth(s.handleListRooms))
s.mux.HandleFunc("POST /api/rooms", s.auth(s.handleCreateRoom))
s.mux.HandleFunc("POST /api/rooms/{id}/join", s.auth(s.handleJoin))
s.mux.HandleFunc("POST /api/rooms/{id}/send", s.auth(s.handleSend))
s.mux.HandleFunc("GET /api/rooms/{id}/stream", s.auth(s.handleStream))
// Everything else is the SPA (when --web-dir is set). Registered last.
if s.webDir != "" {
s.mux.Handle("/", s.spaHandler())
}
}
// meResp is the identity view returned by /api/session, /api/login and /api/me:
// the bus endpoint the session acts as, its signing public key, and the display
// handle.
type meResp struct {
Endpoint string `json:"endpoint"`
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
}
// ---- auth -----------------------------------------------------------------
// auth wraps a handler so it runs only with a valid session cookie, resolving the
// session (and thus the per-user gateway) it belongs to. A missing or unknown
// token yields 401, which the SPA treats as "show the login screen".
func (s *server) auth(next func(http.ResponseWriter, *http.Request, *session)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(sessionCookie)
if err != nil {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
sess, ok := s.sessions.get(c.Value)
if !ok {
writeErr(w, http.StatusUnauthorized, "not authenticated")
return
}
next(w, r, sess)
}
}
// handleLogin is the legacy operator passphrase login: it unlocks a session bound
// to the shared operator gateway. The wallet path (POST /api/session) is
// preferred; this remains for backward compatibility with the single-operator MVP.
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Passphrase string `json:"passphrase"`
}
if !decode(w, r, &req) {
return
}
// Constant-time compare so a wrong passphrase cannot be timed character by
// character. An empty configured passphrase never matches.
if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 {
writeErr(w, http.StatusUnauthorized, "wrong passphrase")
return
}
tok := newToken()
handle := s.operatorGW.endpoint
if len(handle) > 8 {
handle = handle[:8]
}
s.sessions.put(tok, &session{gw: s.operatorGW, owned: false, handle: handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: s.operatorGW.endpoint, SignPub: hex.EncodeToString(s.operatorGW.id.SignPub), Handle: handle})
}
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) {
if c, err := r.Cookie(sessionCookie); err == nil {
if sess, ok := s.sessions.drop(c.Value); ok && sess.owned && sess.gw != nil {
// Per-user session: tear down its bus client so the private key and the
// NATS connection do not outlive the session.
_ = sess.gw.Close()
}
}
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
writeJSON(w, http.StatusOK, map[string]string{"status": "logged_out"})
}
func (s *server) handleMe(w http.ResponseWriter, _ *http.Request, sess *session) {
writeJSON(w, http.StatusOK, meResp{
Endpoint: sess.gw.endpoint,
SignPub: hex.EncodeToString(sess.gw.id.SignPub),
Handle: sess.handle,
})
}
// ---- rooms ----------------------------------------------------------------
func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request, sess *session) {
rooms, err := sess.gw.listRooms()
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, rooms)
}
func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) {
var req createRoomReq
if !decode(w, r, &req) {
return
}
rv, err := sess.gw.createRoom(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusCreated, rv)
}
func (s *server) handleJoin(w http.ResponseWriter, r *http.Request, sess *session) {
if err := sess.gw.join(r.PathValue("id")); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "joined"})
}
func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) {
var req sendReq
if !decode(w, r, &req) {
return
}
if strings.TrimSpace(req.Body) == "" {
writeErr(w, http.StatusBadRequest, "body required")
return
}
if err := sess.gw.send(r.PathValue("id"), req.Body); err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "sent"})
}
// handleStream is the SSE endpoint: it joins the room, attaches to the session's
// fan-out hub, and streams each decrypted message as a `data:` event. For a
// persisted room the hub's underlying subscription delivers history first
// (scrollback) and then live messages; for an ephemeral room only live messages
// flow. The stream ends when the browser disconnects (ctx cancelled).
func (s *server) handleStream(w http.ResponseWriter, r *http.Request, sess *session) {
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
ch, cleanup, err := sess.gw.openStream(r.PathValue("id"))
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
defer cleanup()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx/caddy)
w.WriteHeader(http.StatusOK)
// An initial comment opens the stream immediately so the browser's
// EventSource fires `onopen` without waiting for the first message.
_, _ = w.Write([]byte(": connected\n\n"))
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
// Comment line keeps idle proxies from closing the connection.
if _, err := w.Write([]byte(": ping\n\n")); err != nil {
return
}
flusher.Flush()
case m := <-ch:
b, err := json.Marshal(m)
if err != nil {
continue
}
if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil {
return
}
flusher.Flush()
}
}
}
// ---- SPA serving (optional) -----------------------------------------------
// spaHandler serves the built SPA from s.webDir. A request for an existing asset
// is served directly; any other path (a client-side route) falls back to
// index.html so the SPA router can take over. /api and /healthz are matched first.
func (s *server) spaHandler() http.Handler {
root := http.Dir(s.webDir)
fileServer := http.FileServer(root)
index := filepath.Join(s.webDir, "index.html")
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
http.ServeFile(w, r, index)
return
}
if f, err := root.Open(p); err == nil {
_ = f.Close()
fileServer.ServeHTTP(w, r)
return
}
http.ServeFile(w, r, index) // unknown path -> SPA client-side routing
})
}
// ---- helpers --------------------------------------------------------------
func newToken() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// decode reads a JSON body into v, writing a 400 and returning false on failure.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
defer r.Body.Close()
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return false
}
return true
}
// statFile reports whether path exists and is a regular file (used to validate
// --web-dir at startup so a typo surfaces as a clear log line, not 404s later).
func statFile(path string) bool {
fi, err := os.Stat(path)
return err == nil && !fi.IsDir()
}
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"encoding/hex"
"fmt"
"net/http"
"sync"
"time"
cs "fn-registry/functions/cybersecurity"
)
// session is one logged-in browser. In the wallet model each session carries the
// user's OWN bus identity: the browser unlocks its locally-encrypted private key
// and hands the full keypair to the gateway over TLS, and the gateway spins up a
// dedicated bus client (a *gateway) that acts AS that user. The private key lives
// only in this process's memory for the life of the session — it is never written
// to disk and is dropped when the session ends.
//
// A session may instead point at the shared operator gateway (the legacy
// passphrase login); `owned` distinguishes the two so logout only closes the bus
// client it created.
type session struct {
gw *gateway
owned bool // true => gw was built for this session and must be Closed on logout
handle string
issuedAt time.Time
}
// sessionStore is the gateway's set of live browser sessions, keyed by the opaque
// cookie token. It is independent of any single bus identity.
type sessionStore struct {
mu sync.Mutex
m map[string]*session
}
func newSessionStore() *sessionStore { return &sessionStore{m: map[string]*session{}} }
func (st *sessionStore) put(token string, s *session) {
st.mu.Lock()
st.m[token] = s
st.mu.Unlock()
}
func (st *sessionStore) get(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
return s, ok
}
// drop removes a session and returns it so the caller can close an owned gateway.
func (st *sessionStore) drop(token string) (*session, bool) {
st.mu.Lock()
defer st.mu.Unlock()
s, ok := st.m[token]
if ok {
delete(st.m, token)
}
return s, ok
}
// closeAll closes every owned per-user gateway (used at shutdown). The shared
// operator gateway is owned by main and closed separately.
func (st *sessionStore) closeAll() {
st.mu.Lock()
defer st.mu.Unlock()
for tok, s := range st.m {
if s.owned && s.gw != nil {
_ = s.gw.Close()
}
delete(st.m, tok)
}
}
// identityFromHex builds a cs.Identity from the four hex halves the browser sends
// on POST /api/session. It enforces the exact key sizes (sign_pub 32, sign_priv
// 64, kex_pub 32, kex_priv 32) so a malformed body cannot produce a half-built
// identity that fails opaquely deep in the bus client.
func identityFromHex(signPub, signPriv, kexPub, kexPriv string) (cs.Identity, error) {
sp, err := hex.DecodeString(signPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_pub: %w", err)
}
spriv, err := hex.DecodeString(signPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("sign_priv: %w", err)
}
kp, err := hex.DecodeString(kexPub)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_pub: %w", err)
}
kpriv, err := hex.DecodeString(kexPriv)
if err != nil {
return cs.Identity{}, fmt.Errorf("kex_priv: %w", err)
}
if len(sp) != 32 || len(spriv) != 64 || len(kp) != 32 || len(kpriv) != 32 {
return cs.Identity{}, fmt.Errorf("wrong key sizes (sign_pub=%d sign_priv=%d kex_pub=%d kex_priv=%d; want 32/64/32/32)",
len(sp), len(spriv), len(kp), len(kpriv))
}
return cs.Identity{SignPub: sp, SignPriv: spriv, KexPub: kp, KexPriv: kpriv}, nil
}
// sessionReq is the POST /api/session body: the user's full wallet identity (hex)
// plus a display handle. The private halves arrive only over TLS and are held in
// memory for the session; they are never persisted server-side.
type sessionReq struct {
Handle string `json:"handle"`
SignPub string `json:"sign_pub"`
SignPriv string `json:"sign_priv"`
KexPub string `json:"kex_pub"`
KexPriv string `json:"kex_priv"`
}
// handleSession opens a per-user session. It builds the user's bus identity from
// the posted keypair, connects a dedicated bus client as that user, and issues a
// session cookie bound to it. This is the wallet-model replacement for the
// operator passphrase login.
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
var req sessionReq
if !decode(w, r, &req) {
return
}
id, err := identityFromHex(req.SignPub, req.SignPriv, req.KexPub, req.KexPriv)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad identity: "+err.Error())
return
}
cfg := s.busTemplate
cfg.Identity = id
gw, err := newGateway(cfg)
if err != nil {
writeErr(w, http.StatusBadGateway, "connect bus as user: "+err.Error())
return
}
tok := newToken()
s.sessions.put(tok, &session{gw: gw, owned: true, handle: req.Handle, issuedAt: time.Now()})
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: tok,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, meResp{Endpoint: gw.endpoint, SignPub: req.SignPub, Handle: req.Handle})
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
// fixed wallet vector derived in the browser from the mnemonic
// "legal winner thank year wave sausage worth useful legal winner thank yellow"
// using the unibus-sign-v1 / unibus-kex-v1 HKDF scheme. Used to assert the Go
// side accepts the browser-derived key sizes.
const (
fixSignPub = "3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixSignPriv = "94485d66ac958e23546be2e3b7575a47e1264bdf082e09abb7ad02ab32fcd55e3d594317212e53a3685b305539f6789eb8c538579e350ca795278b180ebb53db"
fixKexPub = "f3561ca116e4444b8880b8c0a35f2c9e85804d8628006facd84b1a6146208257"
fixKexPriv = "f6ffdf15e5ee2af0494897ff43e61a06d632af425a0372cb53a7c3e0f84c2bb2"
)
func TestIdentityFromHex(t *testing.T) {
id, err := identityFromHex(fixSignPub, fixSignPriv, fixKexPub, fixKexPriv)
if err != nil {
t.Fatalf("identityFromHex valid vector: %v", err)
}
if len(id.SignPub) != 32 || len(id.SignPriv) != 64 || len(id.KexPub) != 32 || len(id.KexPriv) != 32 {
t.Fatalf("wrong sizes: %d/%d/%d/%d", len(id.SignPub), len(id.SignPriv), len(id.KexPub), len(id.KexPriv))
}
// Wrong sign_priv size (32 instead of 64) must be rejected.
if _, err := identityFromHex(fixSignPub, fixSignPub, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for short sign_priv")
}
// Non-hex must be rejected.
if _, err := identityFromHex("zz", fixSignPriv, fixKexPub, fixKexPriv); err == nil {
t.Fatalf("expected error for non-hex sign_pub")
}
}
func TestValidHexKey(t *testing.T) {
if !validHexKey(fixSignPub) {
t.Fatalf("fixSignPub should be a valid 32-byte hex key")
}
if validHexKey("abcd") {
t.Fatalf("short key should be invalid")
}
if validHexKey(strings.Repeat("z", 64)) {
t.Fatalf("non-hex key should be invalid")
}
}
func TestNewRegistrarParsesMockTokens(t *testing.T) {
r := newRegistrar("", "demo=demo:member, bob=bob, alice=alice:admin")
if len(r.mockTokens) != 3 {
t.Fatalf("want 3 mock tokens, got %d", len(r.mockTokens))
}
if r.mockTokens["demo"].role != "member" || r.mockTokens["demo"].handle != "demo" {
t.Fatalf("demo token parsed wrong: %+v", r.mockTokens["demo"])
}
if r.mockTokens["bob"].role != "member" {
t.Fatalf("bob should default to role member, got %q", r.mockTokens["bob"].role)
}
if r.mockTokens["alice"].role != "admin" {
t.Fatalf("alice should be admin, got %q", r.mockTokens["alice"].role)
}
}
// post builds a server with only a registrar (the register path does not touch a
// gateway) and runs one POST /api/register, returning status + decoded body.
func postRegister(t *testing.T, s *server, body string) (int, map[string]string) {
t.Helper()
req := httptest.NewRequest("POST", "/api/register", strings.NewReader(body))
w := httptest.NewRecorder()
s.handleRegister(w, req)
var m map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &m)
return w.Code, m
}
func TestHandleRegisterMockSingleUse(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// 1) valid token + valid keys => 201 with the invite's handle/role.
code, body := postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 201 {
t.Fatalf("first register: want 201, got %d (%v)", code, body)
}
if body["handle"] != "demo" || body["role"] != "member" {
t.Fatalf("first register body: %v", body)
}
// 2) same token again => 409 (single-use consumed).
code, _ = postRegister(t, s, `{"token":"demo","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`)
if code != 409 {
t.Fatalf("reused token: want 409, got %d", code)
}
}
func TestHandleRegisterValidation(t *testing.T) {
s := &server{registrar: newRegistrar("", "demo=demo:member")}
// bad sign_pub (too short) => 400
if code, _ := postRegister(t, s, `{"token":"demo","sign_pub":"abcd","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("short sign_pub: want 400, got %d", code)
}
// missing token => 400
if code, _ := postRegister(t, s, `{"sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("missing token: want 400, got %d", code)
}
// unknown token with no mock match and no register-url => 400
if code, _ := postRegister(t, s, `{"token":"nope","sign_pub":"`+fixSignPub+`","kex_pub":"`+fixKexPub+`"}`); code != 400 {
t.Fatalf("unknown token: want 400, got %d", code)
}
}
+7
View File
@@ -47,6 +47,13 @@ func main() {
if err != nil { if err != nil {
log.Fatalf("create room: %v", err) log.Fatalf("create room: %v", err)
} }
// Membership-change contract (issue 0006e): the bus freezes per-subject
// permissions at connect time, and this room did not exist then. Refresh the
// session so the new room's subject becomes publishable under enforce+ACL. On
// an unsecured/dev bus this is a harmless reconnect.
if err := c.RefreshSession(); err != nil {
log.Fatalf("refresh session after create room: %v", err)
}
log.Printf("room %q -> %s (subject %s, cleartext)", *roomSub, roomID, *roomSub) log.Printf("room %q -> %s (subject %s, cleartext)", *roomSub, roomID, *roomSub)
stop := make(chan os.Signal, 1) stop := make(chan os.Signal, 1)
+31
View File
@@ -65,3 +65,34 @@ curl -fsS http://<host-lan-ip>:8470/healthz
- To run against an external NATS instead of the embedded one, append - To run against an external NATS instead of the embedded one, append
`--nats-url nats://<host>:4222` to `ExecStart` and re-run `daemon-reload` + `--nats-url nats://<host>:4222` to `ExecStart` and re-run `daemon-reload` +
`restart`. `restart`.
## Clustering (HA) — see `deploy/cluster/`
The single-node service above is secure on its own. Running unibus as a
multi-node **cluster** has extra hardening rules (issues 0006a0006f); the full
runbook and the generated material live in `deploy/cluster/`. Key points an
operator must know:
- **Homogeneous posture (0006d).** Every node MUST run `--bus-auth enforce` (the
binary refuses to join a cluster otherwise) and present mutual route TLS on a
public bind. `/healthz` publishes each node's `posture` so a monitor can flag a
node that is not `enforce`+`acl`+`tls`.
- **Separate route CA (0006f).** The cluster route layer authenticates *nodes*,
not bus users — sign the route certs with a **dedicated cluster CA**
(`--route-tls-ca`), NOT the client data-plane CA (`--tls-cert`'s CA). Keeping
the two trust roots separate means a client cert can never be presented to the
route port. `deploy/cluster/generate-cluster-certs.sh` builds this CA.
- **Secret out of argv (0006f).** Pass the route password via
`--cluster-pass-file` or the `UNIBUS_CLUSTER_PASS` env var, NOT `--cluster-pass`
or a `nats://user:pass@host` in `--routes` (both are visible in `ps`/journald).
When the secret comes from a file/env, list peers as bare `--routes
nats://<host>:6250` and the binary injects the credentials.
- **`migrate-to-kv` confidentiality (0006f).** The migration writes the allowlist
(handles/roles/sign pubs) into KV. Run it only against a **loopback** nats-url,
or pin TLS with `--ca` for a remote target — otherwise that metadata travels in
cleartext. The binary refuses a remote target without `--ca`.
- **R1 is NOT HA (0006a/N3-DoS).** With `--kv-replicas 1` the control plane
(including the nonce bucket) is a single point of failure: if the node owning
the stream dies, every authenticated request fails closed (auth DoS). Real HA
needs **R3** (quorum 2/3): raise replicas in place with `nats stream update
--replicas 3` once the third node has joined. Do not advertise R1 as HA.
+7
View File
@@ -0,0 +1,7 @@
# Generated TLS material and secrets — NEVER commit (audit 0008: keys/secret).
out/
build/
secrets/
*.key
*.srl
cluster-ca.crt
+285
View File
@@ -0,0 +1,285 @@
# unibus cluster — 3-node deploy runbook (issue 0006g)
This directory holds the material to bring up unibus as a **3-node cluster**
(`magnus` + `homer` + `datardos`) for real HA: with **R3** replication the control
plane (rooms/members/keys/users on JetStream KV + the anti-replay nonce bucket)
survives the loss of any one node (quorum 2/3).
> **Status: this cluster is DEPLOYED in production** (magnus + homer + datardos,
> R3, enforce+ACL+TLS) — see report 0011. The runbook below was authored before any
> VPS existed and has since been **corrected against the real deploy** (report 0012):
> the start ordering, the R1→R3 reality, and the live user-add path were all wrong
> or missing. Steps that change a remote host are marked **HUMAN**; `deploy-cluster.sh`
> still defaults to a dry run.
## Files
| File | What it is |
|---|---|
| `nodes.env` | Topology: cluster name, ports, and the per-node rows (name, ssh host, public IP, WG IP). **HUMAN fills the placeholders.** |
| `generate-cluster-certs.sh` | Mints a **separate cluster route CA** + a route cert per node, and a data-plane server cert per node signed by the **client CA** (`../tls/ca.*`). |
| `membershipd-cluster.service` | One systemd unit, parameterized per node by `/opt/unibus/cluster.env`. enforce + per-subject ACL + TLS + `--store kv`, `Restart=always`. |
| `deploy-cluster.sh` | Cross-builds the linux binary, generates each node's `cluster.env`, and (with `--yes`) rsyncs everything + installs the unit. Staggered start is manual. |
Generated keys/secrets (`out/`, `build/`, `secrets/`) are **gitignored** — they are
secret and never leave the operator's trusted machine except over the secure
rsync channel.
## Topology (as deployed, report 0011)
| Node | SSH | Public IP | Role |
|---|---|---|---|
| magnus | `magnus` (root) | `135.125.201.30` | node — **= organic-machine.com = `om`**, the critical host (caddy + gitea + registry-api + monitoring); the bus runs alongside, untouched |
| homer | `homer` (ubuntu+sudo) | `141.94.69.66` | node |
| datardos | `dd` (ubuntu+sudo) | `51.91.100.142` | node |
`ROUTE_NETWORK=public`, **not `wg`**: there is no WireGuard mesh between the three
nodes (homer and datardos do not even have the `wg` binary; om's only WG peers are
the operator's PCs). The server-to-server routes therefore travel over the public
IPs, protected by the **separate cluster route CA** (mutual route TLS) — a client
data-plane cert can never be presented to the route port. The client data plane and
the HTTP control plane are also reached over the public IPs. There is no fixed
"seed" node: with R3 the three are peers (see "Bring up" for why a lone node cannot
self-serve).
## Prerequisites (HUMAN, once)
1. **Fill `nodes.env`** — replace every `<PLACEHOLDER>` (magnus public IP, all WG
IPs). The scripts refuse to run while any remain.
2. **Client CA exists**`../tls/ca.crt` + `../tls/ca.key`. If not, run
`../tls/generate-certs.sh` on the CA host (om) first. The cluster reuses this CA
for the data plane so existing clients keep trusting the bus.
3. **Mint cluster TLS**:
```bash
./generate-cluster-certs.sh # writes out/<name>/ ; --force to rotate the cluster CA
```
4. **Create the route secret** (out of argv, shared by all nodes):
```bash
mkdir -p secrets && openssl rand -hex 32 > secrets/cluster.pass
```
5. **SSH** to each node's SSH host as `root` works (`ssh magnus true`, `ssh dd true`, ...).
## Stage the nodes
```bash
./deploy-cluster.sh # DRY RUN — prints the full plan, touches nothing
./deploy-cluster.sh --yes # HUMAN: actually rsync + install the unit on all 3 nodes
```
This cross-builds `membershipd` (linux/amd64, `CGO_ENABLED=0`), writes each node's
`cluster.env` (its `NODE_NAME` and the `--routes` to the OTHER two nodes), and
ships the binary, the node's TLS material, the secret, the env file and the unit.
It does **not** start anything.
## Seed the first admin into the KV (HUMAN — loopback bootstrap)
The empty KV control plane has no users, and under `enforce` no external tool can
write the FIRST admin over NATS (it would need to be an admin already — a
chicken-and-egg). The `user` CLI also writes only to a local SQLite file, not the
KV. So the first admin is seeded on the seed node through a **loopback, no-auth
bootstrap** that populates the same JetStream store the cluster unit then reuses:
```bash
ssh root@magnus 'bash -s' <<'SEED'
set -euo pipefail
cd /opt/unibus
# a) Put the first admin into a local SQLite seed file.
./membershipd user add --db ./seed.db --handle root --sign-pub <ADMIN_SIGN_PUB_HEX> --role admin
# b) Bring up a TEMPORARY loopback, no-auth, single-node KV server on the cluster's
# own JetStream store dir (not exposed; bus-auth off is allowed on 127.0.0.1).
./membershipd --store kv --bus-auth off --bind 127.0.0.1 \
--nats-store ./local_files/jetstream --db ./seed.db >/tmp/seed-boot.log 2>&1 &
BOOT=$!; sleep 2
# c) Migrate the admin from SQLite into the replicated KV (loopback — no --ca needed).
./membershipd migrate-to-kv --db ./seed.db --nats-url nats://127.0.0.1:4250 --replicas 1
# d) Stop the bootstrap server. The KV buckets persist in ./local_files/jetstream.
kill "$BOOT"; wait "$BOOT" 2>/dev/null || true
rm -f ./seed.db
SEED
```
> The KV written here lives in `./local_files/jetstream`, which the cluster unit
> reuses (`--nats-store` default), so the admin is present when the enforce cluster
> starts. This loopback bootstrap is needed ONLY for the very first admin (the
> chicken-and-egg). **Every user after that is added with the cluster live** — no
> stop-seed-restart — via `user add --store kv` (see "Add users to the live
> cluster" below, report 0012).
## Bring up (HUMAN)
> **CORRECTION (report 0012).** The original instruction — "start magnus alone and
> verify healthz, then add the others" — is **WRONG and will look like a hung
> deploy.** A 3-node JetStream cluster forms a RAFT meta-group that needs a quorum
> (2 of 3) to elect a leader. A single started node has no quorum, so its JetStream
> meta never becomes current: `--store kv` blocks creating the KV buckets and
> **`/healthz` never returns ok** until a second node joins. Waiting for magnus to
> "go green" before starting the others therefore deadlocks the rollout.
Start the nodes so a quorum forms. On a **clean cluster** the simplest correct
procedure is to start all three close together and let the meta-group converge:
```bash
# Start all three (order does not matter); each blocks on the others until a
# 2/3 quorum elects a JetStream meta leader, then the KV buckets are created.
for h in magnus homer datardos; do ssh "$h" 'sudo systemctl enable --now membershipd-cluster'; done
# Only NOW does healthz return ok — once the meta-group has a leader (give it
# ~10-30s on a cold start). Poll, do not assume the first node is broken.
for h in magnus homer datardos; do
echo "== $h =="; ssh "$h" 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt || echo "(not ready yet — needs quorum)"'
done
```
A **staggered** start also works, but only because `membershipd`'s KV open RETRIES
the bucket creation for a 120s bootstrap budget (issue 0006g, fix #3): the first
node sits in that retry loop — NOT serving healthz — until the second node makes a
quorum, then both converge and the third catches up. Either way, a lone node never
self-serves; do not gate the next node's start on the previous one's healthz.
> A cold multi-node start only converges because of **three cold-start fixes**
> (report 0011): route pooling off (`PoolSize=-1`), `NoAdvertise=true` (Docker
> bridge IPs not gossiped), and the KV-open retry loop above. Without them the
> meta-group re-elects leaders forever and bucket creation hangs. If a fresh
> cluster will not form, confirm the running binary contains these fixes before
> touching config.
## Promote an existing single-node (SQLite) deployment (HUMAN, optional)
Instead of seeding fresh, you can migrate an existing single-node `unibus.db` into
the KV — **loopback only** (the allowlist would otherwise travel cleartext; the
command refuses a remote target without `--ca`). Use the same loopback-bootstrap
shape as the seed step (temporary `--bus-auth off` server on 127.0.0.1, then
`migrate-to-kv --db /opt/unibus/local_files/unibus.db`).
## Verify
```bash
# Posture on every node — all must be enforce+acl+tls+cluster, store=kv.
for h in magnus homer datardos; do
echo "== $h =="
ssh root@$h 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt'
done
# Cluster + JetStream meta-group health (needs the `nats` CLI on a node):
ssh root@magnus 'nats --server nats://127.0.0.1:4250 server report jetstream'
ssh root@magnus 'nats --server nats://127.0.0.1:4250 server list' # 3 servers, routes up
```
A healthy cluster shows 3 routed servers and a JetStream meta-group with a leader.
## Add users to the live cluster (HUMAN — `user add --store kv`)
With the cluster up, add (and revoke) bus users **without stopping anything**,
directly against the replicated KV allowlist. This replaces the stop-seed-restart
procedure the original runbook implied for every user beyond the first admin.
The mechanism is the cluster's own **privileged internal connection**: under
`enforce` every bus user is confined by the per-subject ACL to its own rooms, so no
ordinary identity may write the control-plane buckets. The only identity the
authenticator grants full JetStream permissions is `membershipd`'s internal service
identity. The unit persists that identity to `${INTERNAL_ID_FILE}`
(`/opt/unibus/secrets/internal.id`, 0600) via `--internal-id-file`, so the same key
is available to the CLI. Run the CLI **on a node, over loopback** (the data-plane
TLS cert SAN covers `127.0.0.1`); reading the identity file requires root on that
node, which already implies full control of it, so this adds no practical exposure.
```bash
# Add a member to the live cluster's replicated allowlist (run on any node).
ssh root@magnus 'sudo /opt/unibus/membershipd user add --store kv \
--handle alice --role member --sign-pub <64-hex-ed25519-pub>'
# -> added user "alice" (...) role=member
# -> KV_UNIBUS_users: leader=<node> followers_current=2/2 msgs=N (replicated, HA)
# List / revoke against the same live KV:
ssh root@magnus 'sudo /opt/unibus/membershipd user list --store kv'
ssh root@magnus 'sudo /opt/unibus/membershipd user revoke --store kv <64-hex-ed25519-pub>'
```
Defaults assume an on-node invocation (`--nats-url nats://127.0.0.1:4250`,
`--internal-id-file /opt/unibus/secrets/internal.id`, `--ca /opt/unibus/tls/ca.crt`,
`--kv-replicas 3`). Semantics:
- **Idempotent / non-destructive**: re-adding the same key is an explicit
`already registered` error (exit 1), never a silent overwrite — a re-add cannot
flip a member to admin. To replace a user, `revoke` then add.
- **HA**: the write commits through the JetStream quorum, so it succeeds even with
one node down (2/3); the printed `followers_current` shows replication.
- **No hard delete**: `revoke` flips status to `revoked` (denied on both planes,
auditable); the KV has no row deletion, matching the SQLite store.
> **Rollout note (report 0012):** the live verification deployed this binary +
> `--internal-id-file` to **datardos only** (the non-critical node). magnus and
> homer still run the 0011 binary. To make the capability available (and the unit)
> on all three — recommended, the posture is identical so there is no urgency — roll
> the new binary with backups, one node at a time, verifying healthz between each:
> ```bash
> for h in homer magnus; do
> ssh "$h" 'sudo cp -a /opt/unibus/membershipd /opt/unibus/membershipd.bak' # backup
> scp build/membershipd "$h:/tmp/m" && ssh "$h" 'sudo install -o ubuntu -g ubuntu -m0775 /tmp/m /opt/unibus/membershipd'
> # add INTERNAL_ID_FILE=/opt/unibus/secrets/internal.id to /opt/unibus/cluster.env
> # add `--internal-id-file ${INTERNAL_ID_FILE} \` to the unit before `--store kv`
> ssh "$h" 'sudo systemctl daemon-reload && sudo systemctl restart membershipd-cluster'
> ssh "$h" 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt' # green before next
> done
> ```
> (`deploy-cluster.sh` + the unit template already emit `INTERNAL_ID_FILE` and the
> flag, so a fresh `./deploy-cluster.sh --yes` is correct for all three.)
## Replication: go straight to R3 (HUMAN — real HA)
> **CORRECTION (report 0012).** The original "start at R1, then scale to R3" plan
> assumed R1 is a usable interim state. **It is not, in this cluster.** At R1 all six
> control-plane buckets (`KV_UNIBUS_users/rooms/members/room_keys/rooms_by_member`
> + `KV_UNIBUS_nonces`) live on a SINGLE node — a hard **SPOF for authentication**:
> if that node dies, the nonce/KV control plane is unreachable and EVERY
> authenticated request fails closed (auth DoS). Worse, the cold multi-node start
> only converges at all because of the three cold-start fixes (see "Bring up"); the
> real deploy never ran a healthy R1 and **jumped straight to R3 once the cluster
> formed.** Treat R1 as a transient artifact of bucket creation, not a milestone.
The deployed config already sets `KV_REPLICAS=3` in `nodes.env`. If buckets were
created at R1 (e.g. only one node was up when `--store kv` first opened them), raise
every control-plane stream to R3 IN PLACE (no data loss) once all three nodes are
routed:
```bash
for s in KV_UNIBUS_users KV_UNIBUS_rooms KV_UNIBUS_members KV_UNIBUS_room_keys \
KV_UNIBUS_rooms_by_member KV_UNIBUS_nonces; do
ssh root@magnus "nats --server nats://127.0.0.1:4250 stream update $s --replicas 3 -f"
done
# (also OBJ_UNIBUS_blobs if the object store is in use)
```
After this each bucket shows `followers_current=2/2` (quorum 2/3). The
`user add --store kv` command prints that figure for `KV_UNIBUS_users` on every add,
which is a cheap live HA check.
## Chaos test (HUMAN — requires the 3 live VPS)
Validate quorum tolerance after R3:
```bash
# Kill one node; the cluster keeps serving (quorum 2/3). On ubuntu nodes use sudo.
ssh dd 'sudo systemctl stop membershipd-cluster'
# -> clients fail over (multiple seed URLs); reads/writes still succeed.
ssh dd 'sudo systemctl start membershipd-cluster' # rejoins, catches up
# Kill two nodes; quorum is LOST — the control plane should fail CLOSED (deny),
# never fail open. Verify a request is rejected, not silently served.
```
> **Validated (report 0012).** The 0011 chaos run checked only the control plane
> (healthz + meta/stream-leader failover + KV readable with 2/3). Report 0012 added
> the missing data-plane proofs against the live cluster: a real authenticated
> client (`cmd/clientcheck`, operator identity, nkey+TLS) creating an E2E room and
> publishing/subscribing — including a node stopped mid-stream, where the client
> failed over to a survivor and kept receiving with zero loss (quorum 2/3) — and
> `user add --store kv` committing with one node (the KV leader) down. The kill-2/3
> fail-closed case remains a documented manual step.
## Rollback
`membershipd` does not delete data. To revert a node to standalone SQLite, stop
the unit and start it without `--store kv`/`--cluster-name`; the KV buckets remain
for a later retry. To rotate the cluster CA, re-run `generate-cluster-certs.sh
--force` and re-stage (every node must get the new `cluster-ca.crt` together).
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
#
# deploy-cluster.sh — cross-build membershipd and stage it onto the three cluster
# nodes (issue 0006g). DEFAULT IS DRY-RUN: it prints the plan and touches nothing.
# Pass --yes to actually rsync + run remote commands. Steps that a HUMAN must run
# (or confirm) are marked "HUMAN:".
#
# Prerequisites (HUMAN, once):
# 1. Fill nodes.env (no <PLACEHOLDER> left).
# 2. ./generate-cluster-certs.sh (mints out/<name>/ TLS material)
# 3. Create the route secret locally: mkdir -p secrets && openssl rand -hex 32 > secrets/cluster.pass
# (secrets/ is gitignored; it is rsynced to each node as cluster.pass)
# 4. SSH access to every node's SSH_HOST with sudo-less root (SSH_USER=root).
#
# What it does per node (with --yes):
# - rsync the membershipd binary, the node's TLS material, the unit, the
# generated cluster.env and the route secret into REMOTE_DIR.
# - install + daemon-reload the systemd unit.
# Start is STAGGERED and left to the human (see README): start the seed node,
# seed the admin, then start the rest.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
# shellcheck source=/dev/null
source ./nodes.env
APPLY=0
[[ "${1:-}" == "--yes" ]] && APPLY=1
if grep -q '<[A-Z_]\+>' nodes.env; then
echo "ERROR: nodes.env still has <PLACEHOLDER> values — fill them in first." >&2
exit 2
fi
SECRET_FILE="secrets/cluster.pass"
if [[ ! -f "$SECRET_FILE" ]]; then
echo "ERROR: $SECRET_FILE missing. HUMAN: mkdir -p secrets && openssl rand -hex 32 > $SECRET_FILE" >&2
exit 2
fi
run() {
# Echo every action; only execute it under --yes.
echo " + $*"
if [[ $APPLY -eq 1 ]]; then
"$@"
fi
}
echo "==> [1/3] cross-build membershipd (linux/amd64, CGO disabled)"
run env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/membershipd ../../cmd/membershipd
# Build the comma-separated route list for a node = the OTHER nodes' addresses on
# the chosen network, with NO userinfo (the secret is injected by membershipd from
# the file). Echoes nothing; prints the value.
routes_for() {
local self="$1" out=""
local row name _ssh pub wg addr
for row in "${CLUSTER_NODES[@]}"; do
read -r name _ssh pub wg <<<"$row"
[[ "$name" == "$self" ]] && continue
if [[ "$ROUTE_NETWORK" == "public" ]]; then addr="$pub"; else addr="$wg"; fi
out+="nats://${addr}:${NATS_ROUTE_PORT},"
done
echo "${out%,}"
}
echo "==> [2/3] stage each node (REMOTE_DIR=$REMOTE_DIR)"
for row in "${CLUSTER_NODES[@]}"; do
read -r name ssh _pub _wg <<<"$row"
target="${SSH_USER}@${ssh}"
nodedir="out/${name}"
if [[ ! -d "$nodedir" ]]; then
echo "ERROR: $nodedir missing — run ./generate-cluster-certs.sh first." >&2
exit 2
fi
routes="$(routes_for "$name")"
echo "-- node ${name} (ssh ${ssh}) routes=${routes}"
# Generate this node's cluster.env locally, then ship it.
envfile="build/cluster-${name}.env"
mkdir -p build
cat > "$envfile" <<EOF
NODE_NAME=${name}
CLUSTER_NAME=${CLUSTER_NAME}
CLUSTER_USER=${CLUSTER_USER}
KV_REPLICAS=${KV_REPLICAS}
HTTP_PORT=${HTTP_PORT}
NATS_CLIENT_PORT=${NATS_CLIENT_PORT}
NATS_ROUTE_PORT=${NATS_ROUTE_PORT}
ROUTES=${routes}
CLUSTER_PASS_FILE=${REMOTE_DIR}/secrets/cluster.pass
TLS_CERT=${REMOTE_DIR}/tls/server-${name}.crt
TLS_KEY=${REMOTE_DIR}/tls/server-${name}.key
ROUTE_TLS_CERT=${REMOTE_DIR}/tls/route-${name}.crt
ROUTE_TLS_KEY=${REMOTE_DIR}/tls/route-${name}.key
ROUTE_TLS_CA=${REMOTE_DIR}/tls/cluster-ca.crt
INTERNAL_ID_FILE=${REMOTE_DIR}/secrets/internal.id
EOF
run ssh "$target" "mkdir -p ${REMOTE_DIR}/tls ${REMOTE_DIR}/secrets"
run rsync -az build/membershipd "${target}:${REMOTE_DIR}/membershipd"
run rsync -az "${nodedir}/" "${target}:${REMOTE_DIR}/tls/"
run rsync -az "$SECRET_FILE" "${target}:${REMOTE_DIR}/secrets/cluster.pass"
run rsync -az "$envfile" "${target}:${REMOTE_DIR}/cluster.env"
run rsync -az membershipd-cluster.service "${target}:/etc/systemd/system/membershipd-cluster.service"
run ssh "$target" "chmod 600 ${REMOTE_DIR}/secrets/cluster.pass ${REMOTE_DIR}/tls/*.key && systemctl daemon-reload"
done
echo "==> [3/3] staged."
if [[ $APPLY -eq 0 ]]; then
echo " DRY-RUN: nothing was sent. Re-run with --yes to apply."
fi
cat <<'NEXT'
HUMAN — bring up (see README "Bring up" — a LONE node has no quorum and never
serves healthz, so do NOT gate the next node on the previous one going green):
1. Seed the FIRST admin into the KV via the loopback bootstrap (README
"Seed the first admin"); this is needed only for the chicken-and-egg admin.
2. Start all three so a 2/3 quorum forms (order does not matter); healthz
turns ok only once the meta-group elects a leader (~10-30s cold):
for h in magnus homer datardos; do ssh "$h" 'sudo systemctl enable --now membershipd-cluster'; done
3. Verify posture + quorum (README "Verify").
4. Ensure R3 on every control-plane stream (README "Replication: go straight to
R3"); R1 is a SPOF, not a milestone.
5. Add further users with the cluster LIVE — no restart — via
`membershipd user add --store kv` (README "Add users to the live cluster").
NEXT
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
#
# generate-cluster-certs.sh — mint the TLS material for a unibus 3-node cluster
# (issue 0006g). Run ONCE on a trusted machine (e.g. om, which custodies the bus
# CA); distribute the per-node output to each node over a secure channel. This
# script touches NO remote host.
#
# It produces two trust roots, kept SEPARATE on purpose (audit 0008 N1-low):
#
# 1. The CLUSTER route CA (cluster-ca.crt/key, generated here): signs each
# node's ROUTE certificate. The route layer authenticates NODES, not bus
# users, so it must NOT share the client data-plane CA — a client cert can
# then never be presented to the route port.
# 2. The CLIENT data-plane CA (../tls/ca.crt/key, the one clients pin): signs
# each node's DATA-PLANE server certificate. Reused, not regenerated, so
# existing clients keep trusting the bus.
#
# Per node it emits, under out/<name>/:
# route-<name>.crt/key route cert (cluster CA), EKU server+clientAuth
# (each node is BOTH server and dialer to its peers)
# server-<name>.crt/key data-plane cert (client CA), EKU serverAuth
# cluster-ca.crt the route CA cert (for --route-tls-ca)
# ca.crt the client CA cert (for clients / control-plane TLS)
#
# SANs per node = its public IP + its WireGuard IP + its hostname + localhost.
#
# Key material: EC P-256 (Go crypto/tls + nats-server friendly), matching
# ../tls/generate-certs.sh.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
# shellcheck source=/dev/null
source ./nodes.env
# Refuse to run while any placeholder remains (HUMAN must fill nodes.env first).
if grep -q '<[A-Z_]\+>' nodes.env; then
echo "ERROR: nodes.env still has <PLACEHOLDER> values — fill them in first." >&2
grep -n '<[A-Z_]\+>' nodes.env >&2
exit 2
fi
CLIENT_CA_CRT="../tls/ca.crt"
CLIENT_CA_KEY="../tls/ca.key"
if [[ ! -f "$CLIENT_CA_CRT" || ! -f "$CLIENT_CA_KEY" ]]; then
echo "ERROR: client data-plane CA not found at ../tls/ca.{crt,key}." >&2
echo " Run ../tls/generate-certs.sh first (it mints the client CA)." >&2
exit 2
fi
DAYS_CA=3650
DAYS_CRT=825
force=0
[[ "${1:-}" == "--force" ]] && force=1
# --- cluster route CA (separate trust root) ---
if [[ ! -f cluster-ca.crt || ! -f cluster-ca.key || $force -eq 1 ]]; then
echo "==> generating cluster route CA (separate from the client CA)"
openssl ecparam -name prime256v1 -genkey -noout -out cluster-ca.key
chmod 600 cluster-ca.key
openssl req -x509 -new -key cluster-ca.key -sha256 -days "$DAYS_CA" \
-subj "/CN=unibus-cluster-ca" -out cluster-ca.crt
else
echo "==> reusing existing cluster route CA (pass --force to regenerate)"
fi
# mint <out_key> <out_crt> <subject_cn> <san> <eku> <ca_crt> <ca_key>
mint_cert() {
local out_key="$1" out_crt="$2" cn="$3" san="$4" eku="$5" ca_crt="$6" ca_key="$7"
local csr ext
csr="$(mktemp)"
ext="$(mktemp)"
openssl ecparam -name prime256v1 -genkey -noout -out "$out_key"
chmod 600 "$out_key"
openssl req -new -key "$out_key" -subj "/CN=${cn}" -out "$csr"
cat > "$ext" <<EOF
subjectAltName=${san}
extendedKeyUsage=${eku}
keyUsage=digitalSignature,keyEncipherment
EOF
openssl x509 -req -in "$csr" -CA "$ca_crt" -CAkey "$ca_key" -CAcreateserial \
-sha256 -days "$DAYS_CRT" -extfile "$ext" -out "$out_crt"
rm -f "$csr" "$ext"
}
for row in "${CLUSTER_NODES[@]}"; do
read -r name _ssh pub wg <<<"$row"
echo "==> node ${name}: SAN IP:${pub}, IP:${wg}, DNS:${name}, localhost, 127.0.0.1"
nodedir="out/${name}"
mkdir -p "$nodedir"
san="IP:${pub},IP:${wg},DNS:${name},DNS:localhost,IP:127.0.0.1"
# Route cert: signed by the cluster CA; server+client auth (mutual routes).
mint_cert "${nodedir}/route-${name}.key" "${nodedir}/route-${name}.crt" \
"unibus-route-${name}" "$san" "serverAuth,clientAuth" \
cluster-ca.crt cluster-ca.key
# Data-plane server cert: signed by the client CA; serverAuth only.
mint_cert "${nodedir}/server-${name}.key" "${nodedir}/server-${name}.crt" \
"unibus-${name}" "$san" "serverAuth" \
"$CLIENT_CA_CRT" "$CLIENT_CA_KEY"
# Co-locate the two CA certs each node needs.
cp cluster-ca.crt "${nodedir}/cluster-ca.crt"
cp "$CLIENT_CA_CRT" "${nodedir}/ca.crt"
done
rm -f cluster-ca.srl ../tls/ca.srl 2>/dev/null || true
echo
echo "==> done. Per-node material under out/<name>/ (KEYS ARE SECRET — never git):"
for row in "${CLUSTER_NODES[@]}"; do
read -r name _rest <<<"$row"
echo " out/${name}/ (route-${name}.*, server-${name}.*, cluster-ca.crt, ca.crt)"
done
echo
echo "verify a SAN with:"
echo " openssl x509 -in out/<name>/server-<name>.crt -noout -text | grep -A1 'Subject Alternative Name'"
@@ -0,0 +1,46 @@
[Unit]
# unibus membershipd — cluster node (issue 0006g).
#
# One unit, parameterized per node by /opt/unibus/cluster.env (generated by
# deploy-cluster.sh): NODE_NAME, ROUTES and the cert paths differ per node, the
# rest of the posture (enforce + per-subject ACL + TLS + --store kv) is identical
# on every node, which is the homogeneous posture a secure cluster requires
# (audit 0008 N1).
Description=unibus membershipd (cluster node)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/unibus
EnvironmentFile=/opt/unibus/cluster.env
# The route password comes from a FILE referenced by ${CLUSTER_PASS_FILE}, never
# from argv (audit 0008 N1-low). The peer --routes carry no userinfo; membershipd
# injects the credentials from the file/user.
ExecStart=/opt/unibus/membershipd \
--bind 0.0.0.0 \
--bus-auth enforce \
--http-port ${HTTP_PORT} \
--nats-port ${NATS_CLIENT_PORT} \
--tls-cert ${TLS_CERT} \
--tls-key ${TLS_KEY} \
--cluster-name ${CLUSTER_NAME} \
--server-name ${NODE_NAME} \
--cluster-port ${NATS_ROUTE_PORT} \
--routes ${ROUTES} \
--cluster-user ${CLUSTER_USER} \
--cluster-pass-file ${CLUSTER_PASS_FILE} \
--route-tls-cert ${ROUTE_TLS_CERT} \
--route-tls-key ${ROUTE_TLS_KEY} \
--route-tls-ca ${ROUTE_TLS_CA} \
--internal-id-file ${INTERNAL_ID_FILE} \
--store kv \
--kv-replicas ${KV_REPLICAS}
# Restart=always (NOT on-failure): a clean SIGTERM exits success, and on-failure
# would then NOT restart, leaving the node silently dead (see function_tags.md).
Restart=always
RestartSec=2
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
+57
View File
@@ -0,0 +1,57 @@
# Cluster topology for the unibus 3-node deployment (issue 0006g).
#
# This file is SOURCED by generate-cluster-certs.sh and deploy-cluster.sh.
#
# HUMAN: fill in every placeholder with the real value before running the
# scripts. The public IPs known at authoring time are pre-filled; the WireGuard
# mesh IPs and magnus's public IP must be supplied. The scripts refuse to run
# while any unfilled placeholder remains.
# Cluster identity (must be identical on every node).
CLUSTER_NAME="unibus"
# Route-secret username; the password is NOT here — it lives in a file (see
# CLUSTER_PASS_FILE in deploy-cluster.sh) so it never lands in argv or git.
CLUSTER_USER="unibus-cluster"
# KV/nonce replication factor. START AT 1 for the initial 1->3 rollout, then raise
# to 3 IN PLACE (see README "Scale to R3") once all three nodes have joined. Only
# set this to 3 here after the third node is up and you re-run the KV update.
KV_REPLICAS=3
# Ports (same on every node; the route port is server-to-server only).
NATS_CLIENT_PORT=4250
NATS_ROUTE_PORT=6250
HTTP_PORT=8470
# Remote install layout and SSH login user.
REMOTE_DIR="/opt/unibus"
SSH_USER="root"
# Which address family the inter-node routes use. "wg" builds --routes from the
# WireGuard mesh IPs (private server-to-server links, preferred); "public" uses
# the public IPs. The route layer is always mutual-TLS regardless.
#
# DEPLOY DECISION (2026-06-07): set to "public". No WireGuard mesh exists between
# the three cluster nodes — homer and datardos do not even have the `wg` binary
# installed, and om's only WG peers are the operator's personal PCs, not the VPS.
# Rather than stand up a fresh mesh blindly, the routes go over the public IPs,
# still protected by the separate cluster route CA (mutual-TLS). On magnus (the
# only node with ufw active) the route port 6250 is restricted to the homer and
# datardos public IPs; homer/datardos run ufw inactive (Docker hosts) and rely on
# the route mutual-TLS for 6250.
ROUTE_NETWORK="public"
# One row per node: NAME SSH_HOST PUBLIC_IP WG_IP
# NAME -> --server-name and the per-node cert filenames (unique).
# SSH_HOST -> the `ssh ALIAS` alias (see ~/.ssh/config).
# PUBLIC_IP -> public address; goes in the cert SANs (client-facing data plane).
# WG_IP -> WireGuard mesh address; cert SAN + route target when ROUTE_NETWORK=wg.
# NOTE: with ROUTE_NETWORK=public and no WireGuard mesh, the WG_IP column is set to
# each node's public IP so the cert SAN covers the address actually used by the
# public routes and no unfilled placeholder remains (scripts refuse to run otherwise).
# magnus == organic-machine.com == om (135.125.201.30); SSH alias `magnus` enters as root.
CLUSTER_NODES=(
"magnus magnus 135.125.201.30 135.125.201.30"
"homer homer 141.94.69.66 141.94.69.66"
"datardos dd 51.91.100.142 51.91.100.142"
)
+1 -1
View File
@@ -18,7 +18,7 @@
"decentralized": { "decentralized": {
"enabled": false, "enabled": false,
"issue": "0003", "issue": "0003",
"description": "Control-plane state on replicated JetStream KV instead of local SQLite (branch-by-abstraction membership.Store: sqliteStore default OFF, jetstreamStore ON). The route cluster (0003a) and the KV store (0003b) ship behind this flag; the membershipd boot wiring that selects the KV store completes with the session/reconnect redesign (0003e) and is activated on the multi-node deploy (0003f). OFF keeps the single-node SQLite control plane unchanged.", "description": "Control-plane state on replicated JetStream KV instead of local SQLite (branch-by-abstraction membership.Store: sqliteStore default, jetstreamStore opt-in). The route cluster (0003a) and the KV store (0003b) shipped behind this flag; the membershipd boot wiring that selects the store is COMPLETE since issue 0006c and is realized at runtime with the server flag --store kv|sqlite (default sqlite). The internal-identity bootstrap (0006a) lets membershipd open the KV store on its own embedded NATS under enforce. Per-deploy opt-in: a node joins the decentralized control plane by starting with --store kv (and --cluster-name for HA). OFF (--store sqlite) keeps the single-node SQLite control plane unchanged.",
"added": "2026-06-07", "added": "2026-06-07",
"enabled_at": null "enabled_at": null
} }
@@ -1,8 +1,10 @@
--- ---
issue: 0006 issue: 0006
title: Completar y endurecer el cluster — wiring del control plane KV + N1-N6 de la auditoría 0008 title: Completar y endurecer el cluster — wiring del control plane KV + N1-N6 de la auditoría 0008
status: spec status: done
created: 2026-06-07 created: 2026-06-07
closed: 2026-06-07
closed_by: fases 0006a0006g (ver report 0009); unibus v0.8.0
domain: security domain: security
scope: unibus (cmd/membershipd, pkg/membership, pkg/embeddednats, pkg/busauth, pkg/client) scope: unibus (cmd/membershipd, pkg/membership, pkg/embeddednats, pkg/busauth, pkg/client)
depends_on: 0003 (completa su wiring), 0005 (hereda el bus single-node ya seguro) depends_on: 0003 (completa su wiring), 0005 (hereda el bus single-node ya seguro)
@@ -0,0 +1,78 @@
---
issue: 0007
title: Cifrado at-rest del control plane (JetStream KV / SQLite en disco)
status: spec
created: 2026-06-07
domain: security
scope: unibus (pkg/embeddednats, cmd/membershipd, deploy/cluster) + procedimiento de migración del store existente
---
# Objetivo
Cifrar en reposo el almacenamiento del plano de control para que un nodo comprometido
(root en el VPS) o un disco robado no exponga los metadatos de control en claro.
Estado actual (auditado el 07/06/2026, report 0012 y siguientes):
- **Contenido de los mensajes**: cifrado E2E por room (megolm/olm). El servidor nunca ve el
plaintext; no vive en el plano de control. **No es el objeto de este issue.**
- **Claves de room** (`UNIBUS_room_keys`): guardadas **selladas** (sealed box X25519, cifradas
para cada miembro). El servidor las almacena y reparte pero no puede abrirlas. **Ya protegidas.**
- **Metadatos de control** (`UNIBUS_rooms`, `UNIBUS_members`, `UNIBUS_rooms_by_member`,
`UNIBUS_users`): se serializan con `json.Marshal` y se escriben **en claro** en el store. En
cluster ese store es el directorio `local_files/jetstream/` de cada nodo; en single-node es el
archivo SQLite `local_files/unibus.db`. Hoy **no hay cifrado at-rest**: con root en un nodo se
pueden leer subjects de salas, la pertenencia (quién está en qué sala con qué rol), los handles
y roles de los usuarios, y las claves públicas (signPub/kexPub). No se exponen mensajes (E2E) ni
se pueden descifrar salas (claves selladas), pero sí toda la topología.
Tras este issue, los buckets/archivos del control plane quedan cifrados en disco con una clave por
nodo gestionada fuera de git. El modelo de amenaza pasa de "root del nodo ve la topología" a "root
del nodo necesita además la clave at-rest (que puede vivir en un secreto separado / TPM / variable
de entorno inyectada) para leer cualquier cosa".
# Contexto técnico
- NATS Server / JetStream soporta **encryption at-rest** nativo: se configura una cifra
(`aes` o `chacha20`) y una clave; JetStream cifra los ficheros de los streams/KV en disco. El
bus usa un NATS **embebido** (`pkg/embeddednats`), así que la activación es por opciones del
servidor embebido, no por un `nats-server.conf` externo.
- Para el backend SQLite (single-node) el equivalente sería SQLCipher o cifrado a nivel de
archivo/FS; queda como sub-tarea de menor prioridad porque el despliegue real es cluster (KV).
# Tareas
1. Confirmar la API de encryption-at-rest del NATS embebido en la versión usada (opción de
servidor para cipher + clave; cómo se pasa la clave de forma que no quede en argv ni en git).
2. Activar el cifrado en `pkg/embeddednats` detrás de una opción de configuración. La clave se
inyecta por archivo (`--jetstream-encryption-key-file`, 0600, junto a las claves TLS del nodo)
o variable de entorno desde el unit systemd; nunca en argv ni commiteada.
3. `cmd/membershipd`: flag/env para la clave + reflejar el estado en la posture publicada en
`/healthz` (p.ej. `"at_rest":true`) para que el monitor lo verifique.
4. `deploy/cluster`: provisionar la clave at-rest por nodo (generación + `pass`/secrets gitignored)
y cablearla en `cluster.env` + el unit. Documentar en el runbook.
5. **Migración del store existente** (gotcha crítico): JetStream no re-cifra retroactivamente los
datos ya escritos en claro. Diseñar y documentar el procedimiento seguro para el cluster en
producción (probable: backup → exportar snapshot del control plane → parar nodo → recrear el
store con la clave activa → re-importar; o rotación nodo a nodo aprovechando la replicación R3).
Respetar la regla de migraciones (aditivo, sin pérdida de datos).
6. Tests: arrancar un nodo con clave at-rest, escribir un user/room, y verificar que el fichero en
disco **no** contiene en claro un subject/handle conocido (grep negativo), y que el nodo sigue
leyéndolos con la clave. Verificar que sin la clave el store no se abre.
# Definition of Done
- Cifrado at-rest activo en los 3 nodos del cluster; `/healthz` lo refleja en la posture.
- Evidencia ejecutable: un valor conocido (subject de sala / handle de usuario) **no** aparece en
claro al hacer `grep` sobre `local_files/jetstream/`; el nodo lo sigue sirviendo con la clave.
- Procedimiento de migración probado sobre datos reales sin pérdida (snapshot/restore verificado).
- La clave at-rest nunca está en git ni en argv; vive en archivo 0600 / secreto inyectado.
- No baja ninguna otra capa de seguridad (enforce + ACL + TLS + E2E + sealed keys intactas).
# Notas
Aditivo y ortogonal al resto de la seguridad: TLS protege en tránsito, E2E el contenido, las claves
de room van selladas; este issue cierra el último hueco (metadatos de control en claro en disco)
para el modelo de amenaza "VPS comprometido / disco robado". Prioridad media: el despliegue ya es
seguro frente a ataques de red (enforce+TLS+ACL); esto endurece frente a compromiso físico/root del
host. Relacionado con el endurecimiento de los issues 0004/0005/0006.
-165
View File
@@ -1,165 +0,0 @@
// Package mobile exposes a flat, gomobile-friendly API over the unibus client
// so an Android app can join rooms, publish, and receive messages with the same
// end-to-end encryption as any native Go peer.
//
// gomobile only supports a limited set of types across the binding boundary
// (string, []byte, int, bool, error, named structs, and interfaces). This layer
// translates the richer client API into those primitives and delivers incoming
// frames through a Java/Kotlin-implemented FrameListener callback. No protocol
// or cryptography is reimplemented here: every call delegates to pkg/client,
// which is the single source of truth shared with every other peer on the bus.
package mobile
import (
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/room"
)
// FrameListener receives decrypted messages for a subscribed room. The Android
// side implements this interface. Its methods are invoked from a NATS delivery
// goroutine, so implementations must hop back to the UI thread (for example via
// a coroutine on the main dispatcher) before touching Android views.
type FrameListener interface {
OnFrame(roomID string, sender string, msgID string, text string)
}
// Session is a connected unibus peer. Create it with NewSession and close it
// with Close when the app stops.
type Session struct {
c *client.Client
}
// GenerateIdentity creates (or loads) the long-term keypair stored at path.
// Call it once on first launch. The resulting file holds the peer's private
// Ed25519 and X25519 keys and must be kept private to the app sandbox.
func GenerateIdentity(path string) error {
_, err := client.LoadOrCreateIdentity(path)
return err
}
// NewSession loads the identity at idPath and connects to the bus. natsURL is
// the data plane (for example tls://host:4250) and ctrlURL is the control plane
// HTTP endpoint (for example http://host:8470). caPath is the path to the bus
// CA certificate (ca.crt) bundled with the app: when set, the session connects
// securely (TLS pinned to that CA + nkey authentication on the data plane),
// matching a bus running with auth + TLS. Pass an empty caPath to connect in
// plaintext to an unsecured (dev) bus.
func NewSession(idPath, natsURL, ctrlURL, caPath string) (*Session, error) {
id, err := client.LoadOrCreateIdentity(idPath)
if err != nil {
return nil, err
}
c, err := client.Connect(natsURL, ctrlURL, id, caPath)
if err != nil {
return nil, err
}
return &Session{c: c}, nil
}
// EndpointID returns this peer's stable endpoint identifier, derived from its
// signing public key. It is the value that appears as the sender of frames.
func (s *Session) EndpointID() string {
return s.c.Endpoint().ID
}
// CreateRoom opens a room on the given subject. mode is "matrix" for the
// encrypted, persisted and signed policy, or "nats" for plain cleartext. It
// returns the room id used by Join, Publish and Subscribe.
func (s *Session) CreateRoom(subject, mode string) (string, error) {
p := room.ModeNATS
if mode == "matrix" {
p = room.ModeMatrix
}
return s.c.CreateRoom(subject, p)
}
// Join fetches the room key when the room is encrypted and prepares the session
// to publish to and receive from the room.
func (s *Session) Join(roomID string) error {
return s.c.Join(roomID)
}
// Publish sends a UTF-8 text message to the room.
func (s *Session) Publish(roomID, text string) error {
return s.c.Publish(roomID, []byte(text))
}
// Subscribe streams decrypted messages of the room to the listener until the
// session is closed.
func (s *Session) Subscribe(roomID string, l FrameListener) error {
_, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext))
})
return err
}
// cardJSON is the portable, copy-pasteable public identity a peer shares so a
// room owner can invite it to an encrypted room. It carries no secret: only the
// endpoint id and the two public keys (signing + key-exchange), base64-encoded
// for transport over text or a QR code.
type cardJSON struct {
ID string `json:"id"`
SignPub string `json:"sign_pub"` // base64 std of the Ed25519 public key
KexPub string `json:"kex_pub"` // base64 std of the X25519 public key
}
// Card returns this peer's public identity as a portable JSON string. Share it
// (paste, QR) with a room owner so they can Invite you to an encrypted room. It
// contains no private key and is safe to transmit in the clear.
func (s *Session) Card() string {
ep := s.c.Endpoint()
b, _ := json.Marshal(cardJSON{
ID: ep.ID,
SignPub: base64.StdEncoding.EncodeToString(ep.SignPub),
KexPub: base64.StdEncoding.EncodeToString(ep.KexPub),
})
return string(b)
}
// Invite adds the holder of peerCard to roomID. peerCard is the JSON string the
// invitee produced with Card(). For encrypted rooms this seals the current room
// key to the invitee's X25519 public key and signs the request; the caller must
// be the room owner.
func (s *Session) Invite(roomID, peerCard string) error {
var card cardJSON
if err := json.Unmarshal([]byte(peerCard), &card); err != nil {
return fmt.Errorf("mobile: bad peer card: %w", err)
}
signPub, err := base64.StdEncoding.DecodeString(card.SignPub)
if err != nil {
return fmt.Errorf("mobile: bad sign_pub in card: %w", err)
}
kexPub, err := base64.StdEncoding.DecodeString(card.KexPub)
if err != nil {
return fmt.Errorf("mobile: bad kex_pub in card: %w", err)
}
return s.c.Invite(roomID, client.Endpoint{ID: card.ID, SignPub: signPub, KexPub: kexPub})
}
// Kick removes endpointID from roomID and, for encrypted rooms, rotates the room
// key to a new epoch so the removed peer cannot decrypt messages published after
// the kick (forward secrecy). The caller must be the room owner.
func (s *Session) Kick(roomID, endpointID string) error {
return s.c.Kick(roomID, endpointID)
}
// Request performs an RPC request/reply against subject and returns the reply
// payload as text. timeoutMs bounds the wait in milliseconds.
func (s *Session) Request(subject, text string, timeoutMs int) (string, error) {
out, err := s.c.Request(subject, []byte(text), time.Duration(timeoutMs)*time.Millisecond)
if err != nil {
return "", err
}
return string(out), nil
}
// Close disconnects the peer from the bus.
func (s *Session) Close() error {
return s.c.Close()
}
+40
View File
@@ -82,6 +82,15 @@ type PermissionsFunc func(signPubHex string) (*server.Permissions, error)
type nkeyAuthenticatorACL struct { type nkeyAuthenticatorACL struct {
isAuthorized func(signPubHex string) bool isAuthorized func(signPubHex string) bool
perms PermissionsFunc perms PermissionsFunc
// internalPubHex is the lowercase-hex Ed25519 public key of membershipd's own
// ephemeral internal service identity. A connection that proves that key is
// granted full permissions WITHOUT consulting the allowlist, so the service can
// bootstrap and manage JetStream (the replicated nonce bucket and, when
// decentralized, the control-plane KV buckets) against its own embedded server
// even while the data plane confines every client to its rooms. Empty disables
// the internal-identity path entirely (behavior identical to a plain ACL
// authenticator).
internalPubHex string
} }
// NewNkeyAuthenticatorACL builds an authenticator that authorizes by the bus // NewNkeyAuthenticatorACL builds an authenticator that authorizes by the bus
@@ -94,6 +103,29 @@ func NewNkeyAuthenticatorACL(isAuthorized func(signPubHex string) bool, perms Pe
return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms} return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms}
} }
// NewNkeyAuthenticatorACLInternal is NewNkeyAuthenticatorACL that also recognizes
// membershipd's internal service identity (internalPubHex, the lowercase hex of
// its ephemeral Ed25519 public key): a connection proving that key is granted
// full permissions without an allowlist lookup, so the service can create and
// manage JetStream against its own embedded server under enforce (issue 0006a/c —
// the replicated nonce bucket and the control-plane KV). Every other identity
// goes through the allowlist + per-subject ACL unchanged. An empty internalPubHex
// is identical to NewNkeyAuthenticatorACL, so this is a superset and safe to use
// everywhere the plain constructor was used.
func NewNkeyAuthenticatorACLInternal(isAuthorized func(signPubHex string) bool, perms PermissionsFunc, internalPubHex string) server.Authentication {
return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms, internalPubHex: internalPubHex}
}
// fullPermissions grants publish and subscribe on every subject (">"). It is the
// permission set for membershipd's own internal service connection, which must
// manage the JetStream control plane (nonce bucket + KV buckets) over NATS. It is
// NEVER granted to a bus user — only to the process's own ephemeral internal
// identity, recognized by exact public-key match in Check.
func fullPermissions() *server.Permissions {
sp := &server.SubjectPermission{Allow: []string{">"}}
return &server.Permissions{Publish: sp, Subscribe: sp}
}
// Check verifies the nkey, authorizes against the allowlist, then derives and // Check verifies the nkey, authorizes against the allowlist, then derives and
// registers the connection's subject permissions. A permissions-derivation // registers the connection's subject permissions. A permissions-derivation
// error denies the connection (fail closed) rather than granting open access. // error denies the connection (fail closed) rather than granting open access.
@@ -102,6 +134,14 @@ func (a *nkeyAuthenticatorACL) Check(c server.ClientAuthentication) bool {
if !ok { if !ok {
return false return false
} }
// membershipd's own internal service identity bypasses the allowlist and is
// granted full permissions so the service can bootstrap JetStream under
// enforce. The key is matched exactly against the cryptographically verified
// connecting key, so no other identity can claim these permissions.
if a.internalPubHex != "" && signPubHex == a.internalPubHex {
c.RegisterUser(&server.User{Permissions: fullPermissions()})
return true
}
if !a.isAuthorized(signPubHex) { if !a.isAuthorized(signPubHex) {
return false return false
} }
+70
View File
@@ -456,6 +456,23 @@ type memberRoomJSON struct {
Role string `json:"role"` Role string `json:"role"`
} }
// userJSON mirrors the server's wire type on the admin user-management endpoints.
type userJSON struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
}
// addUserReq is the POST /users body (mirror of the server type).
type addUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// ---- room operations ------------------------------------------------------ // ---- room operations ------------------------------------------------------
// RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the // RoomRef is a room this peer belongs to, returned by ListMyRooms. It is the
@@ -490,6 +507,59 @@ func (c *Client) ListMyRooms() ([]RoomRef, error) {
return out, nil return out, nil
} }
// ---- user administration (admin-only) ------------------------------------
// UserInfo is a bus user as returned by the admin user-management endpoints. It
// is a flat view (no nested types) for the admin panel: the signing key
// (lowercase hex), handle, role ("admin"|"member"), status ("active"|"revoked"),
// and timestamps. RevokedAt is empty for an active user.
type UserInfo struct {
SignPub string
Handle string
Role string
Status string
CreatedAt string
RevokedAt string
}
// ListUsers returns the full bus allowlist, including revoked users. The caller
// must be signing as an admin: a non-admin signer is rejected by the server with
// 403, surfaced here as an error.
func (c *Client) ListUsers() ([]UserInfo, error) {
var resp []userJSON
if err := c.doJSON("GET", "/users", nil, &resp); err != nil {
return nil, err
}
out := make([]UserInfo, 0, len(resp))
for _, u := range resp {
out = append(out, UserInfo{
SignPub: u.SignPub,
Handle: u.Handle,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
RevokedAt: u.RevokedAt,
})
}
return out, nil
}
// AddUser registers a bus user from their Ed25519 signing public key (64-hex).
// role is "admin" or "member" (empty defaults to member, matching the server).
// The caller must be signing as an admin. Re-adding an already-registered key
// returns an error (the server replies 409 and leaves the existing row
// untouched — no silent role/status change).
func (c *Client) AddUser(signPub, handle, role string) error {
return c.doJSON("POST", "/users", addUserReq{SignPub: signPub, Handle: handle, Role: role}, nil)
}
// RevokeUser revokes a bus user by their signing public key (64-hex). Revocation
// is a status flip (no hard delete): the identity stays auditable and is denied
// on both planes immediately. The caller must be signing as an admin.
func (c *Client) RevokeUser(signPub string) error {
return c.doJSON("POST", "/users/"+signPub+"/revoke", nil, nil)
}
// newRoomKey returns 32 random bytes for a symmetric room key. // newRoomKey returns 32 random bytes for a symmetric room key.
func newRoomKey() ([]byte, error) { func newRoomKey() ([]byte, error) {
k := make([]byte, 32) k := make([]byte, 32)
+27 -11
View File
@@ -33,20 +33,36 @@ type identityFile struct {
KexPriv string `json:"kex_priv"` KexPriv string `json:"kex_priv"`
} }
// LoadIdentity loads an existing identity from path. Unlike LoadOrCreateIdentity
// it NEVER creates one: a missing or unreadable file is an error. It is for
// callers that must consume a specific, pre-provisioned identity rather than mint
// a fresh one — for example membershipd's persisted internal service identity,
// which `membershipd user add --store kv` reads to present the privileged nkey
// the cluster authenticator recognizes.
func LoadIdentity(path string) (cs.Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
return cs.Identity{}, fmt.Errorf("client: read identity %q: %w", path, err)
}
var f identityFile
if err := json.Unmarshal(data, &f); err != nil {
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
}
id, err := f.toIdentity()
if err != nil {
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
}
return id, nil
}
// LoadOrCreateIdentity loads the identity at path, or generates and persists a // LoadOrCreateIdentity loads the identity at path, or generates and persists a
// new one if the file does not exist. The file is written with 0600 // new one if the file does not exist. The file is written with 0600
// permissions because it holds private keys. // permissions because it holds private keys. A file that exists but is
// unreadable or corrupt is an error (NOT silently regenerated), so a damaged
// identity surfaces instead of minting a new key that cannot decrypt old data.
func LoadOrCreateIdentity(path string) (cs.Identity, error) { func LoadOrCreateIdentity(path string) (cs.Identity, error) {
if data, err := os.ReadFile(path); err == nil { if _, statErr := os.Stat(path); statErr == nil {
var f identityFile return LoadIdentity(path)
if err := json.Unmarshal(data, &f); err != nil {
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
}
id, err := f.toIdentity()
if err != nil {
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
}
return id, nil
} }
id, err := cs.GenerateIdentity() id, err := cs.GenerateIdentity()
+99
View File
@@ -0,0 +1,99 @@
package client_test
import (
"encoding/hex"
"strings"
"testing"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/membership"
)
// findUserInfo returns the row with the given signing key (case-insensitive).
func findUserInfo(users []client.UserInfo, signPub string) (client.UserInfo, bool) {
want := strings.ToLower(signPub)
for _, u := range users {
if strings.ToLower(u.SignPub) == want {
return u, true
}
}
return client.UserInfo{}, false
}
// TestClientUsersAdminAPI drives the admin user-management API through the real
// pkg/client methods against an in-process membershipd under enforce: an admin
// client adds a user, lists it, revokes it, and sees the status flip — and a
// non-admin client is denied. This is the path the admin panel uses, so it locks
// the client/server contract the panel depends on.
func TestClientUsersAdminAPI(t *testing.T) {
h := newHarnessMode(t, membership.AuthEnforce)
waitHealth(t, h.ctrlURL)
admin, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
if err != nil {
t.Fatalf("connect admin: %v", err)
}
defer admin.Close()
registerClient(t, h, admin, "admin", membership.RoleAdmin)
member, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
if err != nil {
t.Fatalf("connect member: %v", err)
}
defer member.Close()
registerClient(t, h, member, "member", membership.RoleMember)
// A brand-new identity the admin will register over HTTP.
carol := mustIdentity(t)
carolPub := hex.EncodeToString(carol.SignPub)
// Admin adds carol as a member.
if err := admin.AddUser(carolPub, "carol", membership.RoleMember); err != nil {
t.Fatalf("admin AddUser: %v", err)
}
// Admin lists: carol present and active.
users, err := admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers: %v", err)
}
row, ok := findUserInfo(users, carolPub)
if !ok {
t.Fatalf("carol missing from list after add: %+v", users)
}
if row.Status != membership.StatusActive || row.Role != membership.RoleMember {
t.Fatalf("carol row wrong after add: %+v", row)
}
// Re-adding the same key is a conflict surfaced as an error (no silent upsert).
if err := admin.AddUser(carolPub, "carol-again", membership.RoleAdmin); err == nil {
t.Fatalf("re-adding carol should error (409), got nil")
}
// Admin revokes carol; list shows the status flip (no hard delete).
if err := admin.RevokeUser(carolPub); err != nil {
t.Fatalf("admin RevokeUser: %v", err)
}
users, err = admin.ListUsers()
if err != nil {
t.Fatalf("admin ListUsers after revoke: %v", err)
}
row, ok = findUserInfo(users, carolPub)
if !ok {
t.Fatalf("carol vanished after revoke (should be a status flip): %+v", users)
}
if row.Status != membership.StatusRevoked {
t.Fatalf("carol should be revoked, got status %q", row.Status)
}
// A non-admin (member) is denied on every user-management method.
if _, err := member.ListUsers(); err == nil {
t.Fatalf("non-admin ListUsers should error (403), got nil")
}
if err := member.AddUser(carolPub, "x", membership.RoleMember); err == nil {
t.Fatalf("non-admin AddUser should error (403), got nil")
}
if err := member.RevokeUser(carolPub); err == nil {
t.Fatalf("non-admin RevokeUser should error (403), got nil")
}
}
+38 -2
View File
@@ -9,6 +9,7 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/url" "net/url"
"os"
"time" "time"
server "github.com/nats-io/nats-server/v2/server" server "github.com/nats-io/nats-server/v2/server"
@@ -106,6 +107,13 @@ func StartHostAuth(storeDir, host string, port int, auth server.Authentication)
// blocks until the server is ready to accept connections (up to 5s) and returns // blocks until the server is ready to accept connections (up to 5s) and returns
// the running server; the caller must Shutdown it. // the running server; the caller must Shutdown it.
func StartServer(cfg ServerConfig) (*server.Server, error) { func StartServer(cfg ServerConfig) (*server.Server, error) {
// Diagnostic toggle: UNIBUS_NATS_DEBUG=1 enables the embedded nats-server's own
// logger (route/RAFT/JetStream errors), which is otherwise silenced. Off by
// default so production behavior is unchanged; only set it when debugging the
// cluster route layer.
debugLevel := os.Getenv("UNIBUS_NATS_DEBUG")
debugNATS := debugLevel == "1" || debugLevel == "2"
traceNATS := debugLevel == "2"
opts := &server.Options{ opts := &server.Options{
JetStream: true, JetStream: true,
StoreDir: cfg.StoreDir, StoreDir: cfg.StoreDir,
@@ -114,8 +122,17 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
ServerName: cfg.ServerName, ServerName: cfg.ServerName,
DontListen: false, DontListen: false,
// Keep the embedded server quiet by default; the host app logs the URLs. // Keep the embedded server quiet by default; the host app logs the URLs.
NoLog: true, NoLog: !debugNATS,
NoSigs: true, Debug: debugNATS,
Trace: traceNATS,
Logtime: true,
NoSigs: true,
}
if debugNATS {
// Expose the nats-server monitoring endpoint (loopback) so the operator can
// inspect /jsz, /routez, /varz while debugging the cluster meta-group.
opts.HTTPHost = "127.0.0.1"
opts.HTTPPort = 8222
} }
if cfg.Auth != nil { if cfg.Auth != nil {
opts.CustomClientAuthentication = cfg.Auth opts.CustomClientAuthentication = cfg.Auth
@@ -141,6 +158,10 @@ func StartServer(cfg ServerConfig) (*server.Server, error) {
return nil, fmt.Errorf("embeddednats: new server: %w", err) return nil, fmt.Errorf("embeddednats: new server: %w", err)
} }
if debugNATS {
ns.ConfigureLogger()
}
go ns.Start() go ns.Start()
if !ns.ReadyForConnections(5 * time.Second) { if !ns.ReadyForConnections(5 * time.Second) {
@@ -162,6 +183,21 @@ func applyClusterOpts(opts *server.Options, c *ClusterConfig) error {
Port: c.Port, Port: c.Port,
Username: c.Username, Username: c.Username,
Password: c.Password, Password: c.Password,
// Disable route connection pooling (nats-server 2.10+ defaults to a pool of
// 3 connections per peer). On a small cluster the pool churns with
// "duplicate route"/"client closed" reconnects that interrupt the meta-group
// RAFT heartbeats, causing perpetual leader re-elections so the JetStream
// meta never becomes current and stream/KV creation hangs (issue 0006g).
// PoolSize=-1 forces the classic single route per peer, which is stable for
// the 3-node unibus cluster.
PoolSize: -1,
// NoAdvertise stops the server from gossiping its locally-discovered IPs to
// peers. The cluster nodes are Docker hosts, so without this NATS advertises
// the docker bridge addresses (172.x / 10.0.x) as reachable routes; peers
// then try to dial those private, mutually-unreachable IPs, churning the
// route layer and destabilizing the JetStream meta-group. With NoAdvertise
// the nodes use ONLY the explicit public-IP routes we configure (issue 0006g).
NoAdvertise: true,
} }
if c.TLS != nil { if c.TLS != nil {
opts.Cluster.TLSConfig = c.TLS opts.Cluster.TLSConfig = c.TLS
+83 -17
View File
@@ -1,31 +1,95 @@
package membership package membership
// Per-subject data-plane access control derived from room membership (issue // Per-subject data-plane access control derived from room membership (issue
// 0003e, audit H4 residual). The control plane already authorizes metadata by // 0003e, audit H4 residual; tightened in issue 0006b for audit 0008 N2). The
// membership; this is the matching restriction on the NATS data plane so a // control plane already authorizes metadata by membership; this is the matching
// registered peer can only publish/subscribe on the subjects of the rooms it // restriction on the NATS data plane so a registered peer can only
// actually belongs to — not on every subject on the bus. // publish/subscribe on the subjects of the rooms it actually belongs to — and can
// only reach the JetStream API of ITS OWN rooms' streams, never the control-plane
// KV buckets.
import ( import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"strings"
"github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/frame"
) )
// clientInfraSubjects are the subjects every peer needs regardless of room // clientInfraSubjects are the subjects every authorized peer needs regardless of
// membership: the request/reply inbox space and the JetStream API (the durable // room membership, kept deliberately MINIMAL (issue 0006b, audit 0008 N2):
// plane of persisted rooms). They are granted to all authorized peers so //
// request/reply and persisted-room history keep working under the subject ACL. // - "_INBOX.>" — request/reply plus the JetStream pull-consumer delivery
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.>"} // and publish-ack inboxes.
// - "$JS.API.INFO" — account-level JetStream info (limits/usage counters). It
// exposes NO room/user/key contents, so granting it leaks nothing.
//
// It NO LONGER contains "$JS.API.>". That broad grant was the N2 leak: it let any
// registered peer drive the whole JetStream API and read the control-plane KV
// buckets (KV_UNIBUS_users/rooms/members/room_keys) and the object store directly
// over NATS, bypassing the HTTP authorization (requireMember and the own-endpoint
// checks). JetStream API access is now granted PER ROOM, scoped to the stream of
// each room the peer belongs to (jsSubjectsFor). Because the control-plane KV
// streams (KV_UNIBUS_*) and the object store (OBJ_UNIBUS_*) are never a room
// stream, they fall outside the closed allow set and are denied by default.
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.INFO"}
// SubjectACLFor returns a function that maps a signing public key (lowercase // roomStreamName is the JetStream stream name a persisted room maps to. It MUST
// hex) to the data-plane subjects that identity may publish and subscribe to: // stay identical to pkg/client.streamName ("UNIBUS_" + sanitized roomID) so the
// the subject of every room it belongs to, plus the client infrastructure // per-room ACL grants exactly the subjects the client's JetStream calls use. Room
// subjects. It reads the live membership store, so the permissions reflect the // ids are ULIDs (no '.'), so the sanitizing is a no-op in practice, but the rule
// identity's rooms at the moment it connects. A decode error or a store failure // is replicated defensively so the producer (client) and the authorizer (this
// is returned as an error so the caller can fail closed (deny the connection) // ACL) never drift apart.
// rather than grant open access. func roomStreamName(roomID string) string {
var b strings.Builder
b.Grow(len("UNIBUS_") + len(roomID))
b.WriteString("UNIBUS_")
for _, r := range roomID {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
b.WriteRune(r)
default:
b.WriteRune('_')
}
}
return b.String()
}
// jsSubjectsFor returns the MINIMAL JetStream API subjects a peer needs to use the
// durable stream of ONE persisted room: create/update/info the stream, manage and
// pull from its durable consumer, and ack deliveries. Every subject embeds this
// room's stream name, so the grant cannot reach another room's stream nor any
// control-plane stream (KV_UNIBUS_* / OBJ_UNIBUS_*). The wildcard layout matches
// the NATS JetStream API subject grammar (the stream name is the trailing token
// of single-verb requests and follows a two-token verb for MSG.GET / MSG.NEXT /
// DURABLE.CREATE):
//
// $JS.API.STREAM.<verb>.<stream> verb in {CREATE,UPDATE,INFO,DELETE,PURGE,...}
// $JS.API.STREAM.MSG.<op>.<stream> op in {GET,DELETE}
// $JS.API.CONSUMER.<verb>.<stream> verb in {LIST,NAMES,CREATE(ephemeral)}
// $JS.API.CONSUMER.<verb>.<stream>.<consumer>... verb in {CREATE,INFO,DELETE}
// $JS.API.CONSUMER.<v1>.<v2>.<stream>.<cons> {MSG.NEXT, DURABLE.CREATE}
// $JS.ACK.<stream>.> message acknowledgements
func jsSubjectsFor(roomID string) []string {
s := roomStreamName(roomID)
return []string{
"$JS.API.STREAM.*." + s,
"$JS.API.STREAM.*.*." + s,
"$JS.API.CONSUMER.*." + s,
"$JS.API.CONSUMER.*." + s + ".>",
"$JS.API.CONSUMER.*.*." + s + ".>",
"$JS.ACK." + s + ".>",
}
}
// SubjectACLFor returns a function that maps a signing public key (lowercase hex)
// to the data-plane subjects that identity may publish and subscribe to: the
// subject of every room it belongs to, the per-room JetStream API subjects of
// those rooms (so persisted-room history keeps working), plus the minimal client
// infrastructure subjects. It reads the live membership store, so the permissions
// reflect the identity's rooms at the moment it connects. A decode error or a
// store failure is returned as an error so the caller can fail closed (deny the
// connection) rather than grant open access.
// //
// Because NATS freezes permissions at connect time, a peer invited to a new room // Because NATS freezes permissions at connect time, a peer invited to a new room
// after connecting must reconnect (client.RefreshSession) to pick up the new // after connecting must reconnect (client.RefreshSession) to pick up the new
@@ -42,10 +106,12 @@ func SubjectACLFor(store Store) func(signPubHex string) ([]string, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("acl: list rooms for %s: %w", endpoint, err) return nil, fmt.Errorf("acl: list rooms for %s: %w", endpoint, err)
} }
subjects := make([]string, 0, len(rooms)+len(clientInfraSubjects)) // clientInfra + per room: the room subject + that room's JetStream API.
subjects := make([]string, 0, len(clientInfraSubjects)+len(rooms)*7)
subjects = append(subjects, clientInfraSubjects...) subjects = append(subjects, clientInfraSubjects...)
for _, r := range rooms { for _, r := range rooms {
subjects = append(subjects, r.Subject) subjects = append(subjects, r.Subject)
subjects = append(subjects, jsSubjectsFor(r.RoomID)...)
} }
return subjects, nil return subjects, nil
} }
+5 -8
View File
@@ -229,14 +229,11 @@ func TestSubjectACLIsolation(t *testing.T) {
// - golden: the member still pub/subs her own room, and the non-member never // - golden: the member still pub/subs her own room, and the non-member never
// captures that traffic. // captures that traffic.
// //
// Residual (DOCUMENTED, not closed here): the client-infra grant includes // Residual now CLOSED (issue 0006b, audit 0008 N2): the client-infra grant no
// "$JS.API.>", shared by all peers so per-connection JetStream works. A peer that // longer includes "$JS.API.>". JetStream API access is granted per-room only
// subscribes specifically to "$JS.API.>" can still observe stream-management // (membership.jsSubjectsFor), so a peer can reach the API of its OWN rooms'
// requests whose subjects embed the stream name derived from a room id. Fully // streams but not the control-plane KV buckets (KV_UNIBUS_*) nor another room's
// closing that needs NATS accounts/permissions isolation per identity (deferred to // stream. See TestAttack0008_N2 for the closed-leak regression.
// the 0003 decentralization line). The high-impact leak the auditor exploited —
// the room subject itself and JetStream advisories captured via "Subscribe(\">\")"
// — is closed.
func TestReaudit_H4_WildcardMetadataLeak(t *testing.T) { func TestReaudit_H4_WildcardMetadataLeak(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db")) store, err := membership.Open(filepath.Join(dir, "unibus.db"))
+33 -10
View File
@@ -85,8 +85,18 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) {
if opTimeout <= 0 { if opTimeout <= 0 {
opTimeout = defaultKVOpTime opTimeout = defaultKVOpTime
} }
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // Bootstrap budget for creating/opening the buckets. On a single node JetStream
defer cancel() // is ready the instant the server starts, so the first attempt succeeds. On a
// COLD multi-node cluster the JetStream meta-group must first elect a leader and
// each node must establish contact with it before its $JS.API responds. A KV
// op is a NATS request/reply: if it is published before the node's JetStream is
// ready the request is dropped (not queued), and a single long-context call then
// just blocks until it times out (issue 0006g). So we RETRY each bucket op with
// short per-attempt contexts until it succeeds or the overall bootstrap budget
// is exhausted; once the cluster is ready the next retry lands and the buckets
// are created, after which they persist and every node opens them quickly.
bootstrapBudget := 120 * time.Second
deadline := time.Now().Add(bootstrapBudget)
s := &jetstreamStore{opTimeout: opTimeout} s := &jetstreamStore{opTimeout: opTimeout}
for _, b := range []struct { for _, b := range []struct {
@@ -99,14 +109,27 @@ func OpenJetStream(js jetstream.JetStream, cfg JetStreamConfig) (Store, error) {
{bucketRoomKeys, &s.keys}, {bucketRoomKeys, &s.keys},
{bucketUsers, &s.users}, {bucketUsers, &s.users},
} { } {
kv, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ var kv jetstream.KeyValue
Bucket: b.name, var lastErr error
Replicas: cfg.Replicas, for {
History: 1, opCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
Storage: jetstream.FileStorage, kv, lastErr = js.CreateOrUpdateKeyValue(opCtx, jetstream.KeyValueConfig{
}) Bucket: b.name,
if err != nil { Replicas: cfg.Replicas,
return nil, fmt.Errorf("membership: open KV bucket %q (replicas=%d): %w", b.name, cfg.Replicas, err) History: 1,
Storage: jetstream.FileStorage,
})
cancel()
if lastErr == nil {
break
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("membership: open KV bucket %q (replicas=%d) after %s: %w", b.name, cfg.Replicas, bootstrapBudget, lastErr)
}
// JetStream not ready yet (no meta leader / request dropped). Wait and
// re-publish the op; in a cluster cold start this lands once the meta
// group settles.
time.Sleep(1 * time.Second)
} }
*b.dst = kv *b.dst = kv
} }
+152
View File
@@ -0,0 +1,152 @@
package membership_test
// Regression for audit report 0008, vector N2: with the broad "$JS.API.>" grant
// removed (issue 0006b), a registered peer that belongs to no room can no longer
// read the control-plane KV buckets over NATS, while the per-room JetStream API of
// a peer's OWN rooms keeps working. The auditor's ephemeral attack populated the
// KV control plane and had a registered non-member harvest the allowlist, the room
// graph and the sealed-key metadata directly through "$JS.API.>".
import (
"context"
"encoding/hex"
"path/filepath"
"testing"
"time"
"github.com/enmanuel/unibus/pkg/busauth"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
server "github.com/nats-io/nats-server/v2/server"
)
// startACLNatsInternal is startACLNats plus a recognized internal service identity
// (so the test can seed the KV control plane with full permissions, exactly as the
// decentralized membershipd does at bootstrap).
func startACLNatsInternal(t *testing.T, store membership.Store, internalPubHex string) *server.Server {
t.Helper()
auth := busauth.NewNkeyAuthenticatorACLInternal(store.IsAuthorized, aclPermsFunc(store), internalPubHex)
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: aclFreePort(t), Auth: auth,
})
if err != nil {
t.Fatalf("acl nats: %v", err)
}
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
return ns
}
// TestAttack0008_N2 reproduces the control-plane KV leak and proves it is closed.
//
// error : eve (registered, member of no room) cannot read the KV buckets — the
// JetStream KV API and the raw $KV subject space are both denied.
// golden: the owner of a persisted room can still drive the JetStream API of HER
// OWN room's stream (so persisted-room history keeps working).
// edge : eve cannot reach another room's stream API either (cross-room JS deny).
func TestAttack0008_N2(t *testing.T) {
dir := t.TempDir()
// The HTTP control-plane store stays SQLite; the KV buckets below stand in for
// the decentralized control plane the attack targets.
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
ceo, eve, internalID := mustID(t), mustID(t), mustID(t)
ceoEP := frame.EndpointID(ceo.SignPub)
mustAddUser(t, store, ceo, "ceo-root-admin")
mustAddUser(t, store, eve, "eve") // registered, member of nothing
// A persisted room owned by ceo: ceo is a member, so her per-room JS is allowed.
if err := store.CreateRoom(
membership.RoomInfo{RoomID: "PRIVROOM", Subject: "room.board.ma-deal", Encrypt: true, Persist: true, OwnerEndpoint: ceoEP},
ceo.SignPub, ceo.KexPub, []byte("sealed-self"),
); err != nil {
t.Fatalf("create room: %v", err)
}
internalPubHex := hex.EncodeToString(internalID.SignPub)
ns := startACLNatsInternal(t, store, internalPubHex)
url := ns.ClientURL()
// Seed the KV control plane with the privileged internal identity (full perms),
// simulating the decentralized buckets the attack reads.
intErr := make(chan error, 4)
intNC := nkeyConn(t, url, internalID, intErr)
intJS, err := jetstream.New(intNC)
if err != nil {
t.Fatalf("internal jetstream: %v", err)
}
kvStore, err := membership.OpenJetStream(intJS, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
if err != nil {
t.Fatalf("open kv buckets: %v", err)
}
if err := kvStore.AddUser(hex.EncodeToString(ceo.SignPub), "ceo-root-admin", membership.RoleAdmin); err != nil {
t.Fatalf("seed kv user: %v", err)
}
// Each JetStream op gets its own short context: a DENIED request never gets a
// reply, so it blocks until its own deadline — a shared context would be
// exhausted by the first denied call and starve the rest.
freshCtx := func(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// --- error: eve cannot read the control-plane KV buckets ------------------
eveErr := make(chan error, 8)
eveNC := nkeyConn(t, url, eve, eveErr)
eveJS, err := jetstream.New(eveNC)
if err != nil {
t.Fatalf("eve jetstream: %v", err)
}
// (a) The KV API: binding the bucket requires STREAM.INFO.KV_UNIBUS_users, which
// eve has no permission for, so this must fail (no leak of users).
kvCtx, kvCancel := freshCtx(2 * time.Second)
if kv, err := eveJS.KeyValue(kvCtx, "UNIBUS_users"); err == nil {
if e, gerr := kv.Get(kvCtx, hex.EncodeToString(ceo.SignPub)); gerr == nil {
kvCancel()
t.Fatalf("eve read the control-plane KV users bucket: %q (N2 leak still open)", string(e.Value()))
}
kvCancel()
t.Fatalf("eve was able to BIND the KV users bucket (N2 leak still open)")
}
kvCancel()
// (b) The raw KV subject space: a direct subscribe must be a permissions
// violation (delivered async to the error handler).
drain(eveErr)
if _, err := eveNC.Subscribe("$KV.UNIBUS_users.>", func(*nats.Msg) {}); err != nil {
t.Fatalf("eve sub $KV: %v", err)
}
_ = eveNC.Flush()
if e := waitErr(eveErr, 1*time.Second); e == nil {
t.Fatalf("eve subscribing to $KV.UNIBUS_users.> must raise a permissions violation")
}
// --- edge: eve cannot reach another room's stream API ---------------------
edgeCtx, edgeCancel := freshCtx(2 * time.Second)
if _, err := eveJS.Stream(edgeCtx, "UNIBUS_PRIVROOM"); err == nil {
edgeCancel()
t.Fatalf("eve reached the foreign room stream API (cross-room JS not isolated)")
}
edgeCancel()
// --- golden: ceo can drive the JetStream API of HER OWN room's stream ------
ceoErr := make(chan error, 4)
ceoNC := nkeyConn(t, url, ceo, ceoErr)
ceoJS, err := jetstream.New(ceoNC)
if err != nil {
t.Fatalf("ceo jetstream: %v", err)
}
goldenCtx, goldenCancel := freshCtx(5 * time.Second)
defer goldenCancel()
if _, err := ceoJS.CreateOrUpdateStream(goldenCtx, jetstream.StreamConfig{
Name: "UNIBUS_PRIVROOM",
Subjects: []string{"room.board.ma-deal"},
Storage: jetstream.FileStorage,
}); err != nil {
t.Fatalf("ceo could not manage her OWN room stream (per-room JS broken): %v", err)
}
}
+57
View File
@@ -0,0 +1,57 @@
package membership_test
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/membership"
)
// TestHealthExposesPosture: /healthz publishes the node's security posture so a
// monitor (or a peer) can detect a cluster member that is not enforce+ACL+TLS
// (audit 0008 N1). The probe stays unauthenticated.
func TestHealthExposesPosture(t *testing.T) {
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
srv.Posture = membership.Posture{Enforce: true, ACL: true, TLS: true, Cluster: true, Store: "kv"}
ts := httptest.NewServer(srv)
t.Cleanup(ts.Close)
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("get healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("healthz status %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
var got struct {
Status string `json:"status"`
Posture membership.Posture `json:"posture"`
}
if err := json.Unmarshal(body, &got); err != nil {
t.Fatalf("decode healthz %q: %v", string(body), err)
}
if got.Status != "ok" {
t.Fatalf("status = %q, want ok", got.Status)
}
if !got.Posture.Enforce || !got.Posture.ACL || !got.Posture.TLS || !got.Posture.Cluster {
t.Fatalf("posture not surfaced correctly: %+v", got.Posture)
}
if got.Posture.Store != "kv" {
t.Fatalf("posture.store = %q, want kv", got.Posture.Store)
}
}
+88
View File
@@ -0,0 +1,88 @@
package membership_test
import (
"path/filepath"
"testing"
"time"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/enmanuel/unibus/pkg/room"
)
// TestClientCreateRoomRefreshPublishFlow is the issue 0006e DoD: under enforce+ACL
// a peer creates a room AFTER connecting, and pub/sub works without manual
// intervention because the client follows the membership-change contract
// (CreateRoom -> RefreshSession -> Subscribe/Publish), exactly as cmd/chat and
// cmd/worker now do. This is the end-to-end flow through the client API, proving
// the ACL is usable under enforce rather than something an operator must disable.
func TestClientCreateRoomRefreshPublishFlow(t *testing.T) {
dir := t.TempDir()
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { store.Close() })
alice, bob := mustID(t), mustID(t)
mustAddUser(t, store, alice, "alice")
mustAddUser(t, store, bob, "bob")
srv := startACLNats(t, store) // data plane: enforce + per-subject ACL
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
ctrl := newCtrl(t, store, blobs)
aliceC, err := client.NewWithOptions(srv.ClientURL(), ctrl, alice, client.Options{UseNkey: true})
if err != nil {
t.Fatalf("connect alice: %v", err)
}
defer aliceC.Close()
bobC, err := client.NewWithOptions(srv.ClientURL(), ctrl, bob, client.Options{UseNkey: true})
if err != nil {
t.Fatalf("connect bob: %v", err)
}
defer bobC.Close()
// alice creates a room AFTER connecting: the subject was not in her ACL at
// connect time, so she must refresh to publish on it (the worker contract).
roomID, err := aliceC.CreateRoom("room.flow.x", room.ModeNATS)
if err != nil {
t.Fatalf("alice create room: %v", err)
}
if err := aliceC.RefreshSession(); err != nil {
t.Fatalf("alice refresh: %v", err)
}
// alice invites bob; bob joins then refreshes to gain the subject (the chat
// subscriber contract), and only then subscribes.
if err := aliceC.Invite(roomID, bobC.Endpoint()); err != nil {
t.Fatalf("alice invite bob: %v", err)
}
if err := bobC.Join(roomID); err != nil {
t.Fatalf("bob join: %v", err)
}
if err := bobC.RefreshSession(); err != nil {
t.Fatalf("bob refresh: %v", err)
}
got := make(chan string, 4)
sub, err := bobC.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) { got <- string(plaintext) })
if err != nil {
t.Fatalf("bob subscribe after refresh: %v", err)
}
defer sub.Unsubscribe()
time.Sleep(200 * time.Millisecond) // let the subscription settle
if err := aliceC.Publish(roomID, []byte("hello-under-acl")); err != nil {
t.Fatalf("alice publish after refresh: %v", err)
}
select {
case msg := <-got:
if msg != "hello-under-acl" {
t.Fatalf("bob got %q", msg)
}
case <-time.After(3 * time.Second):
t.Fatalf("bob did not receive the message: the create->refresh->subscribe flow is broken under enforce+ACL")
}
}
+193 -8
View File
@@ -81,6 +81,25 @@ type Server struct {
// (non-loopback) bind. See dev/0004d-dataplane-acl.md for the full rationale // (non-loopback) bind. See dev/0004d-dataplane-acl.md for the full rationale
// and the residual metadata exposure this does NOT close. // and the residual metadata exposure this does NOT close.
RequireEncryptedRooms bool RequireEncryptedRooms bool
// Posture is the node's security posture, surfaced on /healthz so an operator
// or a peer can detect a node NOT running the homogeneous enforce+ACL+TLS
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
// the zero value (all false) reflects an unsecured dev node.
Posture Posture
}
// Posture describes the security posture a membershipd node runs with. It is
// non-secret operational metadata (booleans + the store backend name), published
// on /healthz so a monitor can flag a cluster member that is not enforce+ACL+TLS
// — the weak node that would let an unauthenticated peer harvest the cluster's
// forwarded traffic (audit 0008 N1).
type Posture struct {
Enforce bool `json:"enforce"`
ACL bool `json:"acl"`
TLS bool `json:"tls"`
Cluster bool `json:"cluster"`
Store string `json:"store"` // "sqlite" | "kv"
} }
// NewServer wires the membership store and blob store into an http.Handler. The // NewServer wires the membership store and blob store into an http.Handler. The
@@ -194,9 +213,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error()) writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
return return
} }
// Carry the authenticated signer's endpoint into the handler so room handlers // Carry the authenticated signer's endpoint AND signing key into the handler.
// can authorize by membership (audit H3). Only set on a verified identity. // Room handlers authorize by membership via the endpoint (audit H3); the
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint))) // user-management handlers authorize by role via the signing key (the endpoint
// id is a one-way hash of the key, so it cannot be reversed to look the signer
// up in the user allowlist). Both are set only on a verified identity.
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex)))
} }
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader // isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
@@ -210,11 +232,19 @@ func isBodyTooLarge(err error) bool {
// values cannot collide with keys set by other packages. // values cannot collide with keys set by other packages.
type ctxKey int type ctxKey int
const ctxSignerEndpoint ctxKey = iota const (
ctxSignerEndpoint ctxKey = iota
ctxSignerPub
)
// withSigner returns a context carrying the authenticated signer's endpoint id. // withSigner returns a context carrying the authenticated signer's endpoint id
func withSigner(ctx context.Context, endpoint string) context.Context { // and signing public key (lowercase hex). The endpoint authorizes room
return context.WithValue(ctx, ctxSignerEndpoint, endpoint) // membership; the signing key authorizes user-management by role, because the
// endpoint id is a one-way hash of the key (base64url(sha256(signPub))) and so
// cannot be reversed to look the signer up in the user allowlist.
func withSigner(ctx context.Context, endpoint, pubHex string) context.Context {
ctx = context.WithValue(ctx, ctxSignerEndpoint, endpoint)
return context.WithValue(ctx, ctxSignerPub, pubHex)
} }
// signerEndpoint returns the authenticated signer's endpoint id and whether one // signerEndpoint returns the authenticated signer's endpoint id and whether one
@@ -226,6 +256,16 @@ func signerEndpoint(r *http.Request) (string, bool) {
return v, ok && v != "" return v, ok && v != ""
} }
// signerPubHex returns the authenticated signer's signing public key (lowercase
// hex) and whether one is present. Like signerEndpoint it is absent under
// AuthOff and on a soft-mode pass-through; the user-management handlers treat
// that absence as "no admin identity" and deny (default-deny), since a
// privilege-granting operation must never run without a verified admin.
func signerPubHex(r *http.Request) (string, bool) {
v, ok := r.Context().Value(ctxSignerPub).(string)
return v, ok && v != ""
}
// requireMember authorizes a room request by membership (audit H3): it returns // requireMember authorizes a room request by membership (audit H3): it returns
// the signer endpoint and true when the request may proceed, or writes 403 and // the signer endpoint and true when the request may proceed, or writes 403 and
// returns false when an authenticated signer is not a member of roomID. When no // returns false when an authenticated signer is not a member of roomID. When no
@@ -243,6 +283,31 @@ func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID st
return signer, true return signer, true
} }
// requireAdmin authorizes a user-management request: it returns the signer's
// signing-key hex and true ONLY when the authenticated signer is a user with
// role admin and active status; otherwise it writes 403 and returns false.
//
// Default-deny, with no dev relaxation: unlike requireMember (which allows a
// request when no authenticated signer is present, preserving AuthOff/dev
// behavior for room reads), this denies whenever the signer is absent or is not
// a verified active admin. The user-management endpoints grant and revoke bus
// access, so they must never be reachable without a verified admin identity —
// the store is consulted on every call so a just-revoked admin is denied
// immediately, and any store error fails closed.
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
pubHex, ok := signerPubHex(r)
if !ok {
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
return "", false
}
u, err := s.store.GetUser(pubHex)
if err != nil || u.Role != RoleAdmin || u.Status != StatusActive {
writeErr(w, http.StatusForbidden, "forbidden: admin role required")
return "", false
}
return pubHex, true
}
// isAuthExempt lists requests that bypass control-plane auth even under enforce. // isAuthExempt lists requests that bypass control-plane auth even under enforce.
// Only the unauthenticated health probe qualifies: it carries no data and is // Only the unauthenticated health probe qualifies: it carries no data and is
// needed by load balancers / smoke checks / systemd before any identity exists. // needed by load balancers / smoke checks / systemd before any identity exists.
@@ -261,6 +326,13 @@ func (s *Server) routes() {
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom) s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
s.mux.HandleFunc("POST /blobs", s.handlePutBlob) s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob) s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
// User-management (admin-only) — the HTTP-signed equivalent of the local
// `membershipd user` CLI, so the admin panel manages the bus allowlist by
// signing as an admin instead of needing direct store/KV access. All three
// pass through requireAdmin; they hit the same store the room handlers do.
s.mux.HandleFunc("GET /users", s.handleListUsers)
s.mux.HandleFunc("POST /users", s.handleAddUser)
s.mux.HandleFunc("POST /users/{signpub}/revoke", s.handleRevokeUser)
} }
// ---- wire types ----------------------------------------------------------- // ---- wire types -----------------------------------------------------------
@@ -338,6 +410,27 @@ type blobResp struct {
Hash string `json:"hash"` Hash string `json:"hash"`
} }
// userJSON is the wire representation of a bus user on the admin endpoints. It
// carries the full record the panel needs to render the allowlist, including
// status (so revoked users are visible) and the timestamps. revoked_at is
// omitted for an active user.
type userJSON struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
RevokedAt string `json:"revoked_at,omitempty"`
}
// addUserReq is the POST /users body: the new user's Ed25519 signing key
// (64-hex), human handle, and role. role is optional and defaults to member.
type addUserReq struct {
SignPub string `json:"sign_pub"`
Handle string `json:"handle"`
Role string `json:"role"`
}
// ---- helpers -------------------------------------------------------------- // ---- helpers --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) { func writeJSON(w http.ResponseWriter, code int, v any) {
@@ -390,7 +483,7 @@ func (s *Server) verifyOwnerSig(roomID, by string, sig, canonical []byte) (Membe
// ---- handlers ------------------------------------------------------------- // ---- handlers -------------------------------------------------------------
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "posture": s.Posture})
} }
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
@@ -655,3 +748,95 @@ func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(data) _, _ = w.Write(data)
} }
// ---- user-management handlers (admin-only) --------------------------------
// handleListUsers returns the full bus allowlist, including revoked users, so an
// admin sees the complete picture (a revoked identity stays auditable). Admin-only.
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
users, err := s.store.ListUsers()
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
out := make([]userJSON, 0, len(users))
for _, u := range users {
out = append(out, userJSON{
SignPub: u.SignPub,
Handle: u.Handle,
Role: u.Role,
Status: u.Status,
CreatedAt: u.CreatedAt,
RevokedAt: u.RevokedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// handleAddUser registers a new bus user from an admin-supplied Ed25519 signing
// key. It mirrors the `membershipd user add` CLI: the key must be 64-hex, the
// role must be admin or member (empty defaults to member), and re-adding an
// already-registered key is a 409 that leaves the existing row untouched — no
// silent upsert that could flip a role or clobber status. Admin-only.
func (s *Server) handleAddUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
var req addUserReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
if req.SignPub == "" || req.Handle == "" {
writeErr(w, http.StatusBadRequest, "sign_pub and handle required")
return
}
if err := ValidateSignPubHex(req.SignPub); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
role := req.Role
if role == "" {
role = RoleMember
}
if role != RoleAdmin && role != RoleMember {
writeErr(w, http.StatusBadRequest,
fmt.Sprintf("invalid role %q (want %q or %q)", role, RoleAdmin, RoleMember))
return
}
if err := s.store.AddUser(req.SignPub, req.Handle, role); err != nil {
if errors.Is(err, ErrUserExists) {
// Idempotency contract (mirrors the CLI): re-adding a key is an explicit,
// non-destructive conflict. To replace a user, revoke then add again.
writeErr(w, http.StatusConflict,
"user already registered (unchanged); revoke it first to replace")
return
}
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
writeJSON(w, http.StatusCreated, map[string]string{"status": "added"})
}
// handleRevokeUser revokes a bus user by signing key. Revocation is a status
// flip (no hard delete) so the identity stays auditable and IsAuthorized denies
// it on both planes immediately. Revoking an unknown or already-revoked key is a
// 404. Admin-only.
func (s *Server) handleRevokeUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
signPub := r.PathValue("signpub")
if err := ValidateSignPubHex(signPub); err != nil {
writeErr(w, http.StatusBadRequest, err.Error())
return
}
if err := s.store.RevokeUser(signPub); err != nil {
writeServerErr(w, r, http.StatusNotFound, "no active user with that key", err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"})
}
+18
View File
@@ -2,6 +2,7 @@ package membership
import ( import (
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@@ -35,6 +36,23 @@ type User struct {
RevokedAt string // empty unless revoked RevokedAt string // empty unless revoked
} }
// ValidateSignPubHex ensures signPub is exactly a 32-byte Ed25519 public key in
// hex (64 hex chars). It is the single source of truth for that check, shared by
// the local admin CLI (which validates before seeding the first admin) and the
// HTTP user-management handlers (which validate an admin-supplied key before it
// reaches the store). Catching a malformed key here turns a silent "authorized
// nobody" into an explicit error at the boundary.
func ValidateSignPubHex(signPub string) error {
b, err := hex.DecodeString(signPub)
if err != nil {
return fmt.Errorf("sign-pub is not valid hex: %w", err)
}
if len(b) != 32 {
return fmt.Errorf("sign-pub must be a 32-byte Ed25519 public key (64 hex chars), got %d bytes", len(b))
}
return nil
}
// normalizeSignPub lowercases the hex key so lookups are case-insensitive: the // normalizeSignPub lowercases the hex key so lookups are case-insensitive: the
// primary key is stored lowercase and every query normalizes its input the same // primary key is stored lowercase and every query normalizes its input the same
// way, so a caller passing uppercase hex still matches. // way, so a caller passing uppercase hex still matches.
+164
View File
@@ -0,0 +1,164 @@
package membership
import (
"encoding/hex"
"encoding/json"
"net/http"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
)
// signedJSON is signedReq for a JSON body: it marshals v and signs the request
// as id with a distinct nonce. It returns the response status and body, reusing
// the auth_test harness so these tests exercise the real signed wire contract.
func signedJSON(t *testing.T, h *authHarness, method, path string, v any, id cs.Identity, n int) (int, string) {
t.Helper()
var body []byte
if v != nil {
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
body = b
}
return do(t, signedReq(t, h.ts.URL, method, path, body, id, time.Now().Unix(), nonceN(n)))
}
// TestUsersHTTP_NonAdminForbidden is the security spine: a REGISTERED but
// non-admin signer (bob, role member) is denied on every user-management
// endpoint. His signature clears auth (he is in the allowlist), so each request
// reaches the handler, where requireAdmin returns 403 — default-deny by role.
func TestUsersHTTP_NonAdminForbidden(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob") // role member (see register in authz_test.go)
bobPub := hex.EncodeToString(bob.SignPub)
victim, _ := cs.GenerateIdentity()
victimPub := hex.EncodeToString(victim.SignPub)
checks := []struct {
name string
method string
path string
body any
}{
{"list users", "GET", "/users", nil},
{"add user", "POST", "/users", addUserReq{SignPub: victimPub, Handle: "mallory", Role: RoleMember}},
{"revoke user", "POST", "/users/" + bobPub + "/revoke", nil},
}
for i, c := range checks {
code, body := signedJSON(t, h, c.method, c.path, c.body, bob, i+1)
if code != http.StatusForbidden {
t.Fatalf("non-admin %s should be 403, got %d (%s)", c.name, code, body)
}
}
}
// TestUsersHTTP_AdminRoundtrip exercises the golden path end to end: alice (the
// seeded admin) adds carol, sees her in the list as active, revokes her, then
// sees her status flip to revoked (no hard delete — she stays in the list).
func TestUsersHTTP_AdminRoundtrip(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
carol, _ := cs.GenerateIdentity()
carolPub := hex.EncodeToString(carol.SignPub)
// Add carol as a member.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: carolPub, Handle: "carol", Role: RoleMember}, h.alice, 1); code != http.StatusCreated {
t.Fatalf("admin add carol should be 201, got %d (%s)", code, body)
}
// List: carol present and active; alice (the seed admin) also present.
users := listUsers(t, h, 2)
carolRow, ok := findUser(users, carolPub)
if !ok {
t.Fatalf("carol missing from list after add: %+v", users)
}
if carolRow.Status != StatusActive || carolRow.Role != RoleMember || carolRow.Handle != "carol" {
t.Fatalf("carol row wrong after add: %+v", carolRow)
}
if _, ok := findUser(users, h.alicePub); !ok {
t.Fatalf("seeded admin alice missing from list: %+v", users)
}
// Revoke carol.
if code, body := signedJSON(t, h, "POST", "/users/"+carolPub+"/revoke", nil, h.alice, 3); code != http.StatusOK {
t.Fatalf("admin revoke carol should be 200, got %d (%s)", code, body)
}
// List again: carol still present, now revoked (status flip, not delete).
users = listUsers(t, h, 4)
carolRow, ok = findUser(users, carolPub)
if !ok {
t.Fatalf("carol vanished from list after revoke (should be a status flip): %+v", users)
}
if carolRow.Status != StatusRevoked {
t.Fatalf("carol should be revoked, got status %q", carolRow.Status)
}
}
// TestUsersHTTP_Validation covers the input-validation contract: a malformed hex
// key is 400, an unknown role is 400, and re-adding an already-registered key is
// 409 (the existing row is left untouched — no silent upsert).
func TestUsersHTTP_Validation(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
good, _ := cs.GenerateIdentity()
goodPub := hex.EncodeToString(good.SignPub)
// Invalid hex (too short) -> 400.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: "abcd", Handle: "shorty", Role: RoleMember}, h.alice, 1); code != http.StatusBadRequest {
t.Fatalf("malformed sign_pub should be 400, got %d (%s)", code, body)
}
// Invalid role -> 400.
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: goodPub, Handle: "weirdrole", Role: "superuser"}, h.alice, 2); code != http.StatusBadRequest {
t.Fatalf("invalid role should be 400, got %d (%s)", code, body)
}
// Re-adding the seeded admin's own key -> 409 (idempotency, no overwrite).
if code, body := signedJSON(t, h, "POST", "/users",
addUserReq{SignPub: h.alicePub, Handle: "alice-again", Role: RoleMember}, h.alice, 3); code != http.StatusConflict {
t.Fatalf("re-adding an existing key should be 409, got %d (%s)", code, body)
}
// And the existing row is untouched: alice is still an active admin.
u, err := h.store.GetUser(h.alicePub)
if err != nil {
t.Fatalf("get alice after conflicting re-add: %v", err)
}
if u.Role != RoleAdmin || u.Status != StatusActive || u.Handle != "alice" {
t.Fatalf("conflicting re-add mutated the existing row: %+v", u)
}
}
// listUsers signs a GET /users as alice and decodes the response.
func listUsers(t *testing.T, h *authHarness, n int) []userJSON {
t.Helper()
code, body := signedJSON(t, h, "GET", "/users", nil, h.alice, n)
if code != http.StatusOK {
t.Fatalf("admin list users should be 200, got %d (%s)", code, body)
}
var users []userJSON
if err := json.Unmarshal([]byte(body), &users); err != nil {
t.Fatalf("decode users: %v (%s)", err, body)
}
return users
}
// findUser returns the row with the given signing key (case-insensitive).
func findUser(users []userJSON, signPub string) (userJSON, bool) {
want := normalizeSignPub(signPub)
for _, u := range users {
if normalizeSignPub(u.SignPub) == want {
return u, true
}
}
return userJSON{}, false
}
-119
View File
@@ -1,119 +0,0 @@
# unibus playground
An all-in-one, web-based sandbox for the **unibus** message bus. One command
brings up the entire stack embedded — no NATS to install, no services to wire —
and a browser UI lets you exercise the bus visually: create peers, create and
join rooms (cleartext or end-to-end encrypted), invite, publish, watch messages
arrive live, and kick members (forward secrecy).
This is a **playground** (see `.claude/rules/playgrounds.md`): it lives inside
the `unibus` app, reuses the parent Go module (no separate `go.mod`), is not
indexed, and keeps all runtime state under `playground/local_files/` (ephemeral,
safe to delete).
## Run
From the `unibus` app directory:
```bash
cd /home/enmanuel/fn_registry/projects/message_bus/apps/unibus
go run ./playground
```
Then open **http://localhost:7700** in your browser.
Stop with `Ctrl-C` — the server tears down the web UI, every bus client, the
control plane, and the embedded NATS cleanly (no orphaned processes).
## Architecture
The browser never speaks NATS. The Go server is the actual bus peer:
```
browser ──fetch/SSE──▶ playground server (:7700)
│ holds one unibus client per named peer
├──HTTP──▶ membership control plane (127.0.0.1:8480)
└──NATS──▶ embedded NATS + JetStream (:4260)
```
- **:7700** — web UI (the only browser-facing port).
- **127.0.0.1:8480** — membership control plane (rooms, members, sealed keys,
rekey, blobs). Internal only.
- **:4260** — embedded NATS + JetStream (the data plane). Internal only.
Each named peer gets its own long-term identity, persisted to
`playground/local_files/<name>.id`, so a peer keeps the same endpoint across
restarts. When a peer creates or joins a room, the server subscribes on its
behalf and streams every received frame to that peer's open browser tabs over
Server-Sent Events.
The playground only orchestrates the public unibus client API
(`CreateRoom`, `Join`, `Subscribe`, `Publish`, `Invite`, `Kick`); it never
reimplements bus or crypto logic.
## Try it: 2 peers + encryption + kick
1. Open **two browser tabs** on http://localhost:7700.
2. Tab A: type `alice`, click **Connect**.
3. Tab B: type `bob`, click **Connect**.
4. Tab A (alice): type a subject like `room.general`, tick **🔒 encrypted
(E2E)**, click **Create room**. Copy the resulting `room_id`.
5. Tab A (alice): in the Action panel, pick `bob` as the target peer (use the
↻ button to refresh the peer list if needed) and click **Invite to this
room**.
6. Tab B (bob): paste the `room_id` into the join field and click **Join**.
7. Type messages in **both** tabs and hit Send — each message appears live in
both tabs, tagged with subject, sender, time, and 🔒 (encrypted) or `clear`.
8. Tab A (alice): click **Kick from this room** with `bob` selected. The room
key rotates to a new epoch. New messages alice sends are no longer visible to
bob — **forward secrecy**: bob no longer holds the current key.
Cleartext rooms (leave the checkbox unticked) behave like plain NATS fan-out:
fast, ephemeral, unsigned. Encrypted rooms are the Matrix-like mode: E2E
encrypted, persisted, and per-message signed.
## Benchmark: throughput simulator
The bottom panel of the UI is a performance simulator. Press **▶ Ejecutar
benchmark** and one publisher floods a fresh room with thousands of messages
that N subscribers receive (fan-out); a live canvas chart animates the sent vs
received totals while it runs.
The two policy axes are exposed as **independent flags**, so the benchmark
measures the cost of each layer in isolation:
| JetStream | Encryption | Room policy | What it costs |
|---|---|---|---|
| off | off | `{Encrypt:false, Persist:false}` | plain core NATS fan-out |
| **on** | off | `{Encrypt:false, Persist:true}` | durable JetStream (publish ack per message) |
| off | **on** | `{Encrypt:true, Persist:false}` | AEAD + Ed25519 signature per message, core transport |
| **on** | **on** | `{Encrypt:true, Persist:true}` | full E2E + durable history |
A **payload size** slider (16 B 8 KiB) sets the message size. Encrypted or
persistent runs are capped to 30 000 messages (each message pays per-message
crypto and/or a JetStream ack, so they run much slower than plain NATS).
The benchmark uses its own ephemeral peers (fresh identities, never persisted),
so it never touches the named peers of the manual sandbox.
It is driven by an SSE endpoint that streams progress samples:
```bash
curl -N "http://localhost:7700/api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0"
# emits: data: {"type":"start",...} data: {"type":"sample",...} data: {"type":"done",...}
```
Query params: `n_msgs`, `n_subs` (116), `payload` (bytes), `encrypt` (0/1),
`persist` (0/1).
## State / cleanup
All writable state lives under `playground/local_files/`:
- `<name>.id` — per-peer identity (private keys; treat like an SSH key).
- `play.db` — membership store (rooms, members, sealed keys).
- `blobs/` — media blob store.
- `js/` — embedded JetStream store.
Delete the whole `playground/local_files/` directory to reset to a clean slate.
It is gitignored and never distributed.
-594
View File
@@ -1,594 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>unibus playground</title>
<style>
:root {
--bg: #0d1117;
--panel: #161b22;
--panel2: #1c2230;
--border: #2b333f;
--fg: #e6edf3;
--muted: #8b98a5;
--accent: #2f81f7;
--green: #3fb950;
--gold: #d29922;
--red: #f85149;
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 14px;
line-height: 1.5;
}
header {
padding: 14px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: baseline;
gap: 12px;
}
header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; }
header .sub { color: var(--muted); font-size: 12px; }
.wrap {
display: grid;
grid-template-columns: 360px 1fr;
gap: 16px;
padding: 16px 20px;
max-width: 1200px;
}
.col { display: flex; flex-direction: column; gap: 14px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.card h2 {
margin: 0 0 10px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 3px; }
input[type=text], select {
width: 100%;
background: var(--panel2);
border: 1px solid var(--border);
color: var(--fg);
padding: 7px 9px;
border-radius: 6px;
font-family: var(--mono);
font-size: 13px;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
.row { display: flex; gap: 8px; align-items: center; }
.row > * { flex: 1; }
.checkrow { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
.checkrow input { flex: 0 0 auto; width: auto; }
.checkrow label { margin: 0; flex: 0 0 auto; }
button {
background: var(--accent);
border: none;
color: #fff;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-family: var(--mono);
font-size: 13px;
margin-top: 8px;
}
button:hover { filter: brightness(1.12); }
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
button.danger { background: #3a1d1d; border: 1px solid var(--red); color: var(--red); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.pill {
display: inline-block;
background: var(--panel2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2px 9px;
font-size: 11px;
color: var(--muted);
}
.pill.on { color: var(--green); border-color: var(--green); }
.ident { word-break: break-all; font-size: 11px; color: var(--gold); margin-top: 6px; }
.copy {
cursor: pointer; color: var(--accent); font-size: 11px;
margin-left: 6px; text-decoration: underline;
}
#log {
background: #08090c;
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
height: 520px;
overflow-y: auto;
font-size: 12.5px;
white-space: pre-wrap;
}
.msg { padding: 2px 0; border-bottom: 1px solid #11151b; }
.msg .subj { color: var(--accent); }
.msg .from { color: var(--gold); }
.msg .meta { color: var(--muted); font-size: 11px; }
.msg .enc { color: var(--green); }
.msg .clear { color: var(--muted); }
.sys { color: var(--muted); font-style: italic; }
.err { color: var(--red); }
.help {
background: var(--panel2);
border-left: 3px solid var(--accent);
padding: 10px 12px;
border-radius: 4px;
font-size: 12px;
color: var(--muted);
line-height: 1.6;
}
.help b { color: var(--fg); }
.help code { color: var(--gold); }
.status { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
.status.ok { color: var(--green); }
.status.bad { color: var(--red); }
</style>
</head>
<body>
<header>
<h1>unibus playground</h1>
<span class="sub">embedded NATS + JetStream &middot; E2E rooms &middot; forward secrecy &middot; SSE</span>
</header>
<div class="wrap">
<!-- LEFT COLUMN: controls -->
<div class="col">
<div class="card">
<h2>1 &middot; Identity</h2>
<label>Peer name</label>
<div class="row">
<input id="peerName" type="text" placeholder="alice" autocomplete="off" />
<button id="connectBtn" style="flex:0 0 auto">Connect</button>
</div>
<div id="peerIdent" class="ident"></div>
<div id="connStatus" class="status"></div>
</div>
<div class="card">
<h2>2 &middot; Rooms</h2>
<label>Subject (e.g. room.general)</label>
<input id="roomSubject" type="text" placeholder="room.general" autocomplete="off" />
<div class="checkrow">
<input id="roomEncrypt" type="checkbox" />
<label for="roomEncrypt">&#128274; encrypted (E2E)</label>
</div>
<div class="checkrow">
<input id="roomPersist" type="checkbox" />
<label for="roomPersist">&#128450; persistente (historial)</label>
</div>
<div class="help" style="margin:-4px 0 8px; font-size:12px; color:var(--muted)">
persistente = quien se une despues ve el historial; sin persistir = solo mensajes nuevos (NATS simple).
</div>
<button id="createRoomBtn" disabled>Create room</button>
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
<label>Join by room_id</label>
<input id="joinRoomId" type="text" placeholder="01J..." autocomplete="off" />
<button id="joinBtn" class="ghost" disabled>Join</button>
<div id="roomStatus" class="status"></div>
</div>
<div class="card">
<h2>3 &middot; Action</h2>
<label>Active room</label>
<select id="activeRoom"></select>
<label>Message</label>
<div class="row">
<input id="msgText" type="text" placeholder="hello bus" autocomplete="off" />
<button id="sendBtn" style="flex:0 0 auto" disabled>Send</button>
</div>
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
<label>Target peer</label>
<div class="row">
<select id="targetPeer"></select>
<button id="refreshPeersBtn" class="ghost" style="flex:0 0 auto" title="reload peer list">&#8635;</button>
</div>
<button id="inviteBtn" disabled>Invite to this room</button>
<button id="kickBtn" class="danger" disabled>Kick from this room</button>
<div id="actionStatus" class="status"></div>
</div>
</div>
<!-- RIGHT COLUMN: live messages + help -->
<div class="col">
<div class="card" style="padding-bottom:8px">
<h2>Live messages <span id="streamPill" class="pill">disconnected</span></h2>
<div id="log"></div>
</div>
<div class="help">
<b>&#9432; How to try it</b><br />
Open <b>2 tabs</b>. Connect as <code>alice</code> in one and <code>bob</code> in the other.
In alice: create a <code>&#128274; encrypted</code> room, copy the <code>room_id</code>,
then pick <code>bob</code> as target and <b>Invite to this room</b>.
In bob: paste that <code>room_id</code> and <b>Join</b>.
Type in both &rarr; messages appear live on each side.
In alice: <b>Kick</b> bob &rarr; bob stops seeing new messages (forward secrecy: the room
key rotates and bob no longer holds it).
</div>
</div>
</div>
<!-- BENCHMARK: full-width performance simulator -->
<div style="padding: 0 20px 32px; max-width: 1200px;">
<div class="card">
<h2>Benchmark de rendimiento &middot; 1 publisher &rarr; N subscribers</h2>
<div style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-end; margin-bottom:6px;">
<div style="min-width:230px;">
<label>Mensajes a publicar &middot; <span id="bMsgsVal" style="color:var(--fg)">20 000</span></label>
<input id="bMsgs" type="range" min="1000" max="200000" step="1000" value="20000" style="width:100%; accent-color:var(--accent);" />
</div>
<div style="min-width:160px;">
<label>Subscribers &middot; <span id="bSubsVal" style="color:var(--fg)">3</span></label>
<input id="bSubs" type="range" min="1" max="16" step="1" value="3" style="width:100%; accent-color:var(--accent);" />
</div>
<div style="min-width:200px;">
<label>Tamaño payload &middot; <span id="bPayVal" style="color:var(--fg)">128 B</span></label>
<input id="bPay" type="range" min="16" max="8192" step="16" value="128" style="width:100%; accent-color:var(--accent);" />
</div>
<div class="checkrow" style="margin:0;">
<input id="bPersist" type="checkbox" />
<label for="bPersist">&#128450; JetStream (persistente)</label>
</div>
<div class="checkrow" style="margin:0;">
<input id="bEncrypt" type="checkbox" />
<label for="bEncrypt">&#128274; Encriptación E2E</label>
</div>
<button id="bRun" style="margin:0;">&#9654; Ejecutar benchmark</button>
</div>
<div class="help" style="margin:6px 0 12px;">
<b>JetStream</b> y <b>Encriptación</b> son ejes independientes: NATS core (ambos off) &middot; JetStream durable &middot; E2E (AEAD + firma Ed25519 por mensaje) &middot; E2E + JetStream. Los modos con cripto o persistencia se limitan a 30&nbsp;000 mensajes (cada mensaje paga cifrado/firma/ack).
</div>
<div style="display:flex; gap:30px; flex-wrap:wrap; margin:4px 2px 8px;">
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Enviados</div><div id="bSent" style="font-size:22px; color:var(--accent);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Recibidos (&Sigma; subs)</div><div id="bRecv" style="font-size:22px; color:var(--green);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Throughput recv</div><div id="bTps" style="font-size:22px; color:var(--gold);">0</div></div>
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Tiempo</div><div id="bTime" style="font-size:22px;">0.00 s</div></div>
</div>
<canvas id="bChart" style="width:100%; height:300px; display:block; background:#08090c; border:1px solid var(--border); border-radius:8px;"></canvas>
<div style="display:flex; gap:18px; font-size:12px; color:var(--muted); margin-top:6px;">
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:6px;"></span>enviados (publisher)</span>
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--green);margin-right:6px;"></span>recibidos (suma de subscribers)</span>
</div>
<div id="bStatus" class="status" style="margin-top:8px;"></div>
</div>
</div>
<script>
"use strict";
const state = {
peer: null, // connected peer name
rooms: {}, // room_id -> {subject, encrypt}
es: null, // EventSource
};
const $ = (id) => document.getElementById(id);
async function api(path, body) {
const opts = { method: "POST", headers: { "Content-Type": "application/json" } };
if (body !== undefined) opts.body = JSON.stringify(body);
const res = await fetch(path, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
return data;
}
async function apiGet(path) {
const res = await fetch(path);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
return data;
}
function setStatus(id, msg, kind) {
const el = $(id);
el.textContent = msg || "";
el.className = "status" + (kind ? " " + kind : "");
}
function short(s, n = 10) {
if (!s) return "";
return s.length <= n * 2 ? s : s.slice(0, n) + "…" + s.slice(-4);
}
function hhmmss(ms) {
const d = new Date(ms);
const p = (x) => String(x).padStart(2, "0");
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
}
function logSys(text, cls) {
const log = $("log");
const div = document.createElement("div");
div.className = "msg " + (cls || "sys");
div.textContent = text;
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function logMsg(ev) {
const log = $("log");
const div = document.createElement("div");
div.className = "msg";
const enc = ev.encrypted
? '<span class="enc">&#128274;</span>'
: '<span class="clear">clear</span>';
div.innerHTML =
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> &#8614; ' +
escapeHtml(ev.text) +
' <span class="meta">&middot; ' + hhmmss(ev.ts) + ' &middot; ' + enc + '</span>';
log.appendChild(div);
log.scrollTop = log.scrollHeight;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function refreshRoomSelect() {
const sel = $("activeRoom");
const cur = sel.value;
sel.innerHTML = "";
for (const [id, info] of Object.entries(state.rooms)) {
const opt = document.createElement("option");
opt.value = id;
opt.textContent = info.subject + " (" + short(id, 6) + ")" + (info.encrypt ? " 🔒" : "");
sel.appendChild(opt);
}
if (state.rooms[cur]) sel.value = cur;
const has = Object.keys(state.rooms).length > 0;
$("sendBtn").disabled = !has;
$("inviteBtn").disabled = !has;
$("kickBtn").disabled = !has;
}
async function refreshPeers() {
try {
const peers = await apiGet("/api/peers");
const sel = $("targetPeer");
const cur = sel.value;
sel.innerHTML = "";
for (const p of peers) {
if (p.name === state.peer) continue; // don't target yourself
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = p.name + " (" + short(p.endpoint_id, 6) + ")";
sel.appendChild(opt);
}
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
} catch (e) {
setStatus("actionStatus", "peers: " + e.message, "bad");
}
}
function openStream(name) {
if (state.es) state.es.close();
const es = new EventSource("/api/stream?peer=" + encodeURIComponent(name));
es.onopen = () => {
$("streamPill").textContent = "live: " + name;
$("streamPill").className = "pill on";
};
es.onmessage = (e) => {
try { logMsg(JSON.parse(e.data)); } catch (_) {}
};
es.onerror = () => {
$("streamPill").textContent = "reconnecting…";
$("streamPill").className = "pill";
};
state.es = es;
}
// ---- handlers ----
$("connectBtn").onclick = async () => {
const name = $("peerName").value.trim();
if (!name) { setStatus("connStatus", "enter a name", "bad"); return; }
try {
const res = await api("/api/peer", { name });
state.peer = res.name;
state.rooms = {};
refreshRoomSelect();
$("peerIdent").innerHTML =
'endpoint: ' + escapeHtml(res.endpoint_id) +
' <span class="copy" id="copyId">copy</span>';
$("copyId").onclick = () => navigator.clipboard.writeText(res.endpoint_id);
setStatus("connStatus", "connected as " + res.name, "ok");
$("createRoomBtn").disabled = false;
$("joinBtn").disabled = false;
$("log").innerHTML = "";
logSys("connected as " + res.name + " — listening for messages");
openStream(res.name);
refreshPeers();
} catch (e) {
setStatus("connStatus", e.message, "bad");
}
};
$("createRoomBtn").onclick = async () => {
const subject = $("roomSubject").value.trim();
const encrypt = $("roomEncrypt").checked;
const persist = $("roomPersist").checked;
if (!subject) { setStatus("roomStatus", "subject required", "bad"); return; }
try {
const res = await api("/api/room", { peer: state.peer, subject, encrypt, persist });
state.rooms[res.room_id] = { subject: res.subject, encrypt: res.encrypt };
refreshRoomSelect();
$("activeRoom").value = res.room_id;
setStatus("roomStatus", "created " + res.room_id + " (click to copy)", "ok");
$("roomStatus").style.cursor = "pointer";
$("roomStatus").onclick = () => navigator.clipboard.writeText(res.room_id);
logSys("created room " + res.subject + " [" + short(res.room_id) + "]" + (encrypt ? " 🔒" : "") + (res.persist ? " 🗄" : ""));
} catch (e) {
setStatus("roomStatus", e.message, "bad");
}
};
$("joinBtn").onclick = async () => {
const roomId = $("joinRoomId").value.trim();
if (!roomId) { setStatus("roomStatus", "room_id required", "bad"); return; }
try {
const res = await api("/api/join", { peer: state.peer, room_id: roomId });
state.rooms[roomId] = { subject: res.subject, encrypt: res.encrypt };
refreshRoomSelect();
$("activeRoom").value = roomId;
setStatus("roomStatus", "joined " + res.subject + (res.encrypt ? " 🔒" : ""), "ok");
logSys("joined room " + res.subject + " [" + short(roomId) + "]");
} catch (e) {
setStatus("roomStatus", e.message, "bad");
}
};
$("sendBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const text = $("msgText").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
try {
await api("/api/publish", { peer: state.peer, room_id: roomId, text });
$("msgText").value = "";
setStatus("actionStatus", "sent", "ok");
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("msgText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendBtn").click(); });
$("inviteBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const target = $("targetPeer").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
if (!target) { setStatus("actionStatus", "no target peer (connect another peer first)", "bad"); return; }
try {
await api("/api/invite", { peer: state.peer, room_id: roomId, target });
setStatus("actionStatus", "invited " + target, "ok");
logSys("invited " + target + " to " + short(roomId));
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("kickBtn").onclick = async () => {
const roomId = $("activeRoom").value;
const target = $("targetPeer").value;
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
if (!target) { setStatus("actionStatus", "no target peer", "bad"); return; }
try {
await api("/api/kick", { peer: state.peer, room_id: roomId, target });
setStatus("actionStatus", "kicked " + target + " (key rotated)", "ok");
logSys("kicked " + target + " from " + short(roomId) + " — key rotated (forward secrecy)");
} catch (e) {
setStatus("actionStatus", e.message, "bad");
}
};
$("refreshPeersBtn").onclick = refreshPeers;
$("peerName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("connectBtn").click(); });
// ---- benchmark ----
const fmtN = (n) => Number(n).toLocaleString("es-ES");
const bMsgs = $("bMsgs"), bSubs = $("bSubs"), bPay = $("bPay");
bMsgs.oninput = () => $("bMsgsVal").textContent = fmtN(+bMsgs.value);
bSubs.oninput = () => $("bSubsVal").textContent = bSubs.value;
bPay.oninput = () => $("bPayVal").textContent = fmtN(+bPay.value) + " B";
let bSamples = [], bRunning = false, bES = null;
const bCanvas = $("bChart"), bCtx = bCanvas.getContext("2d");
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
function bResize() {
const dpr = window.devicePixelRatio || 1, r = bCanvas.getBoundingClientRect();
bCanvas.width = r.width * dpr; bCanvas.height = r.height * dpr;
bCtx.setTransform(dpr, 0, 0, dpr, 0, 0); bDraw();
}
window.addEventListener("resize", bResize);
function bDraw() {
const r = bCanvas.getBoundingClientRect(), W = r.width, H = r.height;
const padL = 70, padR = 14, padT = 12, padB = 26;
bCtx.clearRect(0, 0, W, H);
const tMax = bSamples.length ? Math.max(bSamples[bSamples.length - 1].t, 0.001) : 1;
const yMax = bSamples.length ? Math.max(...bSamples.map(s => Math.max(s.sent, s.recv)), 1) : 1;
bCtx.strokeStyle = "#2b333f"; bCtx.fillStyle = "#8b98a5"; bCtx.font = "11px ui-monospace";
for (let i = 0; i <= 5; i++) {
const yy = (H - padB) - (i / 5) * (H - padT - padB);
bCtx.beginPath(); bCtx.moveTo(padL, yy); bCtx.lineTo(W - padR, yy); bCtx.stroke();
bCtx.textAlign = "right"; bCtx.fillText(fmtN(Math.round((i / 5) * yMax)), padL - 8, yy + 3);
}
bCtx.textAlign = "center";
bCtx.fillText("0 s", padL, H - padB + 15);
bCtx.fillText(tMax.toFixed(2) + " s", W - padR, H - padB + 15);
if (bSamples.length < 2) return;
const x = (t) => padL + (t / tMax) * (W - padL - padR);
const y = (v) => (H - padB) - (v / yMax) * (H - padT - padB);
const line = (key, color) => {
bCtx.beginPath(); bCtx.lineWidth = 2.2; bCtx.strokeStyle = color;
bSamples.forEach((s, i) => { const px = x(s.t), py = y(s[key]); i ? bCtx.lineTo(px, py) : bCtx.moveTo(px, py); });
bCtx.stroke();
};
line("sent", cssVar("--accent"));
line("recv", cssVar("--green"));
}
function bSetRunning(v) { bRunning = v; $("bRun").disabled = v; }
$("bRun").onclick = () => {
if (bRunning) return;
bSamples = []; bSetRunning(true);
$("bSent").textContent = "0"; $("bRecv").textContent = "0"; $("bTps").textContent = "0"; $("bTime").textContent = "0.00 s";
setStatus("bStatus", "conectando…");
const qs = new URLSearchParams({
n_msgs: bMsgs.value, n_subs: bSubs.value, payload: bPay.value,
encrypt: $("bEncrypt").checked ? "1" : "0", persist: $("bPersist").checked ? "1" : "0",
});
const es = new EventSource("/api/bench?" + qs.toString());
bES = es;
const finish = () => { try { es.close(); } catch (_) {} bSetRunning(false); };
es.addEventListener("end", finish);
es.onmessage = (e) => {
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
if (m.type === "start") {
setStatus("bStatus",
"corriendo… " + fmtN(m.n_msgs) + " msgs → " + m.n_subs + " subs · payload " + fmtN(m.payload) + "B"
+ (m.encrypt ? " · \u{1F512} E2E" : "") + (m.persist ? " · \u{1F5C4} JetStream" : "")
+ (m.capped ? " · (limitado a 30k)" : ""), "");
} else if (m.type === "sample") {
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv); $("bTime").textContent = m.t.toFixed(2) + " s";
if (bSamples.length >= 2) {
const a = bSamples[bSamples.length - 2], b = bSamples[bSamples.length - 1], dt = b.t - a.t;
if (dt > 0) $("bTps").textContent = fmtN(Math.round((b.recv - a.recv) / dt));
}
bDraw();
} else if (m.type === "done") {
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv);
$("bTps").textContent = fmtN(m.recv_tps); $("bTime").textContent = m.t.toFixed(2) + " s";
setStatus("bStatus",
"✓ " + m.t.toFixed(2) + "s · pub " + fmtN(m.pub_tps) + "/s · recv " + fmtN(m.recv_tps) + "/s · fan-out ×"
+ m.n_subs + " · por sub [" + (m.per_sub || []).map(fmtN).join(", ") + "]", "ok");
bDraw(); finish();
} else if (m.type === "error") {
setStatus("bStatus", "error: " + m.msg, "bad"); finish();
}
};
es.onerror = () => { if (bRunning) { setStatus("bStatus", "conexión SSE perdida", "bad"); finish(); } };
};
bResize();
</script>
</body>
</html>
-933
View File
@@ -1,933 +0,0 @@
// Command playground is an all-in-one, web-based sandbox for the unibus message
// bus. A single `go run ./playground` launches the entire stack embedded:
//
// - an embedded NATS server with JetStream (the data plane),
// - the membership control plane (rooms, members, sealed keys, rekey) over an
// internal HTTP server,
// - the media blob store, and
// - a browser-facing web UI on :7700.
//
// The browser never speaks NATS. The Go server is the actual bus peer: it holds
// one unibus client per named peer, subscribes to rooms on the peer's behalf,
// and streams received messages to the browser over Server-Sent Events. The
// browser drives everything with plain fetch() + EventSource() — no build step,
// no JS framework, no external libraries.
//
// This is a playground (see .claude/rules/playgrounds.md): it lives inside the
// unibus app, reuses the parent module (no new go.mod), is not indexed, and
// stores ephemeral state under playground/local_files/.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"sync"
"sync/atomic"
"syscall"
"time"
_ "embed"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/blobstore"
"github.com/enmanuel/unibus/pkg/client"
"github.com/enmanuel/unibus/pkg/embeddednats"
"github.com/enmanuel/unibus/pkg/frame"
"github.com/enmanuel/unibus/pkg/membership"
"github.com/enmanuel/unibus/pkg/room"
)
// Fixed ports (verified free before assignment — do not change without reason).
const (
webAddr = "127.0.0.1:7700" // browser-facing web UI
ctrlAddr = "127.0.0.1:8480" // internal membership control plane
ctrlURL = "http://" + ctrlAddr
natsPort = 4260 // internal embedded NATS
natsURL = "nats://127.0.0.1:4260"
localFiles = "playground/local_files"
)
//go:embed index.html
var indexHTML []byte
// ---------------------------------------------------------------------------
// Event: a message received by a peer on one of its subscribed rooms. Fanned
// out to every SSE listener attached to that peer.
// ---------------------------------------------------------------------------
type Event struct {
RoomID string `json:"room_id"`
Subject string `json:"subject"`
Sender string `json:"sender"`
Text string `json:"text"`
Encrypted bool `json:"encrypted"`
TS int64 `json:"ts"` // unix millis
}
// roomInfo caches the per-room metadata a peer needs to label incoming frames.
type roomInfo struct {
subject string
encrypt bool
}
// peerState holds everything about one named peer: its bus client, its public
// endpoint, its live subscriptions, the rooms it knows, and the set of SSE
// listener channels currently attached to it.
type peerState struct {
name string
client *client.Client
endpoint client.Endpoint
mu sync.Mutex
subs map[string]*client.Sub // roomID -> subscription
rooms map[string]roomInfo // roomID -> subject/encrypt
listeners map[chan Event]struct{} // attached SSE channels
}
// emit fans an event out to all attached listeners without blocking on a slow
// or disconnected consumer.
func (p *peerState) emit(ev Event) {
p.mu.Lock()
defer p.mu.Unlock()
for ch := range p.listeners {
select {
case ch <- ev:
default: // listener buffer full: drop rather than block the NATS callback
}
}
}
func (p *peerState) addListener(ch chan Event) {
p.mu.Lock()
p.listeners[ch] = struct{}{}
p.mu.Unlock()
}
func (p *peerState) removeListener(ch chan Event) {
p.mu.Lock()
delete(p.listeners, ch)
p.mu.Unlock()
}
func (p *peerState) setRoom(roomID string, info roomInfo) {
p.mu.Lock()
p.rooms[roomID] = info
p.mu.Unlock()
}
// roomList returns a snapshot of the rooms this peer knows (created or joined),
// so the SPA can render the peer's room list without re-deriving it client-side.
func (p *peerState) roomList() []map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]map[string]any, 0, len(p.rooms))
for id, info := range p.rooms {
out = append(out, map[string]any{
"room_id": id,
"subject": info.subject,
"encrypt": info.encrypt,
})
}
return out
}
// ---------------------------------------------------------------------------
// Hub: the registry of peers, protected by a single mutex.
// ---------------------------------------------------------------------------
type Hub struct {
mu sync.Mutex
peers map[string]*peerState
}
func newHub() *Hub { return &Hub{peers: map[string]*peerState{}} }
// getOrCreate returns the peer for name, creating its identity + bus client on
// first use. Identities persist to playground/local_files/<name>.id so a peer
// keeps the same endpoint across restarts.
func (h *Hub) getOrCreate(name string) (*peerState, error) {
h.mu.Lock()
defer h.mu.Unlock()
if p, ok := h.peers[name]; ok {
return p, nil
}
idPath := filepath.Join(localFiles, name+".id")
id, err := client.LoadOrCreateIdentity(idPath)
if err != nil {
return nil, fmt.Errorf("identity for %q: %w", name, err)
}
c, err := client.New(natsURL, ctrlURL, id)
if err != nil {
return nil, fmt.Errorf("client for %q: %w", name, err)
}
p := &peerState{
name: name,
client: c,
endpoint: c.Endpoint(),
subs: map[string]*client.Sub{},
rooms: map[string]roomInfo{},
listeners: map[chan Event]struct{}{},
}
h.peers[name] = p
return p, nil
}
// lookup returns an already-created peer or false.
func (h *Hub) lookup(name string) (*peerState, bool) {
h.mu.Lock()
defer h.mu.Unlock()
p, ok := h.peers[name]
return p, ok
}
// list returns a snapshot of all peers (name + endpoint id).
func (h *Hub) list() []map[string]string {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]map[string]string, 0, len(h.peers))
for name, p := range h.peers {
out = append(out, map[string]string{"name": name, "endpoint_id": p.endpoint.ID})
}
return out
}
func (h *Hub) closeAll() {
h.mu.Lock()
defer h.mu.Unlock()
for _, p := range h.peers {
p.mu.Lock()
for _, sub := range p.subs {
_ = sub.Unsubscribe()
}
p.mu.Unlock()
_ = p.client.Close()
}
}
// subscribeRoom subscribes the peer to a room (idempotent) and wires the frame
// handler to fan incoming messages out as Events. info labels each event with
// the room's subject and encryption flag.
func (p *peerState) subscribeRoom(roomID string, info roomInfo) error {
p.mu.Lock()
if _, already := p.subs[roomID]; already {
p.mu.Unlock()
return nil
}
p.mu.Unlock()
sub, err := p.client.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
p.emit(Event{
RoomID: roomID,
Subject: info.subject,
Sender: f.Sender,
Text: string(plaintext),
Encrypted: info.encrypt,
TS: time.Now().UnixMilli(),
})
})
if err != nil {
return fmt.Errorf("subscribe room %s: %w", roomID, err)
}
p.mu.Lock()
p.subs[roomID] = sub
p.mu.Unlock()
p.setRoom(roomID, info)
return nil
}
// ---------------------------------------------------------------------------
// Control-plane helper: fetch a room's subject + policy from membershipd. The
// client package keeps fetchRoom private, so the playground talks to the
// control plane directly (read endpoints are unauthenticated by design).
// ---------------------------------------------------------------------------
type ctrlRoomResp struct {
Subject string `json:"subject"`
Epoch int `json:"epoch"`
Policy struct {
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
SignMsgs bool `json:"sign_msgs"`
} `json:"policy"`
}
func fetchRoomInfo(roomID string) (roomInfo, error) {
resp, err := http.Get(ctrlURL + "/rooms/" + roomID)
if err != nil {
return roomInfo{}, fmt.Errorf("fetch room %s: %w", roomID, err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return roomInfo{}, fmt.Errorf("room %s not found (status %d)", roomID, resp.StatusCode)
}
var r ctrlRoomResp
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return roomInfo{}, fmt.Errorf("decode room %s: %w", roomID, err)
}
return roomInfo{subject: r.Subject, encrypt: r.Policy.Encrypt}, nil
}
// ---------------------------------------------------------------------------
// HTTP handlers (web UI on :7700).
// ---------------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func decodeBody(r *http.Request, out any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(out)
}
func (h *Hub) handleIndex(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(indexHTML)
}
func (h *Hub) handlePeer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := decodeBody(r, &req); err != nil || req.Name == "" {
writeErr(w, http.StatusBadRequest, "name required")
return
}
p, err := h.getOrCreate(req.Name)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"name": p.name, "endpoint_id": p.endpoint.ID})
}
func (h *Hub) handlePeers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.list())
}
func (h *Hub) handleRoom(w http.ResponseWriter, r *http.Request) {
var req struct {
Peer string `json:"peer"`
Subject string `json:"subject"`
Encrypt bool `json:"encrypt"`
Persist bool `json:"persist"`
}
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.Subject == "" {
writeErr(w, http.StatusBadRequest, "peer and subject required")
return
}
p, ok := h.lookup(req.Peer)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
return
}
// The two checkboxes map to an explicit per-room policy. encrypt drives both
// encryption and per-message signing; persist (default false) independently
// toggles durable JetStream history. persist=false keeps plain ephemeral NATS.
policy := room.Policy{Encrypt: req.Encrypt, Persist: req.Persist, SignMsgs: req.Encrypt}
roomID, err := p.client.CreateRoom(req.Subject, policy)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
info := roomInfo{subject: req.Subject, encrypt: req.Encrypt}
if err := p.subscribeRoom(roomID, info); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"room_id": roomID, "subject": req.Subject, "encrypt": req.Encrypt, "persist": req.Persist,
})
}
func (h *Hub) handleJoin(w http.ResponseWriter, r *http.Request) {
var req struct {
Peer string `json:"peer"`
RoomID string `json:"room_id"`
}
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
writeErr(w, http.StatusBadRequest, "peer and room_id required")
return
}
p, ok := h.lookup(req.Peer)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
return
}
if err := p.client.Join(req.RoomID); err != nil {
writeErr(w, http.StatusBadRequest, "join failed: "+err.Error())
return
}
info, err := fetchRoomInfo(req.RoomID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
if err := p.subscribeRoom(req.RoomID, info); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"subject": info.subject, "encrypt": info.encrypt})
}
func (h *Hub) handleInvite(w http.ResponseWriter, r *http.Request) {
var req struct {
Peer string `json:"peer"`
RoomID string `json:"room_id"`
Target string `json:"target"`
}
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
return
}
p, ok := h.lookup(req.Peer)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
return
}
target, ok := h.lookup(req.Target)
if !ok {
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist; connect it first")
return
}
if err := p.client.Invite(req.RoomID, target.endpoint); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "invited", "target": req.Target})
}
func (h *Hub) handlePublish(w http.ResponseWriter, r *http.Request) {
var req struct {
Peer string `json:"peer"`
RoomID string `json:"room_id"`
Text string `json:"text"`
}
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
writeErr(w, http.StatusBadRequest, "peer and room_id required")
return
}
p, ok := h.lookup(req.Peer)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
return
}
if err := p.client.Publish(req.RoomID, []byte(req.Text)); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "published"})
}
func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) {
var req struct {
Peer string `json:"peer"`
RoomID string `json:"room_id"`
Target string `json:"target"`
}
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
return
}
p, ok := h.lookup(req.Peer)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
return
}
target, ok := h.lookup(req.Target)
if !ok {
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist")
return
}
if err := p.client.Kick(req.RoomID, target.endpoint.ID); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "target": req.Target})
}
// handleRooms returns the rooms a peer knows (created or joined). The SPA polls
// or calls this after create/join to refresh its room list.
//
// GET /api/rooms?peer=ana
func (h *Hub) handleRooms(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("peer")
if name == "" {
writeErr(w, http.StatusBadRequest, "peer query param required")
return
}
p, ok := h.lookup(name)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+name)
return
}
writeJSON(w, http.StatusOK, p.roomList())
}
// handleMembers lists the members of a room (endpoint id + role) so the SPA can
// render a members panel and drive invite/kick. It proxies the control plane's
// unauthenticated read endpoint; the public keys it returns are not secret.
//
// GET /api/members?room_id=<id>
func (h *Hub) handleMembers(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("room_id")
if roomID == "" {
writeErr(w, http.StatusBadRequest, "room_id query param required")
return
}
resp, err := http.Get(ctrlURL + "/rooms/" + roomID + "/members")
if err != nil {
writeErr(w, http.StatusInternalServerError, "fetch members: "+err.Error())
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(body)
}
// withCORS allows the SPA running under the Vite dev server (a different origin)
// to call the gateway. It answers preflight OPTIONS and tags every response with
// permissive CORS headers. v1 trusts the local network, mirroring the control
// plane's auth model.
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// handleStream is the SSE endpoint. The browser opens one EventSource per peer;
// each received Event is emitted as a `data: <json>\n\n` block. The listener is
// cleaned up when the HTTP request context is cancelled (tab closed / reload).
func (h *Hub) handleStream(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("peer")
if name == "" {
writeErr(w, http.StatusBadRequest, "peer query param required")
return
}
p, ok := h.lookup(name)
if !ok {
writeErr(w, http.StatusBadRequest, "unknown peer "+name)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := make(chan Event, 64)
p.addListener(ch)
defer p.removeListener(ch)
// Initial comment so the browser marks the stream open immediately.
fmt.Fprintf(w, ": connected to %s\n\n", name)
flusher.Flush()
ctx := r.Context()
ping := time.NewTicker(20 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
fmt.Fprintf(w, ": ping\n\n")
flusher.Flush()
case ev := <-ch:
b, err := json.Marshal(ev)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
}
}
// ---------------------------------------------------------------------------
// Benchmark: one publisher floods a room with thousands of messages that N
// subscribers receive. The two policy axes are exposed as independent flags:
// encrypt (AEAD payload + Ed25519 per-message signature) and persist (durable
// JetStream history vs ephemeral core NATS). Payload size is configurable. The
// benchmark uses its own ephemeral peers (not the hub's named peers) so it never
// interferes with the manual sandbox, and streams progress samples over SSE so
// the browser can animate a live throughput chart.
// ---------------------------------------------------------------------------
// benchSample is one Server-Sent Event of a running benchmark.
type benchSample struct {
Type string `json:"type"` // "start" | "sample" | "done" | "error"
T float64 `json:"t"`
Sent int64 `json:"sent"`
Recv int64 `json:"recv"`
NMsgs int `json:"n_msgs,omitempty"`
NSubs int `json:"n_subs,omitempty"`
Payload int `json:"payload,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
Persist bool `json:"persist,omitempty"`
Capped bool `json:"capped,omitempty"`
PubTps int64 `json:"pub_tps,omitempty"`
RecvTps int64 `json:"recv_tps,omitempty"`
PerSub []int64 `json:"per_sub,omitempty"`
Msg string `json:"msg,omitempty"`
}
// runBench wires up one publisher + nSubs subscribers, publishes nMsgs payloads,
// and calls emit periodically with the running totals. emit is only ever called
// from the calling goroutine (the SSE handler), so it needs no locking.
func runBench(ctx context.Context, emit func(benchSample), nMsgs, nSubs, payloadBytes int, encrypt, persist bool) {
policy := room.Policy{Encrypt: encrypt, Persist: persist, SignMsgs: encrypt}
subject := fmt.Sprintf("bench.%d", time.Now().UnixNano())
newPeer := func() (*client.Client, error) {
id, err := cs.GenerateIdentity()
if err != nil {
return nil, err
}
return client.New(natsURL, ctrlURL, id)
}
pub, err := newPeer()
if err != nil {
emit(benchSample{Type: "error", Msg: "publisher: " + err.Error()})
return
}
defer pub.Close()
roomID, err := pub.CreateRoom(subject, policy)
if err != nil {
emit(benchSample{Type: "error", Msg: "create room: " + err.Error()})
return
}
counters := make([]int64, nSubs)
subClients := make([]*client.Client, 0, nSubs)
defer func() {
for _, c := range subClients {
_ = c.Close()
}
}()
// One room, N subscribers. For encrypted rooms each subscriber must be invited
// (sealed key) and join before subscribing; for cleartext rooms Subscribe on
// the shared roomID is enough.
for i := 0; i < nSubs; i++ {
c, err := newPeer()
if err != nil {
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscriber %d: %v", i, err)})
return
}
subClients = append(subClients, c)
if encrypt {
if err := pub.Invite(roomID, c.Endpoint()); err != nil {
emit(benchSample{Type: "error", Msg: fmt.Sprintf("invite %d: %v", i, err)})
return
}
if err := c.Join(roomID); err != nil {
emit(benchSample{Type: "error", Msg: fmt.Sprintf("join %d: %v", i, err)})
return
}
}
idx := i
if _, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) {
atomic.AddInt64(&counters[idx], 1)
}); err != nil {
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscribe %d: %v", i, err)})
return
}
}
sumRecv := func() int64 {
var s int64
for i := range counters {
s += atomic.LoadInt64(&counters[i])
}
return s
}
payload := bytes.Repeat([]byte{'x'}, payloadBytes)
var sent int64
emit(benchSample{Type: "start", NMsgs: nMsgs, NSubs: nSubs, Payload: payloadBytes, Encrypt: encrypt, Persist: persist})
t0 := time.Now()
done := make(chan struct{})
var pubErr atomic.Value
go func() {
defer close(done)
for k := 0; k < nMsgs; k++ {
if err := pub.Publish(roomID, payload); err != nil {
pubErr.Store(err)
return
}
atomic.AddInt64(&sent, 1)
if k%256 == 0 {
select {
case <-ctx.Done():
return
default:
}
}
}
}()
ticker := time.NewTicker(60 * time.Millisecond)
defer ticker.Stop()
deadline := time.After(120 * time.Second)
target := int64(nMsgs) * int64(nSubs)
sampleLoop:
for {
select {
case <-ctx.Done():
return
case <-deadline:
break sampleLoop
case <-done:
break sampleLoop
case <-ticker.C:
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
}
}
if v := pubErr.Load(); v != nil {
emit(benchSample{Type: "error", Msg: "publish: " + v.(error).Error()})
return
}
// Final drain: keep sampling until every subscriber has caught up (or we give up).
for i := 0; i < 240; i++ {
if sumRecv() >= target {
break
}
select {
case <-ctx.Done():
return
case <-time.After(25 * time.Millisecond):
}
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
}
dur := time.Since(t0).Seconds()
finalSent := atomic.LoadInt64(&sent)
finalRecv := sumRecv()
per := make([]int64, nSubs)
for i := range counters {
per[i] = atomic.LoadInt64(&counters[i])
}
var pubTps, recvTps int64
if dur > 0 {
pubTps = int64(float64(finalSent) / dur)
recvTps = int64(float64(finalRecv) / dur)
}
emit(benchSample{Type: "done", T: dur, Sent: finalSent, Recv: finalRecv, PerSub: per, PubTps: pubTps, RecvTps: recvTps, NSubs: nSubs})
}
// handleBench is the SSE endpoint that drives a benchmark from query params:
//
// GET /api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0
//
// Encrypted/persistent runs are capped to a lower message count (the per-message
// crypto + JetStream ack make them far slower); the cap is reported in the start
// sample so the UI can show it.
func (h *Hub) handleBench(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
atoiDef := func(k string, def int) int {
if v, err := strconv.Atoi(q.Get(k)); err == nil {
return v
}
return def
}
truthy := func(k string) bool { v := q.Get(k); return v == "1" || v == "true" }
nMsgs := atoiDef("n_msgs", 20000)
nSubs := atoiDef("n_subs", 3)
payload := atoiDef("payload", 128)
encrypt := truthy("encrypt")
persist := truthy("persist")
if nSubs < 1 {
nSubs = 1
} else if nSubs > 16 {
nSubs = 16
}
if payload < 1 {
payload = 1
} else if payload > 8192 {
payload = 8192
}
if nMsgs < 100 {
nMsgs = 100
}
maxMsgs := 200000
if encrypt || persist {
maxMsgs = 30000 // crypto + JetStream ack are much slower; keep the run bounded
}
capped := false
if nMsgs > maxMsgs {
nMsgs, capped = maxMsgs, true
}
flusher, ok := w.(http.Flusher)
if !ok {
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
fmt.Fprintf(w, ": bench start\n\n")
flusher.Flush()
emit := func(s benchSample) {
if s.Type == "start" {
s.Capped = capped
}
b, err := json.Marshal(s)
if err != nil {
return
}
fmt.Fprintf(w, "data: %s\n\n", b)
flusher.Flush()
}
runBench(r.Context(), emit, nMsgs, nSubs, payload, encrypt, persist)
fmt.Fprintf(w, "event: end\ndata: {}\n\n")
flusher.Flush()
}
// ---------------------------------------------------------------------------
// main: bring up NATS, control plane, and the web server; tear them all down
// cleanly on signal.
// ---------------------------------------------------------------------------
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("[playground] ")
if err := os.MkdirAll(localFiles, 0o755); err != nil {
log.Fatalf("mkdir %s: %v", localFiles, err)
}
// 1. Data plane: embedded NATS + JetStream on the fixed internal port.
ns, err := embeddednats.Start(filepath.Join(localFiles, "js"), natsPort)
if err != nil {
log.Fatalf("start embedded nats: %v", err)
}
log.Printf("embedded NATS (JetStream) ready: %s", embeddednats.ClientURL(ns))
// 2. Control plane: membership store + blob store + internal HTTP server.
store, err := membership.Open(filepath.Join(localFiles, "play.db"))
if err != nil {
ns.Shutdown()
log.Fatalf("open membership store: %v", err)
}
blobs, err := blobstore.New(filepath.Join(localFiles, "blobs"))
if err != nil {
store.Close()
ns.Shutdown()
log.Fatalf("open blob store: %v", err)
}
// AuthOff: the playground is a local dev gateway that has not migrated to
// signed control-plane requests or a secured upstream bus yet. What it would
// need is written up in dev/0001e-remaining-clients.md (issue 0001, phase 0001e).
ctrlSrv := &http.Server{Addr: ctrlAddr, Handler: membership.NewServer(store, blobs, membership.AuthOff)}
go func() {
if err := ctrlSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("control plane: %v", err)
}
}()
if err := waitHealthy(ctrlURL+"/healthz", 5*time.Second); err != nil {
log.Fatalf("control plane not healthy: %v", err)
}
log.Printf("control plane ready: %s", ctrlURL)
// 3. Web UI on :7700.
hub := newHub()
mux := http.NewServeMux()
mux.HandleFunc("/", hub.handleIndex)
mux.HandleFunc("POST /api/peer", hub.handlePeer)
mux.HandleFunc("GET /api/peers", hub.handlePeers)
mux.HandleFunc("POST /api/room", hub.handleRoom)
mux.HandleFunc("POST /api/join", hub.handleJoin)
mux.HandleFunc("POST /api/invite", hub.handleInvite)
mux.HandleFunc("POST /api/publish", hub.handlePublish)
mux.HandleFunc("POST /api/kick", hub.handleKick)
mux.HandleFunc("GET /api/rooms", hub.handleRooms)
mux.HandleFunc("GET /api/members", hub.handleMembers)
mux.HandleFunc("GET /api/stream", hub.handleStream)
mux.HandleFunc("GET /api/bench", hub.handleBench)
webSrv := &http.Server{Addr: webAddr, Handler: withCORS(mux)}
go func() {
if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("web server: %v", err)
}
}()
log.Printf("web UI ready: http://%s", webAddr)
log.Printf("open http://localhost:7700 in two browser tabs to try the bus")
// 4. Graceful shutdown.
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
log.Printf("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = webSrv.Shutdown(ctx)
hub.closeAll()
_ = ctrlSrv.Shutdown(ctx)
store.Close()
ns.Shutdown()
ns.WaitForShutdown()
log.Printf("bye")
}
// waitHealthy polls url until it returns a 2xx/3xx or the deadline elapses.
func waitHealthy(url string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
c := &http.Client{Timeout: 500 * time.Millisecond}
for time.Now().Before(deadline) {
resp, err := c.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode < 400 {
return nil
}
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timeout waiting for %s", url)
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>unibus · chat</title> <title>unibus</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+4 -2
View File
@@ -3,7 +3,6 @@
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
@@ -12,6 +11,9 @@
"dependencies": { "dependencies": {
"@mantine/core": "^9.3.0", "@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0", "@mantine/hooks": "^9.3.0",
"@noble/curves": "^2.2.0",
"@noble/hashes": "^2.2.0",
"@scure/bip39": "^2.2.0",
"@tabler/icons-react": "^3.36.0", "@tabler/icons-react": "^3.36.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
@@ -23,7 +25,7 @@
"postcss": "^8.4.49", "postcss": "^8.4.49",
"postcss-preset-mantine": "^1.17.0", "postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"typescript": "^5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.3" "vite": "^6.0.3"
} }
} }
+74 -38
View File
@@ -10,10 +10,19 @@ importers:
dependencies: dependencies:
'@mantine/core': '@mantine/core':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': '@mantine/hooks':
specifier: ^9.3.0 specifier: ^9.3.0
version: 9.3.0(react@19.2.7) version: 9.3.0(react@19.2.7)
'@noble/curves':
specifier: ^2.2.0
version: 2.2.0
'@noble/hashes':
specifier: ^2.2.0
version: 2.2.0
'@scure/bip39':
specifier: ^2.2.0
version: 2.2.0
'@tabler/icons-react': '@tabler/icons-react':
specifier: ^3.36.0 specifier: ^3.36.0
version: 3.44.0(react@19.2.7) version: 3.44.0(react@19.2.7)
@@ -26,10 +35,10 @@ importers:
devDependencies: devDependencies:
'@types/react': '@types/react':
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.16 version: 19.2.17
'@types/react-dom': '@types/react-dom':
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.3(@types/react@19.2.16) version: 19.2.3(@types/react@19.2.17)
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.3.4 specifier: ^4.3.4
version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15))) version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
@@ -43,8 +52,8 @@ importers:
specifier: ^7.0.1 specifier: ^7.0.1
version: 7.0.1(postcss@8.5.15) version: 7.0.1(postcss@8.5.15)
typescript: typescript:
specifier: ^5.6.3 specifier: ~5.6.3
version: 5.9.3 version: 5.6.3
vite: vite:
specifier: ^6.0.3 specifier: ^6.0.3
version: 6.4.3(sugarss@5.0.1(postcss@8.5.15)) version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
@@ -339,6 +348,14 @@ packages:
peerDependencies: peerDependencies:
react: ^19.2.0 react: ^19.2.0
'@noble/curves@2.2.0':
resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==}
engines: {node: '>= 20.19.0'}
'@noble/hashes@2.2.0':
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
'@rolldown/pluginutils@1.0.0-beta.27': '@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -480,6 +497,12 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@scure/base@2.2.0':
resolution: {integrity: sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==}
'@scure/bip39@2.2.0':
resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==}
'@tabler/icons-react@3.44.0': '@tabler/icons-react@3.44.0':
resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==}
peerDependencies: peerDependencies:
@@ -508,8 +531,8 @@ packages:
peerDependencies: peerDependencies:
'@types/react': ^19.2.0 '@types/react': ^19.2.0
'@types/react@19.2.16': '@types/react@19.2.17':
resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==} resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
'@vitejs/plugin-react@4.7.0': '@vitejs/plugin-react@4.7.0':
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
@@ -517,8 +540,8 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
baseline-browser-mapping@2.10.33: baseline-browser-mapping@2.10.34:
resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
hasBin: true hasBin: true
@@ -531,8 +554,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
caniuse-lite@1.0.30001793: caniuse-lite@1.0.30001797:
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
clsx@2.1.1: clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
@@ -756,8 +779,8 @@ packages:
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
engines: {node: '>=20'} engines: {node: '>=20'}
typescript@5.9.3: typescript@5.6.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
@@ -1069,7 +1092,7 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies: dependencies:
'@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@mantine/hooks': 9.3.0(react@19.2.7) '@mantine/hooks': 9.3.0(react@19.2.7)
@@ -1077,7 +1100,7 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(react@19.2.7) react-dom: 19.2.7(react@19.2.7)
react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7) react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
type-fest: 5.7.0 type-fest: 5.7.0
transitivePeerDependencies: transitivePeerDependencies:
- '@types/react' - '@types/react'
@@ -1086,6 +1109,12 @@ snapshots:
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
'@noble/curves@2.2.0':
dependencies:
'@noble/hashes': 2.2.0
'@noble/hashes@2.2.0': {}
'@rolldown/pluginutils@1.0.0-beta.27': {} '@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.61.1': '@rollup/rollup-android-arm-eabi@4.61.1':
@@ -1163,6 +1192,13 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.61.1': '@rollup/rollup-win32-x64-msvc@4.61.1':
optional: true optional: true
'@scure/base@2.2.0': {}
'@scure/bip39@2.2.0':
dependencies:
'@noble/hashes': 2.2.0
'@scure/base': 2.2.0
'@tabler/icons-react@3.44.0(react@19.2.7)': '@tabler/icons-react@3.44.0(react@19.2.7)':
dependencies: dependencies:
'@tabler/icons': 3.44.0 '@tabler/icons': 3.44.0
@@ -1193,11 +1229,11 @@ snapshots:
'@types/estree@1.0.9': {} '@types/estree@1.0.9': {}
'@types/react-dom@19.2.3(@types/react@19.2.16)': '@types/react-dom@19.2.3(@types/react@19.2.17)':
dependencies: dependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
'@types/react@19.2.16': '@types/react@19.2.17':
dependencies: dependencies:
csstype: 3.2.3 csstype: 3.2.3
@@ -1213,19 +1249,19 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
baseline-browser-mapping@2.10.33: {} baseline-browser-mapping@2.10.34: {}
browserslist@4.28.2: browserslist@4.28.2:
dependencies: dependencies:
baseline-browser-mapping: 2.10.33 baseline-browser-mapping: 2.10.34
caniuse-lite: 1.0.30001793 caniuse-lite: 1.0.30001797
electron-to-chromium: 1.5.368 electron-to-chromium: 1.5.368
node-releases: 2.0.47 node-releases: 2.0.47
update-browserslist-db: 1.2.3(browserslist@4.28.2) update-browserslist-db: 1.2.3(browserslist@4.28.2)
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001793: {} caniuse-lite@1.0.30001797: {}
clsx@2.1.1: {} clsx@2.1.1: {}
@@ -1356,32 +1392,32 @@ snapshots:
react-refresh@0.17.0: {} react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7): react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7): react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7) react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7)
react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7) react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
tslib: 2.8.1 tslib: 2.8.1
use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7) use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7)
use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7) use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7)
optionalDependencies: optionalDependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7): react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
react: 19.2.7 react: 19.2.7
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
react@19.2.7: {} react@19.2.7: {}
@@ -1441,7 +1477,7 @@ snapshots:
dependencies: dependencies:
tagged-tag: 1.0.0 tagged-tag: 1.0.0
typescript@5.9.3: {} typescript@5.6.3: {}
update-browserslist-db@1.2.3(browserslist@4.28.2): update-browserslist-db@1.2.3(browserslist@4.28.2):
dependencies: dependencies:
@@ -1449,20 +1485,20 @@ snapshots:
escalade: 3.2.0 escalade: 3.2.0
picocolors: 1.1.1 picocolors: 1.1.1
use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7): use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7): use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
detect-node-es: 1.1.0 detect-node-es: 1.1.0
react: 19.2.7 react: 19.2.7
tslib: 2.8.1 tslib: 2.8.1
optionalDependencies: optionalDependencies:
'@types/react': 19.2.16 '@types/react': 19.2.17
util-deprecate@1.0.2: {} util-deprecate@1.0.2: {}
+134 -24
View File
@@ -1,29 +1,139 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { GatewayClient } from "./api"; import { Center, Loader } from "@mantine/core";
import type { Peer } from "./types"; import { ChatShell } from "./ChatShell";
import { ConnectScreen } from "./components/ConnectScreen"; import { Join } from "./Join";
import { ChatLayout } from "./components/ChatLayout"; import { Recover } from "./Recover";
import { WalletLogin } from "./WalletLogin";
import { Welcome } from "./Welcome";
import { api } from "./api";
import { localIdentity } from "./wallet/account";
import type { User } from "./types";
// Connection holds the live gateway client plus the identity it connected as. type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat";
interface Connection {
client: GatewayClient; // readJoinToken returns the invite token if the current URL is /join?token=XXX.
peer: Peer; function readJoinToken(): string | null {
if (window.location.pathname !== "/join") return null;
return new URLSearchParams(window.location.search).get("token");
} }
// App is the root: it shows the connect screen until the user picks a gateway // clearUrl drops any /join?token from the address bar once consumed, so a refresh
// URL and a peer name, then swaps to the full chat layout. Disconnecting drops // or a shared screenshot does not replay the (single-use) token.
// back to the connect screen. function clearUrl() {
export function App() { if (window.location.pathname !== "/") {
const [conn, setConn] = useState<Connection | null>(null); window.history.replaceState(null, "", "/");
}
if (!conn) { }
return <ConnectScreen onConnect={(client, peer) => setConn({ client, peer })} />;
export function App() {
const [route, setRoute] = useState<Route>("loading");
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState("");
const [storedHandle, setStoredHandle] = useState("");
// Decide the entry screen on mount: an invite link goes straight to join; a live
// gateway session resumes the chat; a device with a stored identity shows the
// password unlock; an empty device shows the welcome chooser.
useEffect(() => {
const t = readJoinToken();
if (t) {
setToken(t);
setRoute("join");
return;
}
let cancelled = false;
(async () => {
try {
const me = await api.me();
if (cancelled) return;
setUser({ id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) });
setRoute("chat");
return;
} catch {
// no live session — fall through
}
const stored = await localIdentity();
if (cancelled) return;
if (stored) {
setStoredHandle(stored.handle);
setRoute("login");
} else {
setRoute("welcome");
}
})();
return () => {
cancelled = true;
};
}, []);
const enterChat = (u: User) => {
setUser(u);
setRoute("chat");
clearUrl();
};
const logout = () => {
void api.logout().catch(() => {});
setUser(null);
// Keep the encrypted identity on the device: logging out returns to the
// password unlock, not a full reset.
void localIdentity().then((stored) => {
if (stored) {
setStoredHandle(stored.handle);
setRoute("login");
} else {
setRoute("welcome");
}
});
};
switch (route) {
case "loading":
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
case "join":
return (
<Join
token={token}
onJoined={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "welcome":
return (
<Welcome
onJoinToken={(t) => {
setToken(t);
setRoute("join");
}}
onRecover={() => setRoute("recover")}
/>
);
case "login":
return (
<WalletLogin
handle={storedHandle}
onLoggedIn={enterChat}
onRecover={() => setRoute("recover")}
/>
);
case "recover":
return (
<Recover
onRecovered={enterChat}
onBack={() => setRoute(storedHandle ? "login" : "welcome")}
/>
);
case "chat":
return user ? (
<ChatShell user={user} onLogout={logout} />
) : (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
} }
return (
<ChatLayout
client={conn.client}
peer={conn.peer}
onDisconnect={() => setConn(null)}
/>
);
} }
+47
View File
@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
import { Card, Center, Stack, Text, ThemeIcon, Title } from "@mantine/core";
// AuthCard is the shared centered card used by every pre-chat screen (welcome,
// join, recover, wallet login) so they all look like one flow.
export function AuthCard({
width = 460,
children,
}: {
width?: number;
children: ReactNode;
}) {
return (
<Center h="100vh" bg="dark.9" p="md">
<Card w={width} p="xl" radius="lg" withBorder bg="dark.7">
<Stack gap="lg">{children}</Stack>
</Card>
</Center>
);
}
// AuthHeader is the icon + title + subtitle block at the top of an auth card.
export function AuthHeader({
icon,
title,
subtitle,
}: {
icon: ReactNode;
title: string;
subtitle?: string;
}) {
return (
<Stack align="center" gap="xs">
<ThemeIcon size={56} radius="xl" variant="light" color="brand">
{icon}
</ThemeIcon>
<Title order={3} ta="center">
{title}
</Title>
{subtitle && (
<Text c="dimmed" size="sm" ta="center">
{subtitle}
</Text>
)}
</Stack>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useEffect, useRef, useState } from "react";
import {
ActionIcon,
Avatar,
Box,
Center,
Divider,
Group,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconSend,
IconLock,
IconHash,
IconDotsVertical,
IconPaperclip,
} from "@tabler/icons-react";
import { api, streamRoom } from "./api";
import type { Message, Room } from "./types";
function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
function timeShort(ts: number) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
function MessageRow({ msg }: { msg: Message }) {
return (
<Group align="flex-start" gap="sm" wrap="nowrap">
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
{initials(msg.sender)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
{msg.sender}
</Text>
<Text size="xs" c="dimmed">
{timeShort(msg.ts)}
</Text>
</Group>
<Text size="sm" style={{ wordBreak: "break-word" }}>
{msg.body}
</Text>
</Box>
</Group>
);
}
export function ChatPanel({ room }: { room: Room | undefined }) {
const [draft, setDraft] = useState("");
const [messages, setMessages] = useState<Message[]>([]);
const [sendError, setSendError] = useState<string | null>(null);
const viewport = useRef<HTMLDivElement>(null);
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
useEffect(() => {
setMessages([]);
setSendError(null);
if (!room) return;
const close = streamRoom(room.id, (m) => {
setMessages((prev) =>
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
);
});
return close;
}, [room?.id]);
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
}, [room?.id, messages.length]);
if (!room) {
return (
<Center h="100%">
<Text c="dimmed">Selecciona una conversación</Text>
</Center>
);
}
const send = async () => {
const body = draft.trim();
if (!body) return;
setDraft("");
setSendError(null);
try {
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
// evitando duplicados.
await api.send(room.id, body);
} catch (e) {
setDraft(body); // restaura el borrador si el envío falló
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
}
};
return (
<Stack h="100vh" gap={0}>
<Group justify="space-between" px="md" py="xs" wrap="nowrap">
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar radius="md" size={38} color="brand">
{initials(room.name)}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text fw={650} truncate>
{room.name}
</Text>
{room.encrypted ? (
<Tooltip label="Cifrada de extremo a extremo">
<IconLock size={14} style={{ opacity: 0.6 }} />
</Tooltip>
) : (
<IconHash size={14} style={{ opacity: 0.6 }} />
)}
</Group>
<Text size="xs" c="dimmed">
{room.encrypted ? "cifrada · E2E" : "abierta · cleartext"}
</Text>
</Box>
</Group>
<ActionIcon variant="subtle" color="gray">
<IconDotsVertical size={18} />
</ActionIcon>
</Group>
<Divider color="dark.4" />
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
<Stack gap="lg" p="md">
{messages.map((m) => (
<MessageRow key={m.id} msg={m} />
))}
</Stack>
</ScrollArea>
<Divider color="dark.4" />
{sendError && (
<Text c="red" size="xs" px="sm" pt={4}>
{sendError}
</Text>
)}
<Group p="sm" gap="xs" wrap="nowrap">
<ActionIcon variant="subtle" color="gray" size="lg">
<IconPaperclip size={18} />
</ActionIcon>
<TextInput
style={{ flex: 1 }}
radius="xl"
placeholder={`Mensaje a ${room.name}`}
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void send()}
/>
<ActionIcon
size="lg"
radius="xl"
variant="filled"
color="brand"
onClick={() => void send()}
disabled={!draft.trim()}
>
<IconSend size={18} />
</ActionIcon>
</Group>
</Stack>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState } from "react";
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
import { Sidebar } from "./Sidebar";
import { ChatPanel } from "./ChatPanel";
import { api } from "./api";
import type { Room, User } from "./types";
export function ChatShell({
user,
onLogout,
}: {
user: User;
onLogout: () => void;
}) {
const [rooms, setRooms] = useState<Room[]>([]);
const [activeId, setActiveId] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
api
.listRooms()
.then((rs) => {
setRooms(rs);
setActiveId((cur) => cur || rs[0]?.id || "");
setError(null);
})
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
load();
}, [load]);
const active = rooms.find((r) => r.id === activeId);
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
let panel = <ChatPanel room={active} />;
if (loading && rooms.length === 0) {
panel = (
<Center h="100%">
<Loader color="brand" />
</Center>
);
} else if (error) {
panel = (
<Center h="100%">
<Stack align="center" gap="sm">
<Text c="red" size="sm">
{error}
</Text>
<Button variant="light" color="brand" onClick={load}>
Reintentar
</Button>
</Stack>
</Center>
);
} else if (rooms.length === 0) {
panel = (
<Center h="100%">
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
</Center>
);
}
return (
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
<Box
w={320}
h="100%"
bg="dark.8"
style={{
borderRight: "1px solid var(--mantine-color-dark-4)",
flexShrink: 0,
}}
>
<Sidebar
user={user}
rooms={rooms}
activeId={activeId}
onSelect={setActiveId}
onLogout={onLogout}
/>
</Box>
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
{panel}
</Box>
</Flex>
);
}
+322
View File
@@ -0,0 +1,322 @@
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Button,
Card,
Center,
Checkbox,
CopyButton,
Group,
Loader,
PasswordInput,
SimpleGrid,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCheck,
IconCopy,
IconKey,
IconShieldLock,
} from "@tabler/icons-react";
import { api, ApiError } from "./api";
import { AuthCard, AuthHeader } from "./AuthShell";
import type { User } from "./types";
import { newMnemonic, mnemonicWords } from "./wallet/bip39";
import { deriveIdentity, type WalletIdentity } from "./wallet/derive";
import { saveAndOpen } from "./wallet/account";
type Step = "generating" | "show-seed" | "confirm-seed" | "password" | "joining";
// pickPositions chooses `count` distinct word positions (0-based) to ask the user
// to confirm. This is a UI choice, not key material, so Math.random is fine.
function pickPositions(total: number, count: number): number[] {
const all = Array.from({ length: total }, (_, i) => i);
for (let i = all.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[all[i], all[j]] = [all[j], all[i]];
}
return all.slice(0, count).sort((a, b) => a - b);
}
// Join is the onboarding page reached from an invite link (/join?token=XXX). It
// generates a brand-new BIP39 seed, derives the identity, shows the seed exactly
// once with a confirmation gate, takes a local password, registers the PUBLIC key
// with the bus using the token, and enters the chat. The seed is never persisted
// and never sent to the server.
export function Join({
token,
onJoined,
onRecover,
}: {
token: string;
onJoined: (u: User) => void;
onRecover: () => void;
}) {
const [step, setStep] = useState<Step>("generating");
const [mnemonic, setMnemonic] = useState("");
const [identity, setIdentity] = useState<WalletIdentity | null>(null);
const [error, setError] = useState<string | null>(null);
// Generate the seed + identity once on mount. Deriving is fast and pure.
useEffect(() => {
if (!token) {
setError("Enlace de invitación inválido: falta el token.");
return;
}
try {
const m = newMnemonic();
setMnemonic(m);
setIdentity(deriveIdentity(m));
setStep("show-seed");
} catch {
setError("No se pudo generar la identidad en este navegador.");
}
}, [token]);
const words = useMemo(() => mnemonicWords(mnemonic), [mnemonic]);
if (error && step === "generating") {
return (
<AuthCard>
<Alert color="red" icon={<IconAlertTriangle size={18} />} title="Error">
{error}
</Alert>
<Button variant="light" mt="md" onClick={onRecover}>
Recuperar con mi seed
</Button>
</AuthCard>
);
}
if (step === "generating" || !identity) {
return (
<Center h="100vh" bg="dark.9">
<Loader color="brand" />
</Center>
);
}
if (step === "show-seed") {
return (
<ShowSeed words={words} onContinue={() => setStep("confirm-seed")} />
);
}
if (step === "confirm-seed") {
return (
<ConfirmSeed
words={words}
onBack={() => setStep("show-seed")}
onConfirmed={() => setStep("password")}
/>
);
}
// step === "password" | "joining"
return (
<SetPassword
busy={step === "joining"}
error={error}
onSubmit={async (password) => {
setStep("joining");
setError(null);
try {
// Register the PUBLIC identity with the bus (token authorizes), then
// encrypt the private key locally and open the per-user session.
const res = await api.register(token, identity.signPub, identity.kexPub);
const user = await saveAndOpen(identity, res.handle, password);
onJoined(user);
} catch (e) {
setError(
e instanceof ApiError ? e.message : "No se pudo completar el alta.",
);
setStep("password");
}
}}
/>
);
}
// ---- sub-screens ----------------------------------------------------------
function ShowSeed({
words,
onContinue,
}: {
words: string[];
onContinue: () => void;
}) {
const [acknowledged, setAcknowledged] = useState(false);
const phrase = words.join(" ");
return (
<AuthCard>
<AuthHeader
icon={<IconShieldLock size={30} />}
title="Guarda tu frase de recuperación"
subtitle="Estas 12 palabras son tu ÚNICA forma de recuperar tu cuenta si olvidas la contraseña o cambias de dispositivo. No las compartas con nadie."
/>
<Card bg="dark.8" radius="md" p="md" withBorder>
<SimpleGrid cols={3} spacing="xs" verticalSpacing="xs">
{words.map((w, i) => (
<Group gap={6} wrap="nowrap" key={i}>
<Text size="xs" c="dimmed" w={18} ta="right">
{i + 1}
</Text>
<Text size="sm" ff="monospace" fw={600}>
{w}
</Text>
</Group>
))}
</SimpleGrid>
</Card>
<Group justify="space-between">
<CopyButton value={phrase}>
{({ copied, copy }) => (
<Button
variant="subtle"
size="xs"
color={copied ? "teal" : "gray"}
leftSection={
copied ? <IconCheck size={14} /> : <IconCopy size={14} />
}
onClick={copy}
>
{copied ? "Copiada" : "Copiar"}
</Button>
)}
</CopyButton>
</Group>
<Alert color="yellow" variant="light" icon={<IconAlertTriangle size={16} />}>
unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo
el administrador podrá darte de alta de nuevo.
</Alert>
<Checkbox
checked={acknowledged}
onChange={(e) => setAcknowledged(e.currentTarget.checked)}
label="He guardado mi frase de recuperación en un lugar seguro"
/>
<Button disabled={!acknowledged} onClick={onContinue}>
Continuar
</Button>
</AuthCard>
);
}
function ConfirmSeed({
words,
onBack,
onConfirmed,
}: {
words: string[];
onBack: () => void;
onConfirmed: () => void;
}) {
// Ask the user to re-type 3 random words from their phrase. This proves they
// actually wrote the seed down rather than clicking through.
const positions = useMemo(() => pickPositions(words.length, 3), [words.length]);
const [inputs, setInputs] = useState<Record<number, string>>({});
const allCorrect = positions.every(
(p) => (inputs[p] ?? "").trim().toLowerCase() === words[p],
);
const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0);
return (
<AuthCard>
<AuthHeader
icon={<IconCheck size={30} />}
title="Confirma tu frase"
subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien."
/>
<Stack gap="sm">
{positions.map((p) => (
<TextInput
key={p}
label={`Palabra #${p + 1}`}
placeholder={`palabra ${p + 1}`}
value={inputs[p] ?? ""}
error={
(inputs[p] ?? "").length > 0 &&
(inputs[p] ?? "").trim().toLowerCase() !== words[p]
? "No coincide"
: undefined
}
onChange={(e) => {
// Capture the value synchronously: React nulls e.currentTarget
// after dispatch, so reading it inside the state updater (which runs
// later) would throw "Cannot read properties of null".
const v = e.currentTarget.value;
setInputs((prev) => ({ ...prev, [p]: v }));
}}
autoComplete="off"
spellCheck={false}
/>
))}
</Stack>
{!allCorrect && anyTyped && (
<Text size="xs" c="dimmed">
Revisa el orden y la ortografía de las palabras.
</Text>
)}
<Group grow>
<Button variant="default" onClick={onBack}>
Volver a ver
</Button>
<Button disabled={!allCorrect} onClick={onConfirmed}>
Confirmar
</Button>
</Group>
</AuthCard>
);
}
function SetPassword({
busy,
error,
onSubmit,
}: {
busy: boolean;
error: string | null;
onSubmit: (password: string) => void;
}) {
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
const tooShort = pw.length > 0 && pw.length < 8;
const mismatch = pw2.length > 0 && pw !== pw2;
const ready = pw.length >= 8 && pw === pw2 && !busy;
return (
<AuthCard>
<AuthHeader
icon={<IconKey size={30} />}
title="Protege tu identidad"
subtitle="Elige una contraseña para cifrar tu clave en ESTE dispositivo. No se guarda ni se envía a ningún servidor; solo desbloquea tu clave local."
/>
<PasswordInput
label="Contraseña"
description="Mínimo 8 caracteres"
leftSection={<IconKey size={16} />}
value={pw}
error={tooShort ? "Demasiado corta" : undefined}
onChange={(e) => setPw(e.currentTarget.value)}
data-autofocus
/>
<PasswordInput
label="Repite la contraseña"
leftSection={<IconKey size={16} />}
value={pw2}
error={mismatch ? "No coincide" : undefined}
onChange={(e) => setPw2(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button disabled={!ready} loading={busy} onClick={() => onSubmit(pw)}>
Crear cuenta y entrar
</Button>
</AuthCard>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from "react";
import {
Button,
Card,
Center,
PasswordInput,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { IconShieldLock, IconKey } from "@tabler/icons-react";
import { api, ApiError } from "./api";
import type { User } from "./types";
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
const [handle, setHandle] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const ready = handle.trim().length > 0 && password.length > 0;
const connect = async () => {
if (!ready || busy) return;
setBusy(true);
setError(null);
try {
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
const me = await api.login(password);
const h = handle.trim() || me.endpoint.slice(0, 8);
onLogin({ id: me.endpoint, handle: h });
} catch (e) {
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
setBusy(false);
}
};
return (
<Center h="100vh" bg="dark.9">
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
<Stack align="center" gap="lg">
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
<IconShieldLock size={32} />
</ThemeIcon>
<Stack gap={2} align="center">
<Title order={2}>unibus</Title>
<Text c="dimmed" size="sm">
Mensajería cifrada de extremo a extremo
</Text>
</Stack>
<TextInput
w="100%"
label="Identidad"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
data-autofocus
/>
<PasswordInput
w="100%"
label="Contraseña"
description="Desbloquea tu identidad cifrada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void connect()}
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button
w="100%"
size="md"
onClick={() => void connect()}
disabled={!ready}
loading={busy}
>
Conectar
</Button>
</Stack>
</Card>
</Center>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { useMemo, useState } from "react";
import {
Alert,
Anchor,
Button,
Code,
Group,
PasswordInput,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { IconKey, IconRotateClockwise } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import type { User } from "./types";
import { isValidMnemonic, mnemonicWords, normalizeMnemonic } from "./wallet/bip39";
import { deriveIdentity } from "./wallet/derive";
import { saveAndOpen } from "./wallet/account";
type Step = "phrase" | "password";
// Recover re-creates an existing identity from its 12-word seed — no admin needed.
// Validating the BIP39 phrase and re-deriving yields the SAME keypair (same
// sign_pub) the bus already authorizes, so the user lands back in the allowlist
// with their place intact. A new local password then re-encrypts the key on this
// device. Only if the user loses BOTH the password AND the seed must the admin
// re-provision them.
export function Recover({
onRecovered,
onBack,
}: {
onRecovered: (u: User) => void;
onBack: () => void;
}) {
const [step, setStep] = useState<Step>("phrase");
const [phrase, setPhrase] = useState("");
const [handle, setHandle] = useState("");
const [pw, setPw] = useState("");
const [pw2, setPw2] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const normalized = normalizeMnemonic(phrase);
const wordCount = mnemonicWords(phrase).length;
const valid = isValidMnemonic(phrase);
// Re-derive as soon as the phrase is valid, so we can show the user which
// identity (sign_pub) it maps to before they commit a new password.
const identity = useMemo(
() => (valid ? deriveIdentity(normalized) : null),
[valid, normalized],
);
if (step === "phrase") {
return (
<AuthCard>
<AuthHeader
icon={<IconRotateClockwise size={30} />}
title="Recuperar con tu frase"
subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor."
/>
<Textarea
label="Frase de recuperación (12 palabras)"
placeholder="palabra1 palabra2 palabra3 …"
autosize
minRows={3}
value={phrase}
onChange={(e) => setPhrase(e.currentTarget.value)}
spellCheck={false}
autoComplete="off"
/>
<Text size="xs" c={valid ? "teal" : "dimmed"}>
{wordCount > 0
? valid
? "Frase válida ✓"
: `${wordCount}/12 palabras — frase aún no válida`
: "Separadas por espacios."}
</Text>
{identity && (
<Alert color="brand" variant="light" title="Identidad reconstruida">
<Text size="xs">Tu clave pública de firma (sign_pub):</Text>
<Code block>{identity.signPub}</Code>
</Alert>
)}
<Group grow>
<Button variant="default" onClick={onBack}>
Volver
</Button>
<Button disabled={!valid} onClick={() => setStep("password")}>
Continuar
</Button>
</Group>
</AuthCard>
);
}
// step === "password"
const tooShort = pw.length > 0 && pw.length < 8;
const mismatch = pw2.length > 0 && pw !== pw2;
const ready = pw.length >= 8 && pw === pw2 && !busy && identity !== null;
const finish = async () => {
if (!ready || !identity) return;
setBusy(true);
setError(null);
try {
// No register here: the identity is already in the allowlist. Just re-encrypt
// locally and open the session as the recovered user.
const user = await saveAndOpen(identity, handle.trim(), pw);
onRecovered(user);
} catch (e) {
setError(
e instanceof ApiError
? e.message
: "No se pudo abrir la sesión con la identidad recuperada.",
);
setBusy(false);
}
};
return (
<AuthCard>
<AuthHeader
icon={<IconKey size={30} />}
title="Nueva contraseña"
subtitle="Elige una contraseña para cifrar tu clave recuperada en este dispositivo."
/>
<Stack gap="sm">
<TextInput
label="Nombre a mostrar (opcional)"
placeholder="tu-handle"
value={handle}
onChange={(e) => setHandle(e.currentTarget.value)}
/>
<PasswordInput
label="Contraseña"
description="Mínimo 8 caracteres"
leftSection={<IconKey size={16} />}
value={pw}
error={tooShort ? "Demasiado corta" : undefined}
onChange={(e) => setPw(e.currentTarget.value)}
data-autofocus
/>
<PasswordInput
label="Repite la contraseña"
leftSection={<IconKey size={16} />}
value={pw2}
error={mismatch ? "No coincide" : undefined}
onChange={(e) => setPw2(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void finish()}
/>
</Stack>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Group grow>
<Button variant="default" onClick={() => setStep("phrase")}>
Volver
</Button>
<Button disabled={!ready} loading={busy} onClick={() => void finish()}>
Recuperar y entrar
</Button>
</Group>
<Group justify="center">
<Anchor size="xs" c="dimmed" onClick={onBack}>
Cancelar
</Anchor>
</Group>
</AuthCard>
);
}
+173
View File
@@ -0,0 +1,173 @@
import { useState } from "react";
import {
Avatar,
Badge,
Box,
Divider,
Group,
Menu,
ScrollArea,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import {
IconSearch,
IconLogout,
IconDots,
IconLock,
IconHash,
} from "@tabler/icons-react";
import type { Room, User } from "./types";
function initials(s: string) {
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
}
function timeShort(ts: number) {
const d = new Date(ts);
return `${String(d.getHours()).padStart(2, "0")}:${String(
d.getMinutes(),
).padStart(2, "0")}`;
}
function RoomItem({
room,
active,
onClick,
}: {
room: Room;
active: boolean;
onClick: () => void;
}) {
return (
<UnstyledButton
onClick={onClick}
p="xs"
style={{
borderRadius: "var(--mantine-radius-md)",
backgroundColor: active
? "var(--mantine-color-dark-6)"
: "transparent",
}}
>
<Group gap="sm" wrap="nowrap">
<Avatar radius="md" size={42} color={active ? "brand" : "gray"}>
{initials(room.name)}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
{room.encrypted ? (
<IconLock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
) : (
<IconHash size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
)}
<Text size="sm" fw={600} truncate>
{room.name}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
{timeShort(room.lastTs)}
</Text>
</Group>
<Group justify="space-between" gap={4} wrap="nowrap">
<Text size="xs" c="dimmed" truncate>
{room.lastMessage}
</Text>
{room.unread > 0 && (
<Badge size="sm" circle variant="filled" color="brand">
{room.unread}
</Badge>
)}
</Group>
</Box>
</Group>
</UnstyledButton>
);
}
export function Sidebar({
user,
rooms,
activeId,
onSelect,
onLogout,
}: {
user: User;
rooms: Room[];
activeId: string;
onSelect: (id: string) => void;
onLogout: () => void;
}) {
const [q, setQ] = useState("");
const query = q.trim().toLowerCase();
const filtered = query
? rooms.filter(
(r) =>
r.name.toLowerCase().includes(query) ||
r.messages.some((m) => m.body.toLowerCase().includes(query)),
)
: rooms;
return (
<Stack h="100%" gap={0}>
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar radius="xl" size={34} color="brand">
{initials(user.handle)}
</Avatar>
<Text fw={600} size="sm" truncate>
{user.handle}
</Text>
</Group>
<Menu position="bottom-end" withinPortal>
<Menu.Target>
<UnstyledButton c="dimmed">
<IconDots size={18} />
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconLogout size={15} />}
onClick={onLogout}
>
Desconectar
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
<Box px="sm" pb="sm">
<TextInput
value={q}
onChange={(e) => setQ(e.currentTarget.value)}
placeholder="Buscar rooms, usuarios, mensajes…"
leftSection={<IconSearch size={16} />}
radius="md"
size="sm"
/>
</Box>
<Divider color="dark.4" />
<ScrollArea style={{ flex: 1 }} type="scroll">
<Stack gap={2} p={6}>
{filtered.map((room) => (
<RoomItem
key={room.id}
room={room}
active={room.id === activeId}
onClick={() => onSelect(room.id)}
/>
))}
{filtered.length === 0 && (
<Text c="dimmed" size="sm" ta="center" mt="md">
Sin resultados
</Text>
)}
</Stack>
</ScrollArea>
</Stack>
);
}
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { Anchor, Button, Group, PasswordInput, Text } from "@mantine/core";
import { IconKey, IconWallet } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
import { ApiError } from "./api";
import type { User } from "./types";
import { unlockAndOpen } from "./wallet/account";
import { WrongPasswordError } from "./wallet/crypto";
// WalletLogin is shown when this device already holds an encrypted identity. The
// password decrypts the local private key and opens a per-user gateway session.
// The password is never stored and never sent to the server.
export function WalletLogin({
handle,
onLoggedIn,
onRecover,
}: {
handle: string;
onLoggedIn: (u: User) => void;
onRecover: () => void;
}) {
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const unlock = async () => {
if (!password || busy) return;
setBusy(true);
setError(null);
try {
const user = await unlockAndOpen(password);
onLoggedIn(user);
} catch (e) {
if (e instanceof WrongPasswordError) {
setError("Contraseña incorrecta.");
} else if (e instanceof ApiError) {
setError(e.message);
} else {
setError("No se pudo abrir tu identidad.");
}
setBusy(false);
}
};
return (
<AuthCard width={400}>
<AuthHeader
icon={<IconWallet size={30} />}
title="unibus"
subtitle={`Desbloquea la identidad de ${handle || "este dispositivo"}`}
/>
<PasswordInput
label="Contraseña"
description="Descifra tu clave guardada en este dispositivo"
placeholder="••••••••"
leftSection={<IconKey size={16} />}
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && void unlock()}
data-autofocus
/>
{error && (
<Text c="red" size="sm" ta="center">
{error}
</Text>
)}
<Button fullWidth onClick={() => void unlock()} disabled={!password} loading={busy}>
Entrar
</Button>
<Group justify="center">
<Anchor size="xs" c="dimmed" onClick={onRecover}>
¿Olvidaste la contraseña? Recupera con tu frase de 12 palabras
</Anchor>
</Group>
</AuthCard>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from "react";
import { Button, Divider, Stack, Text, TextInput } from "@mantine/core";
import { IconLink, IconRotateClockwise, IconShieldLock } from "@tabler/icons-react";
import { AuthCard, AuthHeader } from "./AuthShell";
// extractToken pulls the invite token out of whatever the user pastes: a full
// link (.../join?token=XXX), a bare "token=XXX", or just the token itself.
function extractToken(input: string): string {
const s = input.trim();
if (!s) return "";
const m = s.match(/[?&]token=([^&\s]+)/);
if (m) return decodeURIComponent(m[1]);
if (s.startsWith("token=")) return s.slice("token=".length);
return s;
}
// Welcome is the entry screen on a device with no local identity. It offers the
// two ways in: open an invite link (new account) or recover an existing account
// from its 12-word seed.
export function Welcome({
onJoinToken,
onRecover,
}: {
onJoinToken: (token: string) => void;
onRecover: () => void;
}) {
const [link, setLink] = useState("");
const token = extractToken(link);
return (
<AuthCard width={420}>
<AuthHeader
icon={<IconShieldLock size={30} />}
title="unibus"
subtitle="Mensajería cifrada de extremo a extremo. Tu identidad vive en tu dispositivo."
/>
<Stack gap="xs">
<Text size="sm" fw={600}>
Tengo un enlace de invitación
</Text>
<TextInput
placeholder="Pega aquí tu enlace /join?token=…"
leftSection={<IconLink size={16} />}
value={link}
onChange={(e) => setLink(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && token && onJoinToken(token)}
/>
<Button disabled={!token} onClick={() => onJoinToken(token)}>
Crear mi cuenta
</Button>
</Stack>
<Divider label="o" labelPosition="center" color="dark.4" />
<Stack gap="xs">
<Text size="sm" fw={600}>
Ya tengo una cuenta
</Text>
<Button
variant="default"
leftSection={<IconRotateClockwise size={16} />}
onClick={onRecover}
>
Recuperar con mi seed (12 palabras)
</Button>
</Stack>
</AuthCard>
);
}
+164 -96
View File
@@ -1,99 +1,167 @@
// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API. // La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
// Every method is a thin fetch against the gateway, which hosts one real Go bus // bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
// peer per name and performs all NATS + end-to-end crypto on the browser's // bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
// behalf. The base URL is chosen at runtime on the connect screen. // nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
import type { BusEvent, Member, Peer, Room } from "./types"; // sesión opaca (HttpOnly) que el gateway emite tras el login.
import type {
MeInfo,
Message,
MsgWire,
RegisterResult,
Room,
RoomWire,
} from "./types";
import type { WalletIdentity } from "./wallet/derive";
export class GatewayClient { export class ApiError extends Error {
constructor(public readonly baseURL: string) { status: number;
// Normalize: drop a trailing slash so `${base}/api/...` never doubles up. constructor(message: string, status: number) {
this.baseURL = baseURL.replace(/\/+$/, ""); super(message);
} this.status = status;
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(this.baseURL + path, {
method,
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await res.text();
if (!res.ok) {
let msg = text;
try {
const j = JSON.parse(text);
if (j && typeof j.error === "string") msg = j.error;
} catch {
// not JSON: keep the raw text
}
throw new Error(msg || `HTTP ${res.status}`);
}
return (text ? JSON.parse(text) : {}) as T;
}
// connect creates (or recovers) the named peer on the gateway and returns its
// public identity. The identity persists across gateway restarts.
connect(name: string): Promise<Peer> {
return this.req<Peer>("POST", "/api/peer", { name });
}
// peers lists every peer currently hosted by the gateway (for the invite picker
// and to label senders by name).
peers(): Promise<Peer[]> {
return this.req<Peer[]>("GET", "/api/peers");
}
// rooms lists the rooms the named peer knows (created or joined).
rooms(peer: string): Promise<Room[]> {
return this.req<Room[]>("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`);
}
// members lists the participants of a room.
members(roomID: string): Promise<Member[]> {
return this.req<Member[]>("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`);
}
// createRoom opens a room on the given subject. encrypt drives both E2E
// encryption and per-message signing; the peer is auto-subscribed.
createRoom(peer: string, subject: string, encrypt: boolean): Promise<Room & { persist: boolean }> {
return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false });
}
// join subscribes the peer to an existing room (must have been invited first
// when the room is encrypted).
join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> {
return this.req("POST", "/api/join", { peer, room_id: roomID });
}
// invite adds another connected peer (by name) to a room, sealing the room key
// to it. Caller must be the room owner.
invite(peer: string, roomID: string, target: string): Promise<{ status: string }> {
return this.req("POST", "/api/invite", { peer, room_id: roomID, target });
}
// publish sends a text message to a room.
publish(peer: string, roomID: string, text: string): Promise<{ status: string }> {
return this.req("POST", "/api/publish", { peer, room_id: roomID, text });
}
// kick removes a peer (by name) from a room and rotates the key (forward
// secrecy). Caller must be the room owner.
kick(peer: string, roomID: string, target: string): Promise<{ status: string }> {
return this.req("POST", "/api/kick", { peer, room_id: roomID, target });
}
// stream opens the SSE channel for a peer. onEvent fires for each received bus
// message; onError fires if the stream drops. Returns the EventSource so the
// caller can close it.
stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource {
const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`);
es.onmessage = (e) => {
try {
onEvent(JSON.parse(e.data) as BusEvent);
} catch {
// ignore malformed frames (keepalive comments never reach onmessage)
}
};
if (onError) es.onerror = onError;
return es;
} }
} }
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
// same-origin envía la cookie de sesión automáticamente (también detrás del
// proxy de vite en dev).
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
...init,
});
const text = await res.text();
let body: unknown = null;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const msg =
body && typeof body === "object" && "error" in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw new ApiError(msg, res.status);
}
return body as T;
}
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
// alimentará del stream en una iteración futura).
export function roomFromWire(r: RoomWire): Room {
return {
id: r.id,
name: r.name || r.subject,
encrypted: r.encrypt,
lastMessage: "",
lastTs: 0,
unread: 0,
messages: [],
};
}
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
export function messageFromWire(m: MsgWire): Message {
return {
id: m.id,
sender: m.sender,
body: m.body,
ts: m.ts,
mine: m.mine,
};
}
export const api = {
// ---- onboarding wallet --------------------------------------------------
// register publica la identidad PÚBLICA del nuevo usuario en el allowlist del
// bus usando el token del enlace de invitación. NO requiere sesión: el token
// autoriza. El handle y el rol los fija el invite, no el cliente. La clave
// privada NUNCA se envía aquí.
register: (token: string, signPub: string, kexPub: string) =>
req<RegisterResult>("/api/register", {
method: "POST",
body: JSON.stringify({ token, sign_pub: signPub, kex_pub: kexPub }),
}),
// session abre una sesión POR USUARIO: el navegador entrega su identidad wallet
// completa (incluida la privada, solo por TLS) y el gateway conecta un cliente
// del bus que actúa COMO ese usuario. La privada vive en memoria del gateway
// mientras dure la sesión; no se persiste en el servidor.
session: (id: WalletIdentity, handle: string) =>
req<MeInfo>("/api/session", {
method: "POST",
body: JSON.stringify({
handle,
sign_pub: id.signPub,
sign_priv: id.signPriv,
kex_pub: id.kexPub,
kex_priv: id.kexPriv,
}),
}),
// ---- sesión (legacy operador) ------------------------------------------
// login desbloquea una sesión ligada al gateway del operador con su passphrase.
// El camino principal ahora es el wallet (session); login se mantiene por
// compatibilidad con el MVP de operador único.
login: (passphrase: string) =>
req<MeInfo>("/api/login", {
method: "POST",
body: JSON.stringify({ passphrase }),
}),
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
me: () => req<MeInfo>("/api/me"),
// ---- rooms --------------------------------------------------------------
listRooms: async (): Promise<Room[]> => {
const wire = await req<RoomWire[]>("/api/rooms");
return wire.map(roomFromWire);
},
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
// Matrix-like (cifrada + persistida + firmada) por defecto.
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
const r = await req<RoomWire>("/api/rooms", {
method: "POST",
body: JSON.stringify({ subject, encrypted }),
});
return roomFromWire(r);
},
join: (roomID: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/join`,
{ method: "POST" },
),
send: (roomID: string, body: string) =>
req<{ status: string }>(
`/api/rooms/${encodeURIComponent(roomID)}/send`,
{ method: "POST", body: JSON.stringify({ body }) },
),
};
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
// reflejar el estado.
export function streamRoom(
roomID: string,
onMessage: (m: Message) => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
);
es.onmessage = (ev) => {
try {
const wire = JSON.parse(ev.data) as MsgWire;
onMessage(messageFromWire(wire));
} catch {
// frame malformado: se ignora, el stream sigue.
}
};
if (onError) es.onerror = onError;
return () => es.close();
}
-285
View File
@@ -1,285 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AppShell,
Group,
Title,
Badge,
Button,
CopyButton,
Tooltip,
ActionIcon,
ThemeIcon,
Alert,
Transition,
} from "@mantine/core";
import {
IconBolt,
IconLogout,
IconCopy,
IconCheck,
IconAlertTriangle,
} from "@tabler/icons-react";
import { GatewayClient } from "../api";
import type { Member, Message, Peer, Room } from "../types";
import { RoomList } from "./RoomList";
import { MessagePane } from "./MessagePane";
import { MembersPane } from "./MembersPane";
interface Props {
client: GatewayClient;
peer: Peer;
onDisconnect: () => void;
}
// short renders the first 10 chars of an endpoint id, enough to disambiguate.
export function short(endpoint: string): string {
return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint;
}
// ChatLayout owns all chat state: the peer's rooms, the active room, the
// per-room message log fed by the SSE stream, the directory of connected peers
// (to label senders and pick invitees), and the active room's member list. Every
// bus action goes through the gateway client.
export function ChatLayout({ client, peer, onDisconnect }: Props) {
const [rooms, setRooms] = useState<Room[]>([]);
const [activeRoom, setActiveRoom] = useState<string | null>(null);
const [messages, setMessages] = useState<Record<string, Message[]>>({});
const [peers, setPeers] = useState<Peer[]>([]);
const [members, setMembers] = useState<Member[]>([]);
const [error, setError] = useState<string | null>(null);
const seq = useRef(0);
const fail = useCallback((e: unknown) => {
setError(e instanceof Error ? e.message : String(e));
}, []);
// ---- data refreshers ----------------------------------------------------
const refreshRooms = useCallback(async () => {
try {
setRooms(await client.rooms(peer.name));
} catch (e) {
fail(e);
}
}, [client, peer.name, fail]);
const refreshPeers = useCallback(async () => {
try {
setPeers(await client.peers());
} catch (e) {
fail(e);
}
}, [client, fail]);
const refreshMembers = useCallback(
async (roomID: string) => {
try {
setMembers(await client.members(roomID));
} catch (e) {
fail(e);
}
},
[client, fail],
);
// ---- live stream (SSE) --------------------------------------------------
useEffect(() => {
const es = client.stream(
peer.name,
(ev) => {
seq.current += 1;
const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` };
setMessages((prev) => {
const list = prev[ev.room_id] ?? [];
return { ...prev, [ev.room_id]: [...list, msg] };
});
},
() => setError("Se perdió la conexión con el gateway (stream SSE)"),
);
return () => es.close();
}, [client, peer.name]);
// Initial load.
useEffect(() => {
refreshRooms();
refreshPeers();
}, [refreshRooms, refreshPeers]);
// Refresh members whenever the active room changes.
useEffect(() => {
if (activeRoom) refreshMembers(activeRoom);
else setMembers([]);
}, [activeRoom, refreshMembers]);
// ---- actions ------------------------------------------------------------
const onCreateRoom = useCallback(
async (subject: string, encrypt: boolean) => {
try {
const r = await client.createRoom(peer.name, subject, encrypt);
await refreshRooms();
setActiveRoom(r.room_id);
} catch (e) {
fail(e);
}
},
[client, peer.name, refreshRooms, fail],
);
const onJoinRoom = useCallback(
async (roomID: string) => {
try {
await client.join(peer.name, roomID);
await refreshRooms();
setActiveRoom(roomID);
} catch (e) {
fail(e);
}
},
[client, peer.name, refreshRooms, fail],
);
const onInvite = useCallback(
async (target: string) => {
if (!activeRoom) return;
try {
await client.invite(peer.name, activeRoom, target);
await refreshMembers(activeRoom);
} catch (e) {
fail(e);
}
},
[client, peer.name, activeRoom, refreshMembers, fail],
);
const onKick = useCallback(
async (target: string) => {
if (!activeRoom) return;
try {
await client.kick(peer.name, activeRoom, target);
await refreshMembers(activeRoom);
} catch (e) {
fail(e);
}
},
[client, peer.name, activeRoom, refreshMembers, fail],
);
const onPublish = useCallback(
async (text: string) => {
if (!activeRoom) return;
try {
await client.publish(peer.name, activeRoom, text);
} catch (e) {
fail(e);
}
},
[client, peer.name, activeRoom, fail],
);
// endpoint -> display name, using the peer directory; falls back to a short id.
const nameFor = useMemo(() => {
const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name]));
return (endpoint: string) =>
endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint);
}, [peers, peer]);
const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null;
const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner");
return (
<AppShell
header={{ height: 60 }}
navbar={{ width: 300, breakpoint: "sm" }}
aside={{ width: 300, breakpoint: "md", collapsed: { desktop: !activeRoom, mobile: true } }}
padding={0}
>
<AppShell.Header>
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon variant="light" color="violet" radius="md">
<IconBolt size={18} />
</ThemeIcon>
<Title order={4}>unibus</Title>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge variant="light" color="violet" size="lg">
{peer.name}
</Badge>
<CopyButton value={peer.endpoint_id}>
{({ copied, copy }) => (
<Tooltip label={copied ? "¡copiado!" : peer.endpoint_id} withArrow>
<ActionIcon variant="subtle" color="gray" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
<Button
variant="subtle"
color="gray"
leftSection={<IconLogout size={16} />}
onClick={onDisconnect}
>
Salir
</Button>
</Group>
</Group>
</AppShell.Header>
<AppShell.Navbar>
<RoomList
rooms={rooms}
activeRoom={activeRoom}
onSelect={setActiveRoom}
onCreateRoom={onCreateRoom}
onJoinRoom={onJoinRoom}
/>
</AppShell.Navbar>
<AppShell.Main h="100vh">
{error && (
<Transition mounted={!!error} transition="slide-down">
{(styles) => (
<Alert
style={{ ...styles, position: "absolute", top: 70, left: "50%", transform: "translateX(-50%)", zIndex: 200, minWidth: 360 }}
color="red"
variant="filled"
icon={<IconAlertTriangle size={18} />}
withCloseButton
onClose={() => setError(null)}
title="Error"
>
{error}
</Alert>
)}
</Transition>
)}
<MessagePane
room={activeRoomObj}
messages={activeRoom ? messages[activeRoom] ?? [] : []}
myEndpoint={peer.endpoint_id}
nameFor={nameFor}
onPublish={onPublish}
/>
</AppShell.Main>
<AppShell.Aside>
{activeRoomObj && (
<MembersPane
room={activeRoomObj}
members={members}
peers={peers}
myEndpoint={peer.endpoint_id}
iAmOwner={iAmOwner}
nameFor={nameFor}
onInvite={onInvite}
onKick={onKick}
onRefresh={() => activeRoom && refreshMembers(activeRoom)}
/>
)}
</AppShell.Aside>
</AppShell>
);
}
-116
View File
@@ -1,116 +0,0 @@
import { useState } from "react";
import {
Button,
Card,
Center,
Group,
Stack,
Text,
TextInput,
Title,
Alert,
ThemeIcon,
} from "@mantine/core";
import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react";
import { GatewayClient } from "../api";
import type { Peer } from "../types";
const LS_GATEWAY = "unibus.gateway";
const LS_PEER = "unibus.peer";
interface Props {
onConnect: (client: GatewayClient, peer: Peer) => void;
}
// ConnectScreen asks for the gateway URL and the identity (peer name) to connect
// as. Both persist in localStorage so a reload reconnects with one click. The
// gateway hosts the real Go bus peer; the browser only drives it.
export function ConnectScreen({ onConnect }: Props) {
const [gateway, setGateway] = useState(
() => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700",
);
const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const connect = async () => {
const trimmed = name.trim();
if (!trimmed) {
setError("Elige un nombre de identidad");
return;
}
setBusy(true);
setError(null);
try {
const client = new GatewayClient(gateway.trim());
const peer = await client.connect(trimmed);
localStorage.setItem(LS_GATEWAY, client.baseURL);
localStorage.setItem(LS_PEER, trimmed);
onConnect(client, peer);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<Center h="100vh" p="md">
<Card withBorder shadow="md" radius="lg" p="xl" w={420} maw="100%">
<Stack gap="lg">
<Group gap="sm">
<ThemeIcon size="xl" radius="md" variant="light" color="violet">
<IconBolt size={26} />
</ThemeIcon>
<div>
<Title order={3}>unibus</Title>
<Text size="sm" c="dimmed">
chat cifrado extremo a extremo sobre NATS
</Text>
</div>
</Group>
<TextInput
label="Gateway"
description="URL del gateway web de unibus"
placeholder="http://localhost:7700"
value={gateway}
onChange={(e) => setGateway(e.currentTarget.value)}
disabled={busy}
/>
<TextInput
label="Identidad"
description="Tu nombre de peer en el bus (persistente)"
placeholder="ana"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && connect()}
disabled={busy}
data-autofocus
/>
{error && (
<Alert
color="red"
variant="light"
icon={<IconAlertTriangle size={18} />}
title="No se pudo conectar"
>
{error}
</Alert>
)}
<Button
leftSection={<IconPlugConnected size={18} />}
onClick={connect}
loading={busy}
fullWidth
size="md"
>
Conectar
</Button>
</Stack>
</Card>
</Center>
);
}
-153
View File
@@ -1,153 +0,0 @@
import { useState } from "react";
import {
Stack,
Group,
Text,
Badge,
Select,
Button,
ActionIcon,
Divider,
Box,
Avatar,
Tooltip,
ScrollArea,
} from "@mantine/core";
import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react";
import type { Member, Peer, Room } from "../types";
interface Props {
room: Room;
members: Member[];
peers: Peer[];
myEndpoint: string;
iAmOwner: boolean;
nameFor: (endpoint: string) => string;
onInvite: (target: string) => void;
onKick: (target: string) => void;
onRefresh: () => void;
}
// MembersPane is the right column: who is in the active room, plus invite (pick a
// connected peer) and kick (owner only). Invite/kick address peers by name; the
// gateway resolves the name to its bus endpoint.
export function MembersPane({
room,
members,
peers,
myEndpoint,
iAmOwner,
nameFor,
onInvite,
onKick,
onRefresh,
}: Props) {
const [target, setTarget] = useState<string | null>(null);
const memberEndpoints = new Set(members.map((m) => m.endpoint));
// Candidates to invite: connected peers not already in the room.
const candidates = peers
.filter((p) => !memberEndpoints.has(p.endpoint_id))
.map((p) => ({ value: p.name, label: p.name }));
const invite = () => {
if (target) {
onInvite(target);
setTarget(null);
}
};
return (
<Stack gap={0} h="100%">
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
<Group gap="xs">
<IconUsers size={18} />
<Text fw={600}>Miembros</Text>
<Badge size="sm" variant="light">
{members.length}
</Badge>
</Group>
<Tooltip label="Recargar" withArrow>
<ActionIcon variant="subtle" color="gray" onClick={onRefresh}>
<IconRefresh size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Invitar {room.encrypt && "(reparte la clave)"}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-end">
<Select
style={{ flex: 1 }}
size="xs"
placeholder="peer conectado"
data={candidates}
value={target}
onChange={setTarget}
searchable
nothingFoundMessage="sin peers libres"
comboboxProps={{ withinPortal: true }}
/>
<Button
size="xs"
leftSection={<IconUserPlus size={14} />}
onClick={invite}
disabled={!target}
>
Invitar
</Button>
</Group>
</Box>
<Divider />
<ScrollArea style={{ flex: 1 }}>
<Stack gap={4} p="md">
{members.map((m) => {
const isMe = m.endpoint === myEndpoint;
const name = nameFor(m.endpoint);
const canKick = iAmOwner && !isMe && m.role !== "owner";
return (
<Group key={m.endpoint} justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Avatar size="sm" radius="xl" color="violet">
{name.slice(0, 2).toUpperCase()}
</Avatar>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={isMe ? 700 : 500} truncate>
{name} {isMe && "(tú)"}
</Text>
<Text size="9px" c="dimmed" truncate>
{m.endpoint}
</Text>
</Box>
</Group>
<Group gap={4} wrap="nowrap">
{m.role === "owner" && (
<Badge size="xs" color="yellow" variant="light">
owner
</Badge>
)}
{canKick && (
<Tooltip label="Expulsar (rota la clave)" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onKick(name)}
>
<IconUserMinus size={15} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
);
})}
</Stack>
</ScrollArea>
</Stack>
);
}
-153
View File
@@ -1,153 +0,0 @@
import { useEffect, useRef, useState } from "react";
import {
Stack,
Group,
Text,
Badge,
Paper,
ScrollArea,
TextInput,
ActionIcon,
Center,
ThemeIcon,
Box,
CopyButton,
Tooltip,
} from "@mantine/core";
import {
IconLock,
IconHash,
IconSend,
IconMessages,
IconCopy,
IconCheck,
} from "@tabler/icons-react";
import type { Message, Room } from "../types";
interface Props {
room: Room | null;
messages: Message[];
myEndpoint: string;
nameFor: (endpoint: string) => string;
onPublish: (text: string) => void;
}
// formatTime renders a message timestamp as HH:mm:ss in 24h European style.
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString("es-ES", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// MessagePane is the center column: the active room's live message log plus the
// composer. Own messages align right; others align left and show the sender.
export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) {
const [text, setText] = useState("");
const viewport = useRef<HTMLDivElement>(null);
// Auto-scroll to the newest message.
useEffect(() => {
viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
}, [messages.length]);
if (!room) {
return (
<Center h="100%">
<Stack align="center" gap="xs">
<ThemeIcon size={64} radius="xl" variant="light" color="gray">
<IconMessages size={34} />
</ThemeIcon>
<Text c="dimmed">Elige o crea una room para empezar a chatear</Text>
</Stack>
</Center>
);
}
const send = () => {
const t = text.trim();
if (t) {
onPublish(t);
setText("");
}
};
return (
<Stack gap={0} h="100%">
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
<Group gap="xs" wrap="nowrap">
{room.encrypt ? <IconLock size={18} /> : <IconHash size={18} />}
<Text fw={600}>{room.subject}</Text>
{room.encrypt && (
<Badge size="sm" color="teal" variant="light">
cifrada E2E
</Badge>
)}
</Group>
<CopyButton value={room.room_id}>
{({ copied, copy }) => (
<Tooltip label={copied ? "¡copiado!" : "copiar room id"} withArrow>
<ActionIcon variant="subtle" color="gray" onClick={copy}>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} p="md">
<Stack gap="sm">
{messages.length === 0 && (
<Text c="dimmed" ta="center" py="xl" size="sm">
No hay mensajes todavía.
</Text>
)}
{messages.map((m) => {
const mine = m.sender === myEndpoint;
return (
<Box
key={m.id}
style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}
>
<Paper
withBorder
shadow="xs"
radius="md"
p="xs"
bg={mine ? "violet.9" : undefined}
maw="75%"
>
{!mine && (
<Text size="xs" fw={700} c="violet.4">
{nameFor(m.sender)}
</Text>
)}
<Text size="sm" style={{ wordBreak: "break-word", whiteSpace: "pre-wrap" }}>
{m.text}
</Text>
<Text size="9px" c="dimmed" ta="right" mt={2}>
{formatTime(m.ts)}
</Text>
</Paper>
</Box>
);
})}
</Stack>
</ScrollArea>
<Group p="md" gap="xs" wrap="nowrap" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
<TextInput
style={{ flex: 1 }}
placeholder={`Mensaje a ${room.subject}`}
value={text}
onChange={(e) => setText(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
/>
<ActionIcon size="lg" onClick={send} disabled={!text.trim()}>
<IconSend size={18} />
</ActionIcon>
</Group>
</Stack>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { useState } from "react";
import {
Stack,
TextInput,
Checkbox,
Button,
Divider,
Text,
NavLink,
ScrollArea,
Group,
Box,
} from "@mantine/core";
import { IconLock, IconHash, IconPlus, IconDoorEnter } from "@tabler/icons-react";
import type { Room } from "../types";
interface Props {
rooms: Room[];
activeRoom: string | null;
onSelect: (roomID: string) => void;
onCreateRoom: (subject: string, encrypt: boolean) => void;
onJoinRoom: (roomID: string) => void;
}
// RoomList is the navbar: create a room, join one by id, and pick the active
// room from the peer's known rooms.
export function RoomList({ rooms, activeRoom, onSelect, onCreateRoom, onJoinRoom }: Props) {
const [subject, setSubject] = useState("room.general");
const [encrypt, setEncrypt] = useState(true);
const [joinID, setJoinID] = useState("");
const create = () => {
if (subject.trim()) onCreateRoom(subject.trim(), encrypt);
};
const join = () => {
if (joinID.trim()) {
onJoinRoom(joinID.trim());
setJoinID("");
}
};
return (
<Stack gap={0} h="100%">
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Crear room
</Text>
<Stack gap="xs">
<TextInput
size="xs"
placeholder="subject (room.general)"
leftSection={<IconHash size={14} />}
value={subject}
onChange={(e) => setSubject(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
/>
<Checkbox
size="xs"
label="Cifrado extremo a extremo"
checked={encrypt}
onChange={(e) => setEncrypt(e.currentTarget.checked)}
/>
<Button size="xs" leftSection={<IconPlus size={14} />} onClick={create}>
Crear
</Button>
</Stack>
</Box>
<Divider />
<Box p="md">
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
Unirse por id
</Text>
<Group gap="xs" wrap="nowrap">
<TextInput
size="xs"
placeholder="room id"
value={joinID}
onChange={(e) => setJoinID(e.currentTarget.value)}
onKeyDown={(e) => e.key === "Enter" && join()}
style={{ flex: 1 }}
/>
<Button size="xs" variant="light" onClick={join} px="sm">
<IconDoorEnter size={16} />
</Button>
</Group>
</Box>
<Divider />
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="md" pt="md" pb="xs">
Rooms ({rooms.length})
</Text>
<ScrollArea style={{ flex: 1 }}>
<Stack gap={2} px="xs" pb="md">
{rooms.length === 0 && (
<Text size="sm" c="dimmed" px="sm" py="lg" ta="center">
Aún no hay rooms. Crea o únete a una.
</Text>
)}
{rooms.map((r) => (
<NavLink
key={r.room_id}
active={r.room_id === activeRoom}
onClick={() => onSelect(r.room_id)}
label={r.subject}
description={r.room_id.slice(0, 14) + "…"}
leftSection={
r.encrypt ? <IconLock size={16} /> : <IconHash size={16} />
}
variant="filled"
/>
))}
</Stack>
</ScrollArea>
</Stack>
);
}
+6 -6
View File
@@ -1,14 +1,14 @@
import React from "react"; import { StrictMode } from "react";
import ReactDOM from "react-dom/client"; import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core"; import { MantineProvider } from "@mantine/core";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import { theme } from "./theme"; import { theme } from "./theme";
import { App } from "./App"; import { App } from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<React.StrictMode> <StrictMode>
<MantineProvider theme={theme} defaultColorScheme="dark"> <MantineProvider theme={theme} forceColorScheme="dark">
<App /> <App />
</MantineProvider> </MantineProvider>
</React.StrictMode>, </StrictMode>,
); );
+59
View File
@@ -0,0 +1,59 @@
import type { Room } from "./types";
// Datos de muestra para iterar el diseño sin el bus conectado.
const now = 1749300000000;
const m = (n: number) => now - n * 60_000;
export const MOCK_ROOMS: Room[] = [
{
id: "general",
name: "general",
encrypted: true,
lastMessage: "¿Lo desplegamos hoy?",
lastTs: m(2),
unread: 3,
messages: [
{ id: "1", sender: "ana", body: "Buenas, ¿cómo va el cluster?", ts: m(40) },
{ id: "2", sender: "lucas", body: "Los 3 nodos en R3, quorum verde", ts: m(38), mine: true },
{ id: "3", sender: "ana", body: "Brutal. ¿Y el frontend?", ts: m(30) },
{ id: "4", sender: "leo", body: "Primera iteración lista, estilo Element", ts: m(6) },
{ id: "5", sender: "ana", body: "¿Lo desplegamos hoy?", ts: m(2) },
],
},
{
id: "board",
name: "board · privado",
encrypted: true,
lastMessage: "Os paso el acta cifrada",
lastTs: m(95),
unread: 0,
messages: [
{ id: "1", sender: "ceo", body: "Reunión a las 18:00", ts: m(120) },
{ id: "2", sender: "lucas", body: "Anotado", ts: m(96), mine: true },
{ id: "3", sender: "ceo", body: "Os paso el acta cifrada", ts: m(95) },
],
},
{
id: "bots",
name: "bots",
encrypted: false,
lastMessage: "echo: ping",
lastTs: m(210),
unread: 0,
messages: [
{ id: "1", sender: "lucas", body: "!ping", ts: m(212), mine: true },
{ id: "2", sender: "echobot", body: "echo: ping", ts: m(210) },
],
},
{
id: "infra",
name: "infra",
encrypted: true,
lastMessage: "magnus + homer + datardos OK",
lastTs: m(330),
unread: 1,
messages: [
{ id: "1", sender: "leo", body: "magnus + homer + datardos OK", ts: m(330) },
],
},
];
+20 -10
View File
@@ -1,14 +1,24 @@
import { createTheme } from "@mantine/core"; import { createTheme, type MantineColorsTuple } from "@mantine/core";
// Acento de marca de unibus — un violeta-índigo moderno.
const brand: MantineColorsTuple = [
"#f1edff",
"#dcd3ff",
"#b5a3f5",
"#8d70ed",
"#6c47e6",
"#5a2fe2",
"#5023e0",
"#4119c7",
"#3915b3",
"#2f0f9e",
];
// The unibus theme: a single accent color and a slightly tighter default radius.
// Mantine generates all its CSS variables from this; the SPA never hand-writes
// CSS or color literals.
export const theme = createTheme({ export const theme = createTheme({
primaryColor: "violet", primaryColor: "brand",
defaultRadius: "md", colors: { brand },
fontFamily: fontFamily:
"Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif", "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
headings: { defaultRadius: "md",
fontWeight: "650", headings: { fontWeight: "650" },
},
}); });
+50 -26
View File
@@ -1,41 +1,65 @@
// Domain types shared across the SPA. They mirror the JSON the unibus gateway // Tipos de dominio de la UI. Los datos vienen del gateway Go (REST/SSE), que es
// (playground/server.go) returns; the browser never speaks NATS or crypto // un peer autenticado del bus. El navegador nunca firma ni habla NATS.
// directly — the Go peer behind the gateway does, so every type here is a plain
// view of a gateway response.
// Peer is a named identity hosted by the gateway. endpoint_id is the stable bus export interface User {
// endpoint (base64url of sha256(signPub)). id: string;
export interface Peer { handle: string;
name: string; }
endpoint_id: string;
export interface Message {
id: string;
sender: string; // endpoint id del remitente (handle legible es fase 2)
body: string;
ts: number; // epoch ms
mine?: boolean;
} }
// Room is a channel the connected peer created or joined. encrypt true means the
// payloads are sealed end-to-end with the room key.
export interface Room { export interface Room {
room_id: string; id: string;
subject: string; name: string;
encrypt: boolean; encrypted: boolean;
lastMessage: string;
lastTs: number;
unread: number;
messages: Message[];
} }
// Member is one participant of a room as reported by the control plane. // ---- formas de la API del gateway (wire) ---------------------------------
export interface Member {
// MeInfo es la identidad que el gateway encarna en la sesión actual (GET /api/me,
// POST /api/session, POST /api/login). En el modelo wallet es la identidad del
// USUARIO logueado; `handle` es su nombre a mostrar.
export interface MeInfo {
endpoint: string; endpoint: string;
sign_pub: string;
handle: string;
}
// RegisterResult es la respuesta de POST /api/register: el handle y rol que el
// invite (token) fijó para el nuevo usuario.
export interface RegisterResult {
handle: string;
role: string; role: string;
} }
// BusEvent is one Server-Sent Event delivered on /api/stream: a message a peer // RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae
// received on one of its subscribed rooms, already decrypted by the Go peer. // mensajes: estos llegan por SSE (GET /api/rooms/{id}/stream).
export interface BusEvent { export interface RoomWire {
room_id: string; id: string;
subject: string; subject: string;
sender: string; name: string;
text: string; epoch: number;
encrypted: boolean; encrypt: boolean;
ts: number; // unix millis persist: boolean;
sign_msgs: boolean;
role: string;
} }
// Message is a BusEvent enriched with a stable local id for React keys. // MsgWire es un mensaje ya descifrado que el gateway empuja por SSE.
export interface Message extends BusEvent { export interface MsgWire {
id: string; id: string;
sender: string;
body: string;
ts: number;
mine: boolean;
} }
+60
View File
@@ -0,0 +1,60 @@
// High-level wallet account operations shared by the join, recover and login
// flows. These compose the low-level primitives (derive / crypto / store) with
// the gateway API so the page components stay thin.
import { api } from "../api";
import type { MeInfo, User } from "../types";
import { decryptJSON, encryptJSON } from "./crypto";
import type { WalletIdentity } from "./derive";
import { getIdentity, putIdentity, type StoredIdentity } from "./store";
function toUser(me: MeInfo): User {
return { id: me.endpoint, handle: me.handle || me.endpoint.slice(0, 8) };
}
// saveAndOpen encrypts the identity under `password`, stores it on this device,
// and opens a gateway session as that user. Used by join (new identity) and
// recover (re-derived identity): both end with a locally-encrypted key plus a
// live per-user session. The mnemonic/seed is NOT touched here — only the derived
// keypair is persisted (encrypted).
export async function saveAndOpen(
identity: WalletIdentity,
handle: string,
password: string,
): Promise<User> {
const enc = await encryptJSON(identity, password);
await putIdentity({
handle,
signPub: identity.signPub,
kexPub: identity.kexPub,
enc,
createdAt: Date.now(),
});
const me = await api.session(identity, handle);
return toUser(me);
}
// unlockAndOpen reads this device's stored identity, decrypts the private key with
// `password`, and opens a gateway session. Throws WrongPasswordError on a bad
// password (GCM auth failure) and NoLocalIdentityError if the device has none.
export async function unlockAndOpen(password: string): Promise<User> {
const stored = await getIdentity();
if (!stored) throw new NoLocalIdentityError();
const identity = await decryptJSON<WalletIdentity>(stored.enc, password);
const me = await api.session(identity, stored.handle);
return toUser(me);
}
// localIdentity returns the device's stored identity record (or null), for the
// router to decide between the password-unlock screen and the welcome screen, and
// to greet the user by handle before unlocking.
export async function localIdentity(): Promise<StoredIdentity | null> {
return getIdentity();
}
export class NoLocalIdentityError extends Error {
constructor() {
super("no local identity on this device");
this.name = "NoLocalIdentityError";
}
}
+55
View File
@@ -0,0 +1,55 @@
// Thin wrappers over @scure/bip39 (a small, audited BIP39 implementation that
// ships the English wordlist and the mnemonic<->entropy conversions). We do not
// roll our own checksum logic — getting the BIP39 checksum wrong silently is a
// classic footgun, so the conversion stays in the library.
import {
generateMnemonic,
validateMnemonic,
mnemonicToEntropy,
} from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
// MNEMONIC_STRENGTH_BITS = 128 bits of entropy => exactly 12 words.
export const MNEMONIC_STRENGTH_BITS = 128;
export const MNEMONIC_WORD_COUNT = 12;
// newMnemonic returns a fresh 12-word mnemonic from a CSPRNG (crypto.getRandomValues
// inside @scure). The caller must show it to the user once and never persist it.
export function newMnemonic(): string {
return generateMnemonic(wordlist, MNEMONIC_STRENGTH_BITS);
}
// normalizeMnemonic lowercases, trims and collapses whitespace so a phrase the
// user typed (extra spaces, trailing newline, mixed case) validates the same way
// it would have been generated.
export function normalizeMnemonic(input: string): string {
return input.trim().toLowerCase().split(/\s+/).filter(Boolean).join(" ");
}
// mnemonicWords splits a phrase into its individual words (normalized).
export function mnemonicWords(input: string): string[] {
const n = normalizeMnemonic(input);
return n ? n.split(" ") : [];
}
// isValidMnemonic checks word count, that every word is in the wordlist, and the
// BIP39 checksum. A phrase that fails this must not be used to derive an identity.
export function isValidMnemonic(input: string): boolean {
const n = normalizeMnemonic(input);
if (mnemonicWords(n).length !== MNEMONIC_WORD_COUNT) return false;
try {
return validateMnemonic(n, wordlist);
} catch {
return false;
}
}
// entropyHex returns the underlying entropy (hex) of a valid mnemonic. Used only
// for diagnostics / tests, never sent anywhere.
export function entropyHex(input: string): string {
const bytes = mnemonicToEntropy(normalizeMnemonic(input), wordlist);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
+124
View File
@@ -0,0 +1,124 @@
// Local at-rest encryption of the wallet's private key, using only the platform
// WebCrypto (crypto.subtle) — no extra dependency, no WASM. The password derives
// an AES-GCM key via PBKDF2; the password itself is never stored, never sent to
// the server, and is not part of the identity (it only protects the local copy
// of the private key). The identity's source of truth is the BIP39 seed.
// PBKDF2 work factor. 210k SHA-256 iterations is the OWASP 2023 floor for
// PBKDF2-HMAC-SHA256; stored alongside the blob so a future bump stays readable.
const PBKDF2_ITERS = 210_000;
// EncryptedBlob is the at-rest form of a secret: AES-256-GCM ciphertext plus the
// public KDF parameters needed to re-derive the key from the password. None of
// these fields is secret on its own — only the password (never stored) unlocks it.
export interface EncryptedBlob {
kdf: "PBKDF2-SHA256";
iters: number;
salt: string; // hex, 16 random bytes (PBKDF2 salt)
iv: string; // hex, 12 random bytes (AES-GCM nonce)
ciphertext: string; // hex (includes the GCM auth tag)
}
function toHex(b: Uint8Array): string {
let s = "";
for (const x of b) s += x.toString(16).padStart(2, "0");
return s;
}
function fromHex(h: string): Uint8Array {
const out = new Uint8Array(h.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
async function deriveAesKey(
password: string,
salt: Uint8Array,
iters: number,
): Promise<CryptoKey> {
const enc = new TextEncoder();
const baseKey = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
return crypto.subtle.deriveKey(
{ name: "PBKDF2", salt: salt as BufferSource, iterations: iters, hash: "SHA-256" },
baseKey,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
// encryptSecret seals `plaintext` under `password` with a fresh random salt+iv.
export async function encryptSecret(
plaintext: Uint8Array,
password: string,
): Promise<EncryptedBlob> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const key = await deriveAesKey(password, salt, PBKDF2_ITERS);
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: iv as BufferSource },
key,
plaintext as BufferSource,
);
return {
kdf: "PBKDF2-SHA256",
iters: PBKDF2_ITERS,
salt: toHex(salt),
iv: toHex(iv),
ciphertext: toHex(new Uint8Array(ct)),
};
}
// WrongPasswordError is thrown when GCM authentication fails on decrypt — almost
// always a wrong password (or a corrupted blob). Callers map it to a friendly
// "contraseña incorrecta" message.
export class WrongPasswordError extends Error {
constructor() {
super("wrong password");
this.name = "WrongPasswordError";
}
}
// decryptSecret re-derives the key from `password` and opens the blob. A wrong
// password makes GCM verification fail, surfaced as WrongPasswordError.
export async function decryptSecret(
blob: EncryptedBlob,
password: string,
): Promise<Uint8Array> {
const key = await deriveAesKey(password, fromHex(blob.salt), blob.iters);
try {
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: fromHex(blob.iv) as BufferSource },
key,
fromHex(blob.ciphertext) as BufferSource,
);
return new Uint8Array(pt);
} catch {
throw new WrongPasswordError();
}
}
// JSON convenience: encrypt/decrypt a JS value as UTF-8 JSON. We use this to seal
// the whole WalletIdentity object (the private halves) under the password.
export async function encryptJSON(
value: unknown,
password: string,
): Promise<EncryptedBlob> {
return encryptSecret(new TextEncoder().encode(JSON.stringify(value)), password);
}
export async function decryptJSON<T>(
blob: EncryptedBlob,
password: string,
): Promise<T> {
const bytes = await decryptSecret(blob, password);
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
+69
View File
@@ -0,0 +1,69 @@
// Deterministic identity derivation from a BIP39 mnemonic.
//
// The identity is NOT a loose random keypair: it is derived deterministically
// and reproducibly from a 12-word BIP39 mnemonic (128 bits of entropy). The
// SAME mnemonic always yields the SAME keypair (same sign_pub), which is what
// lets a user recover their account on a new device — or after forgetting the
// local password — without admin intervention: the re-derived identity is byte
// for byte the one already in the bus allowlist.
//
// SCHEME (must be identical at create time and at recovery time):
//
// 1. mnemonic 12 BIP39 words (128-bit entropy + 4-bit checksum)
// 2. seed = BIP39_seed(mnemonic)
// = PBKDF2(HMAC-SHA512, password = NFKD(mnemonic),
// salt = "mnemonic", iterations = 2048, dkLen = 64)
// (the standard BIP39 seed; no extra passphrase)
// 3. signSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-sign-v1", L = 32)
// 4. Ed25519 signing key from signSeed:
// sign_pub = Ed25519.publicKey(signSeed) (32 bytes)
// sign_priv = signSeed || sign_pub (64 bytes; Go's
// ed25519.PrivateKey layout = seed||pub, what the gateway expects)
// 5. kexSeed = HKDF-SHA256(ikm = seed, salt = "", info = "unibus-kex-v1", L = 32)
// 6. X25519 key-exchange key from kexSeed:
// kex_priv = kexSeed (32 bytes; X25519 clamps internally)
// kex_pub = X25519.publicKey(kexSeed) (32 bytes)
//
// The two distinct HKDF `info` labels domain-separate the signing key from the
// key-exchange key so they can never collide. All four halves match cs.Identity
// on the Go side exactly (sign_pub 32, sign_priv 64, kex_pub 32, kex_priv 32),
// so the gateway can act as the user's peer with the derived keys.
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { hkdf } from "@noble/hashes/hkdf.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, concatBytes } from "@noble/hashes/utils.js";
import { mnemonicToSeedSync } from "@scure/bip39";
export const INFO_SIGN = "unibus-sign-v1";
export const INFO_KEX = "unibus-kex-v1";
// WalletIdentity holds the four keypair halves, each lowercase hex. This is the
// shape the gateway's POST /api/session consumes (and a subset — the two public
// halves — is what POST /api/register sends to the bus).
export interface WalletIdentity {
signPub: string; // 64 hex (32-byte Ed25519 public key)
signPriv: string; // 128 hex (64-byte Ed25519 private key, seed||pub)
kexPub: string; // 64 hex (32-byte X25519 public key)
kexPriv: string; // 64 hex (32-byte X25519 private key)
}
// deriveIdentity turns a validated BIP39 mnemonic into the deterministic
// keypair. Pure: the same mnemonic in always produces the same identity out.
export function deriveIdentity(mnemonic: string): WalletIdentity {
const seed = mnemonicToSeedSync(mnemonic.normalize("NFKD")); // 64 bytes
const info = new TextEncoder();
const signSeed = hkdf(sha256, seed, undefined, info.encode(INFO_SIGN), 32);
const kexSeed = hkdf(sha256, seed, undefined, info.encode(INFO_KEX), 32);
const signPub = ed25519.getPublicKey(signSeed);
const signPriv = concatBytes(signSeed, signPub); // Go ed25519.PrivateKey = seed||pub
const kexPub = x25519.getPublicKey(kexSeed);
return {
signPub: bytesToHex(signPub),
signPriv: bytesToHex(signPriv),
kexPub: bytesToHex(kexPub),
kexPriv: bytesToHex(kexSeed),
};
}
+95
View File
@@ -0,0 +1,95 @@
// IndexedDB persistence of the device-local wallet. Only the encrypted private
// key plus the public halves and the display handle are stored — never the
// password, never the BIP39 seed. The private key never leaves the device except
// over TLS to the gateway to open a session (see api.session).
//
// MVP: one active identity per device (keyed by a fixed id). Multi-account on a
// single device is a documented gap.
import type { EncryptedBlob } from "./crypto";
const DB_NAME = "unibus-wallet";
const DB_VERSION = 1;
const STORE = "identity";
const ACTIVE_ID = "active";
// StoredIdentity is one row in IndexedDB. `enc` is the encrypted WalletIdentity
// (all four hex halves); signPub/kexPub are kept in the clear for display and so
// the UI can show who you are without unlocking.
export interface StoredIdentity {
id: string; // always ACTIVE_ID for the single-identity MVP
handle: string;
signPub: string; // 64 hex (public, safe to store in the clear)
kexPub: string; // 64 hex (public)
enc: EncryptedBlob; // encrypted private identity (the secret material)
createdAt: number;
}
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE)) {
db.createObjectStore(STORE, { keyPath: "id" });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
function tx<T>(
db: IDBDatabase,
mode: IDBTransactionMode,
fn: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
return new Promise((resolve, reject) => {
const t = db.transaction(STORE, mode);
const req = fn(t.objectStore(STORE));
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
// getIdentity returns the device's active identity, or null if this device has
// no wallet yet (first visit, or a fresh device awaiting recovery/invite).
export async function getIdentity(): Promise<StoredIdentity | null> {
const db = await openDB();
try {
const row = await tx<StoredIdentity | undefined>(db, "readonly", (s) =>
s.get(ACTIVE_ID),
);
return row ?? null;
} finally {
db.close();
}
}
// hasIdentity is a cheap check for the router: does this device hold a wallet?
export async function hasIdentity(): Promise<boolean> {
return (await getIdentity()) !== null;
}
// putIdentity stores (or replaces) the active identity. Used by both join (new)
// and recover (re-derived): both end with an encrypted private key on the device.
export async function putIdentity(
rec: Omit<StoredIdentity, "id">,
): Promise<void> {
const db = await openDB();
try {
await tx(db, "readwrite", (s) => s.put({ id: ACTIVE_ID, ...rec }));
} finally {
db.close();
}
}
// clearIdentity removes the wallet from this device (e.g. "forget this device").
export async function clearIdentity(): Promise<void> {
const db = await openDB();
try {
await tx(db, "readwrite", (s) => s.delete(ACTIVE_ID));
} finally {
db.close();
}
}

Some files were not shown because too many files have changed in this diff Show More