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