feat: initial scaffold of unibus message bus (membership service + client lib + demo peers)
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
)
|
||||
|
||||
// Server is the HTTP control plane: the authoritative source of room metadata,
|
||||
// membership, and per-epoch sealed keys. The data plane (messages) is NATS.
|
||||
//
|
||||
// Auth model (v1): mutating endpoints require an Ed25519 signature from the
|
||||
// room owner over the canonical bytes of the request (the request body with the
|
||||
// "sig" field cleared). v1 trusts the internal network: there is no TLS, no
|
||||
// rate limiting, and read endpoints (GET) are unauthenticated. Hardening
|
||||
// (mTLS, capabilities, rate limits) is a later phase.
|
||||
type Server struct {
|
||||
store *Store
|
||||
blobs *blobstore.Store
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer wires the membership store and blob store into an http.Handler.
|
||||
func NewServer(store *Store, blobs *blobstore.Store) *Server {
|
||||
s := &Server{store: store, blobs: blobs, mux: http.NewServeMux()}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// ServeHTTP satisfies http.Handler.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("GET /healthz", s.handleHealth)
|
||||
s.mux.HandleFunc("POST /rooms", s.handleCreateRoom)
|
||||
s.mux.HandleFunc("POST /rooms/{id}/invite", s.handleInvite)
|
||||
s.mux.HandleFunc("GET /rooms/{id}/key", s.handleGetKey)
|
||||
s.mux.HandleFunc("GET /rooms/{id}/members", s.handleListMembers)
|
||||
s.mux.HandleFunc("POST /rooms/{id}/rekey", s.handleRekey)
|
||||
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
|
||||
s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
|
||||
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
|
||||
}
|
||||
|
||||
// ---- wire types -----------------------------------------------------------
|
||||
|
||||
type policyJSON struct {
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Persist bool `json:"persist"`
|
||||
SignMsgs bool `json:"sign_msgs"`
|
||||
}
|
||||
|
||||
type endpointJSON struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SignPub []byte `json:"sign_pub"`
|
||||
KexPub []byte `json:"kex_pub"`
|
||||
}
|
||||
|
||||
type createRoomReq struct {
|
||||
Subject string `json:"subject"`
|
||||
Policy policyJSON `json:"policy"`
|
||||
Owner endpointJSON `json:"owner"`
|
||||
SealedKeySelf []byte `json:"sealed_key_self"`
|
||||
}
|
||||
|
||||
type createRoomResp struct {
|
||||
RoomID string `json:"room_id"`
|
||||
}
|
||||
|
||||
type inviteReq struct {
|
||||
By string `json:"by"` // owner endpoint id
|
||||
Sig []byte `json:"sig"` // Ed25519 over canonical(request with sig cleared)
|
||||
Member endpointJSON `json:"member"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type keyResp struct {
|
||||
Epoch int `json:"epoch"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type memberJSON struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Role string `json:"role"`
|
||||
SignPub []byte `json:"sign_pub"`
|
||||
KexPub []byte `json:"kex_pub"`
|
||||
}
|
||||
|
||||
type roomResp struct {
|
||||
Subject string `json:"subject"`
|
||||
Epoch int `json:"epoch"`
|
||||
Policy policyJSON `json:"policy"`
|
||||
}
|
||||
|
||||
type rekeyKey struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type rekeyReq struct {
|
||||
By string `json:"by"`
|
||||
Sig []byte `json:"sig"`
|
||||
NewEpoch int `json:"new_epoch"`
|
||||
Keys []rekeyKey `json:"keys"`
|
||||
Remove []string `json:"remove"`
|
||||
}
|
||||
|
||||
type blobResp struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// ---- helpers --------------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// canonicalSig returns the bytes to verify for a request: the request struct
|
||||
// re-marshaled with its Sig field cleared. The caller passes a copy with Sig
|
||||
// already zeroed. This is symmetric with how the client signs.
|
||||
func canonicalSig(v any) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// verifyOwnerSig checks that sig is a valid Ed25519 signature by the room owner
|
||||
// over canonical(reqWithSigCleared). It returns the owner Member on success.
|
||||
func (s *Server) verifyOwnerSig(roomID, by string, sig, canonical []byte) (Member, error) {
|
||||
info, err := s.store.GetRoom(roomID)
|
||||
if err != nil {
|
||||
return Member{}, fmt.Errorf("room not found")
|
||||
}
|
||||
if by != info.OwnerEndpoint {
|
||||
return Member{}, fmt.Errorf("requester %q is not the room owner", by)
|
||||
}
|
||||
owner, err := s.store.GetMember(roomID, by)
|
||||
if err != nil {
|
||||
return Member{}, fmt.Errorf("owner member not found")
|
||||
}
|
||||
if !cs.VerifyEd25519(owner.SignPub, canonical, sig) {
|
||||
return Member{}, fmt.Errorf("invalid owner signature")
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||
var req createRoomReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Subject == "" || req.Owner.Endpoint == "" {
|
||||
writeErr(w, http.StatusBadRequest, "subject and owner.endpoint required")
|
||||
return
|
||||
}
|
||||
roomID := newULID()
|
||||
info := RoomInfo{
|
||||
RoomID: roomID,
|
||||
Subject: req.Subject,
|
||||
Encrypt: req.Policy.Encrypt,
|
||||
Persist: req.Policy.Persist,
|
||||
SignMsgs: req.Policy.SignMsgs,
|
||||
OwnerEndpoint: req.Owner.Endpoint,
|
||||
}
|
||||
if err := s.store.CreateRoom(info, req.Owner.SignPub, req.Owner.KexPub, req.SealedKeySelf); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, createRoomResp{RoomID: roomID})
|
||||
}
|
||||
|
||||
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.PathValue("id")
|
||||
var req inviteReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Canonical bytes = the request with Sig cleared.
|
||||
sig := req.Sig
|
||||
req.Sig = nil
|
||||
if _, err := s.verifyOwnerSig(roomID, req.By, sig, canonicalSig(req)); err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
info, err := s.store.GetRoom(roomID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
m := Member{
|
||||
Endpoint: req.Member.Endpoint,
|
||||
Role: "member",
|
||||
SignPub: req.Member.SignPub,
|
||||
KexPub: req.Member.KexPub,
|
||||
}
|
||||
if err := s.store.AddMember(roomID, m, info.Epoch, req.SealedKey); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "invited"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetKey(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.PathValue("id")
|
||||
endpoint := r.URL.Query().Get("endpoint")
|
||||
if endpoint == "" {
|
||||
writeErr(w, http.StatusBadRequest, "endpoint query param required")
|
||||
return
|
||||
}
|
||||
epoch := 0
|
||||
if e := r.URL.Query().Get("epoch"); e != "" {
|
||||
if n, err := strconv.Atoi(e); err == nil {
|
||||
epoch = n
|
||||
}
|
||||
}
|
||||
ep, sealed, err := s.store.GetSealedKey(roomID, endpoint, epoch)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, keyResp{Epoch: ep, SealedKey: sealed})
|
||||
}
|
||||
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.PathValue("id")
|
||||
members, err := s.store.ListMembers(roomID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
out := make([]memberJSON, 0, len(members))
|
||||
for _, m := range members {
|
||||
out = append(out, memberJSON{Endpoint: m.Endpoint, Role: m.Role, SignPub: m.SignPub, KexPub: m.KexPub})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.PathValue("id")
|
||||
info, err := s.store.GetRoom(roomID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, roomResp{
|
||||
Subject: info.Subject,
|
||||
Epoch: info.Epoch,
|
||||
Policy: policyJSON{Encrypt: info.Encrypt, Persist: info.Persist, SignMsgs: info.SignMsgs},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleRekey(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.PathValue("id")
|
||||
var req rekeyReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
return
|
||||
}
|
||||
sig := req.Sig
|
||||
req.Sig = nil
|
||||
if _, err := s.verifyOwnerSig(roomID, req.By, sig, canonicalSig(req)); err != nil {
|
||||
writeErr(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
if req.NewEpoch <= 0 {
|
||||
writeErr(w, http.StatusBadRequest, "new_epoch must be > 0")
|
||||
return
|
||||
}
|
||||
// Bump epoch, then store the fresh sealed keys for the remaining members,
|
||||
// then remove the kicked/left members.
|
||||
if err := s.store.BumpEpoch(roomID, req.NewEpoch); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
keys := make(map[string][]byte, len(req.Keys))
|
||||
for _, k := range req.Keys {
|
||||
keys[k.Endpoint] = k.SealedKey
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
if err := s.store.PutSealedKeys(roomID, req.NewEpoch, keys); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ep := range req.Remove {
|
||||
if err := s.store.RemoveMember(roomID, ep); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "rekeyed", "epoch": req.NewEpoch})
|
||||
}
|
||||
|
||||
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
|
||||
return
|
||||
}
|
||||
hash, err := s.blobs.Put(data)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, blobResp{Hash: hash})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if strings.ContainsAny(hash, "/\\.") {
|
||||
writeErr(w, http.StatusBadRequest, "invalid hash")
|
||||
return
|
||||
}
|
||||
data, err := s.blobs.Get(hash)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
Reference in New Issue
Block a user