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:
@@ -12,5 +12,9 @@ worker.id
|
||||
/membershipd
|
||||
/worker
|
||||
/chat
|
||||
/webgw
|
||||
*.exe
|
||||
registry.db
|
||||
|
||||
# Local session infra (machine-specific absolute paths; never distributed).
|
||||
.mcp.json
|
||||
|
||||
+24
-5
@@ -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 (<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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+86
-60
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user