0aa2caae43
Three medium audit findings. H6 (owner spoof): handleCreateRoom now binds the body's declared owner to the authenticated signer — both the endpoint id and the signing key must be the signer's — so a registered peer cannot create rooms in another identity's name. Enforced only when an authenticated signer is present. H7 (nonce-cache poison pre-auth): IsAuthorized now runs BEFORE the replay cache is touched, so an unregistered identity (Ed25519 keys are free) can no longer seed nonces into it. The cache is rewritten with O(expired) pruning (insertion order equals expiry order under a constant TTL) instead of the previous O(n) full-map scan under the mutex, plus a size cap with oldest-eviction. This is the prerequisite the 0003 replicated nonce store builds on. H12 (error leak): internal store/blob errors are logged and replaced with a generic client message via writeServerErr, so SQL fragments and filesystem paths no longer reach the caller. Crafted 4xx messages (owner-sig, validation) are kept. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
616 lines
22 KiB
Go
616 lines
22 KiB
Go
package membership
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"github.com/enmanuel/unibus/pkg/blobstore"
|
|
"github.com/enmanuel/unibus/pkg/frame"
|
|
)
|
|
|
|
// Body-size ceilings for the control plane. They bound how much an unauthenticated
|
|
// peer can make the server buffer in RAM before the request is even authenticated
|
|
// (the signature is verified over the full body, so the body must be read — but
|
|
// not unboundedly). maxControlBodyBytes covers JSON metadata requests; /blobs gets
|
|
// a separate, larger ceiling because media ciphertext is legitimately bigger. A
|
|
// request whose declared Content-Length already exceeds its ceiling is rejected
|
|
// before a single byte is buffered.
|
|
const (
|
|
maxControlBodyBytes = 1 << 20 // 1 MiB for JSON control-plane requests
|
|
maxBlobBytes = 16 << 20 // 16 MiB for a single media blob upload
|
|
// MaxHeaderBytes caps request header size; wired into the http.Server by the
|
|
// command. Exported so the bound lives next to its body-size siblings.
|
|
MaxHeaderBytes = 1 << 20 // 1 MiB
|
|
)
|
|
|
|
// Per-IP rate-limit defaults for the control plane. Tuned for an interactive
|
|
// human/agent bus rather than a high-QPS API: a steady ~20 req/s with a burst of
|
|
// 40 absorbs a chat client's bursty polling while throttling a flood. Loopback
|
|
// dev stacks pass r<=0 to disable limiting entirely.
|
|
const (
|
|
defaultRatePerSec = rate.Limit(20)
|
|
defaultRateBurst = 40
|
|
rateBucketTTL = 10 * time.Minute
|
|
)
|
|
|
|
// 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
|
|
authMode AuthMode
|
|
nonces *nonceCache
|
|
limiter *ipRateLimiter
|
|
|
|
// RequireEncryptedRooms, when true, refuses to create cleartext (ModeNATS)
|
|
// rooms. It is the minimum-defensive control for the data plane (audit H4):
|
|
// the embedded NATS has no per-subject ACL, so a cleartext room is readable by
|
|
// any registered peer that knows (or guesses) its subject. Forcing every room
|
|
// to be end-to-end encrypted keeps message CONTENT confidential even when the
|
|
// transport offers no subject isolation. The command sets this on a public
|
|
// (non-loopback) bind. See dev/0004d-dataplane-acl.md for the full rationale
|
|
// and the residual metadata exposure this does NOT close.
|
|
RequireEncryptedRooms bool
|
|
}
|
|
|
|
// NewServer wires the membership store and blob store into an http.Handler. The
|
|
// authMode selects the control-plane auth rollout state (AuthOff for callers and
|
|
// tests that have not migrated to signed requests yet). It installs a per-IP
|
|
// rate limiter with the package defaults; loopback dev behavior is unchanged
|
|
// because the burst comfortably exceeds any single client's request rate.
|
|
func NewServer(store *Store, blobs *blobstore.Store, authMode AuthMode) *Server {
|
|
s := &Server{
|
|
store: store,
|
|
blobs: blobs,
|
|
mux: http.NewServeMux(),
|
|
authMode: authMode,
|
|
nonces: newNonceCache(nonceTTL, maxNonceCacheEntries),
|
|
limiter: newIPRateLimiter(defaultRatePerSec, defaultRateBurst, rateBucketTTL),
|
|
}
|
|
s.routes()
|
|
return s
|
|
}
|
|
|
|
// ServeHTTP satisfies http.Handler. It runs the control-plane auth middleware
|
|
// (signature verification + anti-replay + allowlist) ahead of the router
|
|
// according to authMode, then dispatches to the matched handler.
|
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
now := time.Now()
|
|
|
|
// Per-IP rate limit runs first, ahead of auth and body reads, so a flood is
|
|
// shed at the cheapest possible point. The health probe is exempt so liveness
|
|
// checks are never throttled.
|
|
if !isAuthExempt(r) && !s.limiter.allow(clientIP(r), now) {
|
|
writeErr(w, http.StatusTooManyRequests, "rate limit exceeded")
|
|
return
|
|
}
|
|
|
|
// Cap how much body we will buffer, BEFORE reading a single byte. The ceiling
|
|
// is per-route: /blobs may legitimately carry a media ciphertext, everything
|
|
// else is small JSON. A declared Content-Length over the ceiling is rejected
|
|
// outright (no buffering); MaxBytesReader then guards against a lying or
|
|
// chunked sender by failing the read once the limit is crossed. This is the
|
|
// fix for the pre-auth DoS: without it an unauthenticated peer could make the
|
|
// server buffer an unbounded body in RAM before authenticate() ever ran.
|
|
limit := int64(maxControlBodyBytes)
|
|
if r.Method == http.MethodPost && r.URL.Path == "/blobs" {
|
|
limit = int64(maxBlobBytes)
|
|
}
|
|
if r.ContentLength > limit {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
|
return
|
|
}
|
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
|
|
|
if s.authMode == AuthOff || isAuthExempt(r) {
|
|
s.mux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
|
|
// Buffer the (now bounded) body so the signature can be verified over it and
|
|
// the handler still reads it.
|
|
body, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
if isBodyTooLarge(err) {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusBadRequest, "read body")
|
|
return
|
|
}
|
|
_ = r.Body.Close()
|
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
|
|
|
res, err := s.authenticate(r, body, now)
|
|
if err != nil {
|
|
if s.authMode == AuthSoft {
|
|
log.Printf("[auth] soft: would reject %s %s: %v", r.Method, r.URL.Path, err)
|
|
s.mux.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
writeErr(w, http.StatusUnauthorized, "unauthorized: "+err.Error())
|
|
return
|
|
}
|
|
// Carry the authenticated signer's endpoint into the handler so room handlers
|
|
// can authorize by membership (audit H3). Only set on a verified identity.
|
|
s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint)))
|
|
}
|
|
|
|
// isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader
|
|
// when the body exceeds its limit, so the middleware can map it to 413.
|
|
func isBodyTooLarge(err error) bool {
|
|
var maxErr *http.MaxBytesError
|
|
return errors.As(err, &maxErr)
|
|
}
|
|
|
|
// ctxKey is the unexported type for this package's request-context keys, so the
|
|
// values cannot collide with keys set by other packages.
|
|
type ctxKey int
|
|
|
|
const ctxSignerEndpoint ctxKey = iota
|
|
|
|
// withSigner returns a context carrying the authenticated signer's endpoint id.
|
|
func withSigner(ctx context.Context, endpoint string) context.Context {
|
|
return context.WithValue(ctx, ctxSignerEndpoint, endpoint)
|
|
}
|
|
|
|
// signerEndpoint returns the authenticated signer's endpoint id and whether one
|
|
// is present. It is absent under AuthOff (no verification) and when a soft-mode
|
|
// request was let through unauthenticated — in both cases membership
|
|
// authorization is skipped, preserving dev/legacy behavior.
|
|
func signerEndpoint(r *http.Request) (string, bool) {
|
|
v, ok := r.Context().Value(ctxSignerEndpoint).(string)
|
|
return v, ok && v != ""
|
|
}
|
|
|
|
// requireMember authorizes a room request by membership (audit H3): it returns
|
|
// the signer endpoint and true when the request may proceed, or writes 403 and
|
|
// returns false when an authenticated signer is not a member of roomID. When no
|
|
// authenticated signer is present (AuthOff/dev, or soft pass-through) it allows
|
|
// the request — membership is only enforced once the caller's identity is known.
|
|
func (s *Server) requireMember(w http.ResponseWriter, r *http.Request, roomID string) (string, bool) {
|
|
signer, ok := signerEndpoint(r)
|
|
if !ok {
|
|
return "", true
|
|
}
|
|
if _, err := s.store.GetMember(roomID, signer); err != nil {
|
|
writeErr(w, http.StatusForbidden, "forbidden: not a member of this room")
|
|
return signer, false
|
|
}
|
|
return signer, true
|
|
}
|
|
|
|
// isAuthExempt lists requests that bypass control-plane auth even under enforce.
|
|
// Only the unauthenticated health probe qualifies: it carries no data and is
|
|
// needed by load balancers / smoke checks / systemd before any identity exists.
|
|
func isAuthExempt(r *http.Request) bool {
|
|
return r.Method == http.MethodGet && r.URL.Path == "/healthz"
|
|
}
|
|
|
|
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("GET /members/{endpoint}/rooms", s.handleListMemberRooms)
|
|
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 memberRoomJSON struct {
|
|
RoomID string `json:"room_id"`
|
|
Subject string `json:"subject"`
|
|
Epoch int `json:"epoch"`
|
|
Policy policyJSON `json:"policy"`
|
|
Role string `json:"role"`
|
|
}
|
|
|
|
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})
|
|
}
|
|
|
|
// writeServerErr logs the internal error detail and returns ONLY a generic
|
|
// message to the client (audit H12): raw store/blob errors embed SQL fragments
|
|
// and filesystem paths, which must not leak to a caller. Use it for any error
|
|
// that originates inside the server (5xx, or a not-found wrapping a store error).
|
|
func writeServerErr(w http.ResponseWriter, r *http.Request, code int, publicMsg string, err error) {
|
|
log.Printf("[handler] %s %s -> %d: %v", r.Method, r.URL.Path, code, err)
|
|
writeErr(w, code, publicMsg)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
// Data-plane minimum defense (audit H4): on a public deployment cleartext
|
|
// rooms are disabled, so no message ever rides the un-ACL'd NATS subject in
|
|
// the clear for another registered peer to sniff.
|
|
if s.RequireEncryptedRooms && !req.Policy.Encrypt {
|
|
writeErr(w, http.StatusForbidden,
|
|
"cleartext rooms are disabled on this deployment; create an encrypted (Matrix-policy) room")
|
|
return
|
|
}
|
|
// Owner binding (audit H6): the declared owner must BE the authenticated
|
|
// signer — both the endpoint id and the signing key. Otherwise a registered
|
|
// peer could create rooms in another identity's name. Enforced only when an
|
|
// authenticated signer is present (AuthOff/dev trusts the caller).
|
|
if signer, ok := signerEndpoint(r); ok {
|
|
if req.Owner.Endpoint != signer || frame.EndpointID(req.Owner.SignPub) != signer {
|
|
writeErr(w, http.StatusForbidden, "forbidden: room owner must be the authenticated signer")
|
|
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 {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
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 {
|
|
writeServerErr(w, r, http.StatusNotFound, "room not found", err)
|
|
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 {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
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
|
|
}
|
|
// A sealed room key is sealed to one identity's X25519 key. Serving it only to
|
|
// that identity (the signer) stops a registered peer from harvesting another
|
|
// member's sealed key (audit H3). Membership is implied by owning a sealed key,
|
|
// but we also require the signer to be a member for defense in depth.
|
|
if signer, ok := signerEndpoint(r); ok {
|
|
if endpoint != signer {
|
|
writeErr(w, http.StatusForbidden, "forbidden: may only fetch your own sealed key")
|
|
return
|
|
}
|
|
if _, err := s.store.GetMember(roomID, signer); err != nil {
|
|
writeErr(w, http.StatusForbidden, "forbidden: not a member of this room")
|
|
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
|
|
}
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, keyResp{Epoch: ep, SealedKey: sealed})
|
|
}
|
|
|
|
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
|
roomID := r.PathValue("id")
|
|
// Membership authorization (audit H3): the member list exposes every member's
|
|
// sign_pub + kex_pub, so it must not be served to a non-member.
|
|
if _, ok := s.requireMember(w, r, roomID); !ok {
|
|
return
|
|
}
|
|
members, err := s.store.ListMembers(roomID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "internal 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) handleListMemberRooms(w http.ResponseWriter, r *http.Request) {
|
|
endpoint := r.PathValue("endpoint")
|
|
if endpoint == "" {
|
|
writeErr(w, http.StatusBadRequest, "endpoint required")
|
|
return
|
|
}
|
|
// A peer may only enumerate its OWN room directory (audit H3): otherwise any
|
|
// registered identity could map another's entire social graph of rooms.
|
|
if signer, ok := signerEndpoint(r); ok && endpoint != signer {
|
|
writeErr(w, http.StatusForbidden, "forbidden: may only list your own rooms")
|
|
return
|
|
}
|
|
rooms, err := s.store.ListRoomsForEndpoint(endpoint)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
out := make([]memberRoomJSON, 0, len(rooms))
|
|
for _, rm := range rooms {
|
|
out = append(out, memberRoomJSON{
|
|
RoomID: rm.RoomID,
|
|
Subject: rm.Subject,
|
|
Epoch: rm.Epoch,
|
|
Policy: policyJSON{Encrypt: rm.Encrypt, Persist: rm.Persist, SignMsgs: rm.SignMsgs},
|
|
Role: rm.Role,
|
|
})
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) {
|
|
roomID := r.PathValue("id")
|
|
if _, ok := s.requireMember(w, r, roomID); !ok {
|
|
return
|
|
}
|
|
info, err := s.store.GetRoom(roomID)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, "room not found")
|
|
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 {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
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 {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
return
|
|
}
|
|
}
|
|
for _, ep := range req.Remove {
|
|
if err := s.store.RemoveMember(roomID, ep); err != nil {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": "rekeyed", "epoch": req.NewEpoch})
|
|
}
|
|
|
|
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request) {
|
|
// The body arrives already bounded: ServeHTTP wraps it in a MaxBytesReader
|
|
// (maxBlobBytes) and rejects an over-declared Content-Length before this
|
|
// handler runs, in every auth mode. Reading here therefore cannot buffer
|
|
// more than the ceiling; a sender that lies about its length (e.g. chunked)
|
|
// trips MaxBytesReader and we map that to 413 rather than a generic 400.
|
|
data, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
if isBodyTooLarge(err) {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "request body too large")
|
|
return
|
|
}
|
|
writeErr(w, http.StatusBadRequest, "read body")
|
|
return
|
|
}
|
|
hash, err := s.blobs.Put(data)
|
|
if err != nil {
|
|
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
|
|
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 {
|
|
writeServerErr(w, r, http.StatusNotFound, "not found", err)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(data)
|
|
}
|