7d93d550d1
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>
194 lines
5.9 KiB
Go
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 := ®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)
|
|
}
|