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