b1d1f64c16
- membership server returns 403 + human-readable message on missing sealed key (was leaking 'sql: no rows in result set')
- client doJSON unwraps the server's {"error"} field instead of pasting the raw HTTP envelope
350 lines
10 KiB
Go
350 lines
10 KiB
Go
package membership
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"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 {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
writeErr(w, http.StatusForbidden,
|
|
"not invited to this encrypted room: no key has been sealed for your identity. Ask the room owner to invite you before joining.")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusInternalServerError, 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)
|
|
}
|