e8e37d77fe
Extracted from unibus v0.13.0: the chat SPA (web/, React+Mantine, per-user BIP39 wallet) and the web gateway (cmd/webgw, REST+SSE) that acts as a bus peer for the browser. Consumes unibus as a Go module via replace => ../unibus, keeping its own replace fn-registry for the cybersecurity primitives. go build/vet/test and pnpm build green in the new location.
147 lines
4.7 KiB
Go
147 lines
4.7 KiB
Go
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})
|
|
}
|