feat(webgw): per-user wallet sessions + invite register

Add the gateway backend for the wallet onboarding flow so each browser
session carries its OWN bus identity instead of sharing the single
operator client.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:21:33 +02:00
parent 5ea8fa1c20
commit 7d93d550d1
6 changed files with 567 additions and 65 deletions
+86 -60
View File
@@ -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