From fb8a03cf0c4f2fce23271f392f864b821eae50ad Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 7 Jun 2026 21:14:08 +0200 Subject: [PATCH 1/5] feat(webgw): web gateway peer (REST + SSE) for the chat SPA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cmd/webgw/gateway.go | 246 ++++++++++++++++++++++++++++++++++ cmd/webgw/hub.go | 140 ++++++++++++++++++++ cmd/webgw/identity.go | 98 ++++++++++++++ cmd/webgw/main.go | 180 +++++++++++++++++++++++++ cmd/webgw/server.go | 301 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 965 insertions(+) create mode 100644 cmd/webgw/gateway.go create mode 100644 cmd/webgw/hub.go create mode 100644 cmd/webgw/identity.go create mode 100644 cmd/webgw/main.go create mode 100644 cmd/webgw/server.go diff --git a/cmd/webgw/gateway.go b/cmd/webgw/gateway.go new file mode 100644 index 00000000..738f761b --- /dev/null +++ b/cmd/webgw/gateway.go @@ -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)) +} diff --git a/cmd/webgw/hub.go b/cmd/webgw/hub.go new file mode 100644 index 00000000..523dea03 --- /dev/null +++ b/cmd/webgw/hub.go @@ -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 +} diff --git a/cmd/webgw/identity.go b/cmd/webgw/identity.go new file mode 100644 index 00000000..41ee3b08 --- /dev/null +++ b/cmd/webgw/identity.go @@ -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 ` 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 ` 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 +} diff --git a/cmd/webgw/main.go b/cmd/webgw/main.go new file mode 100644 index 00000000..c34181dd --- /dev/null +++ b/cmd/webgw/main.go @@ -0,0 +1,180 @@ +// 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 session (dev). Prefer --unlock-pass-entry") + unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the unlock passphrase (used when --unlock-pass is empty)") + 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) + + gw, err := newGateway(gatewayConfig{ + Identity: id, + NatsURL: *natsURL, + CtrlURL: *ctrlURL, + CtrlURLs: splitCSV(*ctrlURLs), + NatsURLs: splitCSV(*natsURLs), + CAPath: *caPath, + }) + if err != nil { + log.Fatalf("%v", err) + } + defer gw.Close() + + 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") + } + + srv := newServer(gw, 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 or --identity-pass ") + } +} + +// 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 +} diff --git a/cmd/webgw/server.go b/cmd/webgw/server.go new file mode 100644 index 00000000..7277b48f --- /dev/null +++ b/cmd/webgw/server.go @@ -0,0 +1,301 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "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 gated by +// a session cookie, plus an optional static file server for the built SPA. The +// gateway's privileged operator identity never leaves the process; the browser +// authenticates with a passphrase and thereafter holds only an opaque session +// token. +type server struct { + gw *gateway + unlock string // passphrase that unlocks a session (compared in constant time) + webDir string // optional path to the built SPA (web/dist); empty = API only + mux *http.ServeMux + + mu sync.Mutex + sessions map[string]time.Time // token -> issued-at +} + +func newServer(gw *gateway, unlock, webDir string) *server { + s := &server{ + gw: gw, + unlock: unlock, + webDir: webDir, + mux: http.NewServeMux(), + sessions: map[string]time.Time{}, + } + 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"}) + }) + + // Auth: login is the only /api route reachable without a session. + s.mux.HandleFunc("POST /api/login", s.handleLogin) + 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()) + } +} + +// ---- auth ----------------------------------------------------------------- + +// auth wraps a handler so it runs only with a valid session cookie. A missing or +// unknown token yields 401, which the SPA treats as "show the login screen". +func (s *server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie(sessionCookie) + if err != nil || !s.validSession(c.Value) { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return + } + next(w, r) + } +} + +func (s *server) validSession(token string) bool { + if token == "" { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.sessions[token] + return ok +} + +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 (main refuses to + // start without one, so this is defense in depth). + if s.unlock == "" || subtle.ConstantTimeCompare([]byte(req.Passphrase), []byte(s.unlock)) != 1 { + writeErr(w, http.StatusUnauthorized, "wrong passphrase") + return + } + tok := newToken() + s.mu.Lock() + s.sessions[tok] = time.Now() + s.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: tok, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + writeJSON(w, http.StatusOK, s.gw.me()) +} + +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + s.mu.Lock() + delete(s.sessions, c.Value) + s.mu.Unlock() + } + 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) { + writeJSON(w, http.StatusOK, s.gw.me()) +} + +// ---- rooms ---------------------------------------------------------------- + +func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request) { + rooms, err := s.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) { + var req createRoomReq + if !decode(w, r, &req) { + return + } + rv, err := s.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) { + if err := s.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) { + var req sendReq + if !decode(w, r, &req) { + return + } + if strings.TrimSpace(req.Body) == "" { + writeErr(w, http.StatusBadRequest, "body required") + return + } + if err := s.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 room'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) { + flusher, ok := w.(http.Flusher) + if !ok { + writeErr(w, http.StatusInternalServerError, "streaming unsupported") + return + } + ch, cleanup, err := s.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() +} From 5ea8fa1c2099dbff35058e0d18fee012489b9c43 Mon Sep 17 00:00:00 2001 From: agent Date: Sun, 7 Jun 2026 21:14:19 +0200 Subject: [PATCH 2/5] 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) --- web/src/App.tsx | 37 +++++++++++- web/src/ChatPanel.tsx | 61 ++++++++++++-------- web/src/ChatShell.tsx | 63 +++++++++++++++++--- web/src/Login.tsx | 35 +++++++++-- web/src/api.ts | 131 ++++++++++++++++++++++++++++++++++++++++++ web/src/types.ts | 36 +++++++++++- web/vite.config.ts | 9 ++- 7 files changed, 331 insertions(+), 41 deletions(-) create mode 100644 web/src/api.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 48066ebd..996d6e9d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,11 +1,44 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; +import { Center, Loader } from "@mantine/core"; import { Login } from "./Login"; import { ChatShell } from "./ChatShell"; +import { api } from "./api"; import type { User } from "./types"; +// shortEndpoint hace legible el endpoint id del operador para mostrarlo como +// handle por defecto cuando no se escribió uno en el login. +function shortEndpoint(ep: string) { + return ep.slice(0, 8); +} + export function App() { const [user, setUser] = useState(null); + const [checking, setChecking] = useState(true); + // Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la + // hay, entra directo; si no (401), muestra el login. + useEffect(() => { + api + .me() + .then((me) => + setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }), + ) + .catch(() => {}) + .finally(() => setChecking(false)); + }, []); + + const logout = () => { + void api.logout().catch(() => {}); + setUser(null); + }; + + if (checking) { + return ( +
+ +
+ ); + } if (!user) return ; - return setUser(null)} />; + return ; } diff --git a/web/src/ChatPanel.tsx b/web/src/ChatPanel.tsx index 99ed24a6..8e669b5d 100644 --- a/web/src/ChatPanel.tsx +++ b/web/src/ChatPanel.tsx @@ -19,7 +19,8 @@ import { IconDotsVertical, IconPaperclip, } from "@tabler/icons-react"; -import type { Message, Room, User } from "./types"; +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() || "?"; @@ -54,22 +55,30 @@ function MessageRow({ msg }: { msg: Message }) { ); } -export function ChatPanel({ - room, - user, -}: { - room: Room | undefined; - user: User; -}) { +export function ChatPanel({ room }: { room: Room | undefined }) { const [draft, setDraft] = useState(""); - const [extra, setExtra] = useState>({}); + const [messages, setMessages] = useState([]); + const [sendError, setSendError] = useState(null); const viewport = useRef(null); - const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : []; + // 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, msgs.length]); + }, [room?.id, messages.length]); if (!room) { return ( @@ -79,18 +88,19 @@ export function ChatPanel({ ); } - const send = () => { + const send = async () => { const body = draft.trim(); if (!body) return; - const msg: Message = { - id: `local-${Date.now()}`, - sender: user.handle, - body, - ts: Date.now(), - mine: true, - }; - setExtra((e) => ({ ...e, [room.id]: [...(e[room.id] ?? []), msg] })); 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 ( @@ -126,13 +136,18 @@ export function ChatPanel({ - {msgs.map((m) => ( + {messages.map((m) => ( ))} + {sendError && ( + + {sendError} + + )} @@ -143,14 +158,14 @@ export function ChatPanel({ placeholder={`Mensaje a ${room.name}`} value={draft} onChange={(e) => setDraft(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && send()} + onKeyDown={(e) => e.key === "Enter" && void send()} /> void send()} disabled={!draft.trim()} > diff --git a/web/src/ChatShell.tsx b/web/src/ChatShell.tsx index fc6ecd4a..550c6527 100644 --- a/web/src/ChatShell.tsx +++ b/web/src/ChatShell.tsx @@ -1,9 +1,9 @@ -import { useState } from "react"; -import { Flex, Box } from "@mantine/core"; +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 { MOCK_ROOMS } from "./mock"; -import type { User } from "./types"; +import { api } from "./api"; +import type { Room, User } from "./types"; export function ChatShell({ user, @@ -12,10 +12,59 @@ export function ChatShell({ user: User; onLogout: () => void; }) { - const [rooms] = useState(MOCK_ROOMS); - const [activeId, setActiveId] = useState(rooms[0]?.id ?? ""); + const [rooms, setRooms] = useState([]); + const [activeId, setActiveId] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = ; + if (loading && rooms.length === 0) { + panel = ( +
+ +
+ ); + } else if (error) { + panel = ( +
+ + + {error} + + + +
+ ); + } else if (rooms.length === 0) { + panel = ( +
+ No perteneces a ninguna room todavía +
+ ); + } + return ( - + {panel} ); diff --git a/web/src/Login.tsx b/web/src/Login.tsx index 30081d2a..731bfe6f 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -11,15 +11,29 @@ import { 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(null); const ready = handle.trim().length > 0 && password.length > 0; - const connect = () => { - const h = handle.trim(); - if (ready) onLogin({ id: h, handle: h }); + 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 ( @@ -52,9 +66,20 @@ export function Login({ onLogin }: { onLogin: (u: User) => void }) { leftSection={} value={password} onChange={(e) => setPassword(e.currentTarget.value)} - onKeyDown={(e) => e.key === "Enter" && connect()} + onKeyDown={(e) => e.key === "Enter" && void connect()} /> - diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 00000000..c8b6633c --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,131 @@ +// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go +// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del +// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma, +// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de +// sesión opaca (HttpOnly) que el gateway emite tras el login. +import type { MeInfo, Message, MsgWire, Room, RoomWire } from "./types"; + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +async function req(path: string, init?: RequestInit): Promise { + 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 = { + // ---- sesión ------------------------------------------------------------- + // login desbloquea la sesión del gateway con la passphrase del operador. El + // gateway responde con una cookie de sesión; me() comprueba si ya hay una. + login: (passphrase: string) => + req("/api/login", { + method: "POST", + body: JSON.stringify({ passphrase }), + }), + logout: () => req<{ status: string }>("/api/logout", { method: "POST" }), + me: () => req("/api/me"), + + // ---- rooms -------------------------------------------------------------- + listRooms: async (): Promise => { + const wire = await req("/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 => { + const r = await req("/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(); +} diff --git a/web/src/types.ts b/web/src/types.ts index eaacc026..9b4732d7 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -1,5 +1,5 @@ -// Tipos de dominio de la UI. En la iteración 1 se llenan con datos mock; -// más adelante vendrán del gateway (REST/SSE) que es un peer del bus. +// Tipos de dominio de la UI. Los datos vienen del gateway Go (REST/SSE), que es +// un peer autenticado del bus. El navegador nunca firma ni habla NATS. export interface User { id: string; @@ -8,7 +8,7 @@ export interface User { export interface Message { id: string; - sender: string; // handle + sender: string; // endpoint id del remitente (handle legible es fase 2) body: string; ts: number; // epoch ms mine?: boolean; @@ -23,3 +23,33 @@ export interface Room { unread: number; messages: Message[]; } + +// ---- formas de la API del gateway (wire) --------------------------------- + +// MeInfo es la identidad del operador que el gateway encarna (GET /api/me). +export interface MeInfo { + endpoint: string; + sign_pub: string; +} + +// RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae +// mensajes: estos llegan por SSE (GET /api/rooms/{id}/stream). +export interface RoomWire { + id: string; + subject: string; + name: string; + epoch: number; + encrypt: boolean; + persist: boolean; + sign_msgs: boolean; + role: string; +} + +// MsgWire es un mensaje ya descifrado que el gateway empuja por SSE. +export interface MsgWire { + id: string; + sender: string; + body: string; + ts: number; + mine: boolean; +} diff --git a/web/vite.config.ts b/web/vite.config.ts index ca4ab1f2..3ccfd18a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,5 +3,12 @@ import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], - server: { host: true, port: 5181 }, + // En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481). + // El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a + // través de él. En producción el gateway sirve el dist embebido y no hay proxy. + server: { + host: true, + port: 5181, + proxy: { "/api": "http://127.0.0.1:8481" }, + }, }); From 7d93d550d146b59aebe6f266ad454b3760f28cfb Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 8 Jun 2026 21:21:33 +0200 Subject: [PATCH 3/5] 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) --- .gitignore | 4 + cmd/webgw/main.go | 29 ++++-- cmd/webgw/register.go | 193 ++++++++++++++++++++++++++++++++++++++++ cmd/webgw/server.go | 146 +++++++++++++++++------------- cmd/webgw/session.go | 146 ++++++++++++++++++++++++++++++ cmd/webgw/webgw_test.go | 114 ++++++++++++++++++++++++ 6 files changed, 567 insertions(+), 65 deletions(-) create mode 100644 cmd/webgw/register.go create mode 100644 cmd/webgw/session.go create mode 100644 cmd/webgw/webgw_test.go diff --git a/.gitignore b/.gitignore index ddb3435c..be13c3b0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,9 @@ worker.id /membershipd /worker /chat +/webgw *.exe registry.db + +# Local session infra (machine-specific absolute paths; never distributed). +.mcp.json diff --git a/cmd/webgw/main.go b/cmd/webgw/main.go index c34181dd..ae94e234 100644 --- a/cmd/webgw/main.go +++ b/cmd/webgw/main.go @@ -50,8 +50,10 @@ func main() { 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 session (dev). Prefer --unlock-pass-entry") - unlockEntry = flag.String("unlock-pass-entry", "unibus/admin-panel-password", "pass(1) entry holding the unlock passphrase (used when --unlock-pass is empty)") + 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 (/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() @@ -77,19 +79,34 @@ func main() { resolvedWebDir := resolveWebDir(*webDir) - gw, err := newGateway(gatewayConfig{ + // 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)" @@ -103,7 +120,9 @@ func main() { log.Printf("API only (no --web-dir): use the vite dev server with a /api+stream proxy") } - srv := newServer(gw, unlock, resolvedWebDir) + 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, diff --git a/cmd/webgw/register.go b/cmd/webgw/register.go new file mode 100644 index 00000000..ac45a5e9 --- /dev/null +++ b/cmd/webgw/register.go @@ -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 := ®istrar{ + 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) +} diff --git a/cmd/webgw/server.go b/cmd/webgw/server.go index 7277b48f..2eb0d5a0 100644 --- a/cmd/webgw/server.go +++ b/cmd/webgw/server.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "strings" - "sync" "time" ) @@ -19,28 +18,37 @@ import ( // 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 gated by -// a session cookie, plus an optional static file server for the built SPA. The -// gateway's privileged operator identity never leaves the process; the browser -// authenticates with a passphrase and thereafter holds only an opaque session -// token. +// 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 { - gw *gateway - unlock string // passphrase that unlocks a session (compared in constant time) - webDir string // optional path to the built SPA (web/dist); empty = API only - mux *http.ServeMux - - mu sync.Mutex - sessions map[string]time.Time // token -> issued-at + 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(gw *gateway, unlock, webDir string) *server { +func newServer(operatorGW *gateway, busTemplate gatewayConfig, registrar *registrar, unlock, webDir string) *server { s := &server{ - gw: gw, - unlock: unlock, - webDir: webDir, - mux: http.NewServeMux(), - sessions: map[string]time.Time{}, + operatorGW: operatorGW, + busTemplate: busTemplate, + registrar: registrar, + unlock: unlock, + webDir: webDir, + mux: http.NewServeMux(), + sessions: newSessionStore(), } s.routes() return s @@ -54,11 +62,14 @@ func (s *server) routes() { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) - // Auth: login is the only /api route reachable without a session. - s.mux.HandleFunc("POST /api/login", s.handleLogin) + // 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)) @@ -71,31 +82,39 @@ func (s *server) routes() { } } +// 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. A missing or -// unknown token yields 401, which the SPA treats as "show the login screen". -func (s *server) auth(next http.HandlerFunc) http.HandlerFunc { +// 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 || !s.validSession(c.Value) { + if err != nil { writeErr(w, http.StatusUnauthorized, "not authenticated") return } - next(w, r) + sess, ok := s.sessions.get(c.Value) + if !ok { + writeErr(w, http.StatusUnauthorized, "not authenticated") + return + } + next(w, r, sess) } } -func (s *server) validSession(token string) bool { - if token == "" { - return false - } - s.mu.Lock() - defer s.mu.Unlock() - _, ok := s.sessions[token] - return ok -} - +// 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"` @@ -104,16 +123,17 @@ func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { return } // Constant-time compare so a wrong passphrase cannot be timed character by - // character. An empty configured passphrase never matches (main refuses to - // start without one, so this is defense in depth). + // 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() - s.mu.Lock() - s.sessions[tok] = time.Now() - s.mu.Unlock() + 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, @@ -122,27 +142,33 @@ func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) { HttpOnly: true, SameSite: http.SameSiteLaxMode, }) - writeJSON(w, http.StatusOK, s.gw.me()) + 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) { +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request, _ *session) { if c, err := r.Cookie(sessionCookie); err == nil { - s.mu.Lock() - delete(s.sessions, c.Value) - s.mu.Unlock() + 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) { - writeJSON(w, http.StatusOK, s.gw.me()) +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) { - rooms, err := s.gw.listRooms() +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 @@ -150,12 +176,12 @@ func (s *server) handleListRooms(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, rooms) } -func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { +func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request, sess *session) { var req createRoomReq if !decode(w, r, &req) { return } - rv, err := s.gw.createRoom(req) + rv, err := sess.gw.createRoom(req) if err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return @@ -163,15 +189,15 @@ func (s *server) handleCreateRoom(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, rv) } -func (s *server) handleJoin(w http.ResponseWriter, r *http.Request) { - if err := s.gw.join(r.PathValue("id")); err != nil { +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) { +func (s *server) handleSend(w http.ResponseWriter, r *http.Request, sess *session) { var req sendReq if !decode(w, r, &req) { return @@ -180,25 +206,25 @@ func (s *server) handleSend(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusBadRequest, "body required") return } - if err := s.gw.send(r.PathValue("id"), req.Body); err != nil { + 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 room's +// 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) { +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 := s.gw.openStream(r.PathValue("id")) + ch, cleanup, err := sess.gw.openStream(r.PathValue("id")) if err != nil { writeErr(w, http.StatusBadGateway, err.Error()) return diff --git a/cmd/webgw/session.go b/cmd/webgw/session.go new file mode 100644 index 00000000..2583d2bb --- /dev/null +++ b/cmd/webgw/session.go @@ -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}) +} diff --git a/cmd/webgw/webgw_test.go b/cmd/webgw/webgw_test.go new file mode 100644 index 00000000..ca3df95f --- /dev/null +++ b/cmd/webgw/webgw_test.go @@ -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) + } +} From 4994ea1483f9cafd2c2d4d93aa159af5a59e9ac3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 8 Jun 2026 21:21:50 +0200 Subject: [PATCH 4/5] 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) --- web/package.json | 3 + web/pnpm-lock.yaml | 36 +++++ web/src/App.tsx | 141 ++++++++++++++--- web/src/AuthShell.tsx | 47 ++++++ web/src/Join.tsx | 322 ++++++++++++++++++++++++++++++++++++++ web/src/Recover.tsx | 175 +++++++++++++++++++++ web/src/WalletLogin.tsx | 77 +++++++++ web/src/Welcome.tsx | 70 +++++++++ web/src/api.ts | 44 +++++- web/src/types.ts | 12 +- web/src/wallet/account.ts | 60 +++++++ web/src/wallet/bip39.ts | 55 +++++++ web/src/wallet/crypto.ts | 124 +++++++++++++++ web/src/wallet/derive.ts | 69 ++++++++ web/src/wallet/store.ts | 95 +++++++++++ web/vite.config.ts | 2 +- 16 files changed, 1303 insertions(+), 29 deletions(-) create mode 100644 web/src/AuthShell.tsx create mode 100644 web/src/Join.tsx create mode 100644 web/src/Recover.tsx create mode 100644 web/src/WalletLogin.tsx create mode 100644 web/src/Welcome.tsx create mode 100644 web/src/wallet/account.ts create mode 100644 web/src/wallet/bip39.ts create mode 100644 web/src/wallet/crypto.ts create mode 100644 web/src/wallet/derive.ts create mode 100644 web/src/wallet/store.ts diff --git a/web/package.json b/web/package.json index 552fb9f4..79b69781 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,9 @@ "dependencies": { "@mantine/core": "^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", "react": "^19.2.0", "react-dom": "^19.2.0" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 7fa163af..0685fcf9 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -14,6 +14,15 @@ importers: '@mantine/hooks': specifier: ^9.3.0 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': specifier: ^3.36.0 version: 3.44.0(react@19.2.7) @@ -339,6 +348,14 @@ packages: peerDependencies: 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': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -480,6 +497,12 @@ packages: cpu: [x64] 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': resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} peerDependencies: @@ -1086,6 +1109,12 @@ snapshots: dependencies: 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': {} '@rollup/rollup-android-arm-eabi@4.61.1': @@ -1163,6 +1192,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.1': 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)': dependencies: '@tabler/icons': 3.44.0 diff --git a/web/src/App.tsx b/web/src/App.tsx index 996d6e9d..5c8e7996 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,44 +1,139 @@ import { useEffect, useState } from "react"; import { Center, Loader } from "@mantine/core"; -import { Login } from "./Login"; import { ChatShell } from "./ChatShell"; +import { Join } from "./Join"; +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"; -// shortEndpoint hace legible el endpoint id del operador para mostrarlo como -// handle por defecto cuando no se escribió uno en el login. -function shortEndpoint(ep: string) { - return ep.slice(0, 8); +type Route = "loading" | "join" | "welcome" | "login" | "recover" | "chat"; + +// readJoinToken returns the invite token if the current URL is /join?token=XXX. +function readJoinToken(): string | null { + if (window.location.pathname !== "/join") return null; + return new URLSearchParams(window.location.search).get("token"); +} + +// clearUrl drops any /join?token from the address bar once consumed, so a refresh +// or a shared screenshot does not replay the (single-use) token. +function clearUrl() { + if (window.location.pathname !== "/") { + window.history.replaceState(null, "", "/"); + } } export function App() { + const [route, setRoute] = useState("loading"); const [user, setUser] = useState(null); - const [checking, setChecking] = useState(true); + const [token, setToken] = useState(""); + const [storedHandle, setStoredHandle] = useState(""); - // Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la - // hay, entra directo; si no (401), muestra el login. + // 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(() => { - api - .me() - .then((me) => - setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }), - ) - .catch(() => {}) - .finally(() => setChecking(false)); + 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"); + } + }); }; - if (checking) { - return ( -
- -
- ); + switch (route) { + case "loading": + return ( +
+ +
+ ); + case "join": + return ( + setRoute("recover")} + /> + ); + case "welcome": + return ( + { + setToken(t); + setRoute("join"); + }} + onRecover={() => setRoute("recover")} + /> + ); + case "login": + return ( + setRoute("recover")} + /> + ); + case "recover": + return ( + setRoute(storedHandle ? "login" : "welcome")} + /> + ); + case "chat": + return user ? ( + + ) : ( +
+ +
+ ); } - if (!user) return ; - return ; } diff --git a/web/src/AuthShell.tsx b/web/src/AuthShell.tsx new file mode 100644 index 00000000..6f772fb5 --- /dev/null +++ b/web/src/AuthShell.tsx @@ -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 ( +
+ + {children} + +
+ ); +} + +// 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 ( + + + {icon} + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + ); +} diff --git a/web/src/Join.tsx b/web/src/Join.tsx new file mode 100644 index 00000000..cab85eda --- /dev/null +++ b/web/src/Join.tsx @@ -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("generating"); + const [mnemonic, setMnemonic] = useState(""); + const [identity, setIdentity] = useState(null); + const [error, setError] = useState(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 ( + + } title="Error"> + {error} + + + + ); + } + + if (step === "generating" || !identity) { + return ( +
+ +
+ ); + } + + if (step === "show-seed") { + return ( + setStep("confirm-seed")} /> + ); + } + + if (step === "confirm-seed") { + return ( + setStep("show-seed")} + onConfirmed={() => setStep("password")} + /> + ); + } + + // step === "password" | "joining" + return ( + { + 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 ( + + } + 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." + /> + + + {words.map((w, i) => ( + + + {i + 1} + + + {w} + + + ))} + + + + + {({ copied, copy }) => ( + + )} + + + }> + unibus NO guarda esta frase. Si la pierdes y olvidas la contraseña, solo + el administrador podrá darte de alta de nuevo. + + setAcknowledged(e.currentTarget.checked)} + label="He guardado mi frase de recuperación en un lugar seguro" + /> + + + ); +} + +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>({}); + const allCorrect = positions.every( + (p) => (inputs[p] ?? "").trim().toLowerCase() === words[p], + ); + const anyTyped = positions.some((p) => (inputs[p] ?? "").length > 0); + return ( + + } + title="Confirma tu frase" + subtitle="Escribe las palabras solicitadas para confirmar que la guardaste bien." + /> + + {positions.map((p) => ( + 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} + /> + ))} + + {!allCorrect && anyTyped && ( + + Revisa el orden y la ortografía de las palabras. + + )} + + + + + + ); +} + +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 ( + + } + 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." + /> + } + value={pw} + error={tooShort ? "Demasiado corta" : undefined} + onChange={(e) => setPw(e.currentTarget.value)} + data-autofocus + /> + } + value={pw2} + error={mismatch ? "No coincide" : undefined} + onChange={(e) => setPw2(e.currentTarget.value)} + onKeyDown={(e) => e.key === "Enter" && ready && onSubmit(pw)} + /> + {error && ( + + {error} + + )} + + + ); +} diff --git a/web/src/Recover.tsx b/web/src/Recover.tsx new file mode 100644 index 00000000..991de7d5 --- /dev/null +++ b/web/src/Recover.tsx @@ -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("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(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 ( + + } + title="Recuperar con tu frase" + subtitle="Introduce tus 12 palabras de recuperación. Se quedan en este navegador: nunca se envían al servidor." + /> +