Files
unibus/cmd/webgw/register.go
T
egutierrez 7d93d550d1 feat(webgw): per-user wallet sessions + invite register
Add the gateway backend for the wallet onboarding flow so each browser
session carries its OWN bus identity instead of sharing the single
operator client.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:33 +02:00

194 lines
5.9 KiB
Go

package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
)
// registerReq is the POST /api/register body. It mirrors the bus contract exactly
// (token + the two PUBLIC key halves, each 64 hex chars). The private key never
// appears here — registration only publishes the public identity. The handle and
// role are NOT accepted from the client; they are fixed by the invite the token
// belongs to (no privilege escalation).
type registerReq struct {
Token string `json:"token"`
SignPub string `json:"sign_pub"`
KexPub string `json:"kex_pub"`
}
// registerResp is what we return to the browser on success. The bus's /register
// (issue: user-accounts) decides handle/role from the invite; in mock mode the
// gateway echoes the configured pair so the SPA can greet the new user.
type registerResp struct {
Handle string `json:"handle"`
Role string `json:"role"`
}
// registrar fulfils POST /api/register. It targets the bus's POST /register
// endpoint (added by the user-accounts work, bus >= 0.12.0). Until that endpoint
// is rolled out, a built-in mock validates against a configured set of one-shot
// tokens so the whole wallet flow is testable locally. Mock tokens are checked
// first; anything else is proxied to the real bus when --register-url is set.
type registrar struct {
mu sync.Mutex
registerURL string // bus POST /register; empty => mock-only
httpc *http.Client // for proxying to the bus
mockTokens map[string]*mockToken // configured one-shot invites for local testing
}
// mockToken is a local stand-in for a bus invite: a token that maps to a fixed
// handle+role and can be consumed exactly once.
type mockToken struct {
handle string
role string
used bool
}
// newRegistrar parses the --mock-tokens spec ("tok=handle:role,tok2=h2:role2")
// and configures the optional proxy target.
func newRegistrar(registerURL, mockSpec string) *registrar {
r := &registrar{
registerURL: strings.TrimSpace(registerURL),
httpc: &http.Client{Timeout: 10 * time.Second},
mockTokens: map[string]*mockToken{},
}
for _, part := range strings.Split(mockSpec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
// tok=handle:role (role optional, defaults to member)
eq := strings.IndexByte(part, '=')
if eq < 0 {
continue
}
tok := strings.TrimSpace(part[:eq])
hr := strings.TrimSpace(part[eq+1:])
handle, role := hr, "member"
if c := strings.IndexByte(hr, ':'); c >= 0 {
handle, role = strings.TrimSpace(hr[:c]), strings.TrimSpace(hr[c+1:])
}
if tok != "" && handle != "" {
r.mockTokens[tok] = &mockToken{handle: handle, role: role}
}
}
return r
}
// mockTokenCount counts configured mock tokens in a --mock-tokens spec (for the
// startup log line).
func mockTokenCount(spec string) int {
n := 0
for _, part := range strings.Split(spec, ",") {
if p := strings.TrimSpace(part); p != "" && strings.ContainsRune(p, '=') {
n++
}
}
return n
}
// validHexKey reports whether s is exactly 64 lowercase/uppercase hex chars (a
// 32-byte key). Both sign_pub and kex_pub are 32-byte keys.
func validHexKey(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// handleRegister validates the keys and consumes the token. Order of resolution:
// 1. strict validation of the public keys (defends both mock and proxy paths);
// 2. mock token (one-shot) if configured;
// 3. proxy to the bus /register if --register-url is set;
// 4. otherwise reject with a clear error.
func (s *server) handleRegister(w http.ResponseWriter, r *http.Request) {
var req registerReq
if !decode(w, r, &req) {
return
}
req.Token = strings.TrimSpace(req.Token)
if req.Token == "" {
writeErr(w, http.StatusBadRequest, "token required")
return
}
if !validHexKey(req.SignPub) {
writeErr(w, http.StatusBadRequest, "sign_pub must be 64 hex chars (32 bytes)")
return
}
if !validHexKey(req.KexPub) {
writeErr(w, http.StatusBadRequest, "kex_pub must be 64 hex chars (32 bytes)")
return
}
reg := s.registrar
// 2) mock one-shot token.
reg.mu.Lock()
mt, isMock := reg.mockTokens[req.Token]
if isMock {
if mt.used {
reg.mu.Unlock()
writeErr(w, http.StatusConflict, "invite already used")
return
}
mt.used = true
handle, role := mt.handle, mt.role
reg.mu.Unlock()
writeJSON(w, http.StatusCreated, registerResp{Handle: handle, Role: role})
return
}
reg.mu.Unlock()
// 3) proxy to the real bus /register when configured.
if reg.registerURL != "" {
s.proxyRegister(w, req)
return
}
// 4) no mock match, no proxy target.
writeErr(w, http.StatusBadRequest, "invalid or unknown token (and no bus /register configured)")
}
// proxyRegister forwards the registration to the bus's POST /register. The bus
// validates the invite (existence, not-used, not-expired) and adds the public
// identity to the allowlist with the invite's handle+role. This is unsigned by
// design: the TOKEN authorizes the call, not an admin signature.
func (s *server) proxyRegister(w http.ResponseWriter, req registerReq) {
body, _ := json.Marshal(req)
resp, err := s.registrar.httpc.Post(
s.registrar.registerURL,
"application/json",
bytes.NewReader(body),
)
if err != nil {
writeErr(w, http.StatusBadGateway, "bus register unreachable: "+err.Error())
return
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
// On success, try to pass through the bus's handle/role if it returned them;
// otherwise a bare 201 is still success.
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusOK {
var rr registerResp
_ = json.Unmarshal(raw, &rr)
writeJSON(w, http.StatusCreated, rr)
return
}
// Forward the bus's error verbatim where possible.
msg := strings.TrimSpace(string(raw))
if msg == "" {
msg = fmt.Sprintf("bus register failed (HTTP %d)", resp.StatusCode)
}
writeErr(w, resp.StatusCode, msg)
}