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) + } +}