- BLAKE3 keyed proof-of-work on object storage and auth challenges, with frozen vectors cross-checked against an independent Python reference implementing the single-block hash it needs. - Sparse Merkle trie over object IDs: order-independent roots, inclusion and absence proofs (internal/smt). - Signed checkpoint chain per relay: transport key amendment to INV-1, /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe epoch continuity (internal/checkpoint). - Head gossip with TOFU pinning and equivocation detection; light node (cmd/lightnode) that stores no history: quorum of pinned relays, every served object proven against the agreed root, LRU disk cache. - WebSocket streaming on relay and light node (coder/websocket): scoped channels mirroring REST, raw envelopes verified client-side; light node marks streamed objects unproven until checkpoint coverage. - Protocol v1 additions: DelegationClaim tag 0x07 with deterministic chain resolution in verify.Graph, KeyRotationRequest/Confirm tags 0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge; spec sections, frozen vectors appended byte-identically, Python reference extended. - Optional permissioned BFT finality over gossip (internal/bft): prevote/precommit with quorum certificates verifiable offline. - Quick wins: Policy.TrustedIssuers, per-type stored metrics, batch fetch, lexicographic lists with stable cursor pagination. - Security review of the network layer (docs/SECURITY-REVIEW.md) with findings F-01..F-09; hub send/close race and unstable pagination fixed under review. 12 packages green, vet/gofmt clean, protocol fuzzing stable.
589 lines
18 KiB
Go
589 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/identity"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/pow"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/tce"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
|
)
|
|
|
|
// maxBodyBytes bounds the size of any request body the relay will read. A
|
|
// content-addressed object is bounded by protocol limits; this is a hard cap on
|
|
// the wire envelope so a single client cannot exhaust server memory.
|
|
const maxBodyBytes = tce.MaxClaimTCE*2 + 1024
|
|
|
|
// challengeTTL is how long an issued auth challenge stays valid.
|
|
const challengeTTL = 5 * time.Minute
|
|
|
|
// sessionTTL is how long a verified auth session stays valid.
|
|
const sessionTTL = 30 * time.Minute
|
|
|
|
// Server is the trust relay: it stores signed objects and brokers
|
|
// authentication, without ever holding a signing key or making authorization
|
|
// decisions (INV-1, INV-5).
|
|
type Server struct {
|
|
store *Store
|
|
audience string
|
|
metrics *Metrics
|
|
ready bool
|
|
|
|
putLimiter *ipLimiter
|
|
challengeLimiter *ipLimiter
|
|
powChallengeLimiter *ipLimiter
|
|
|
|
powPutBits int
|
|
powAuthBits int
|
|
|
|
mu sync.Mutex
|
|
challenges map[string]time.Time // challenge hex -> expiry
|
|
powChallenges map[string]powChallengeRecord // pow key hex -> issued challenge
|
|
sessions map[string]session // session token -> session
|
|
|
|
// ckpt publishes the signed object-set commitment; nil when disabled.
|
|
ckpt *checkpointState
|
|
|
|
// gossip records heads other relays have announced, for split-view
|
|
// detection. Present even when checkpoints are disabled locally: a node
|
|
// can relay others' claims about their own logs.
|
|
gossip *gossipState
|
|
|
|
// ws streams newly stored envelopes to subscribed sessions.
|
|
ws *wsHubState
|
|
|
|
// bft is the optional finality validator state; nil unless enabled.
|
|
bft *bftState
|
|
}
|
|
|
|
type session struct {
|
|
identity string
|
|
scope string
|
|
expiry time.Time
|
|
}
|
|
|
|
// New builds a relay that binds AuthAssertions to the given audience (its
|
|
// hostname, e.g. "trust.n1ko.dev"). If dataDir is non-empty, objects are
|
|
// persisted there across restarts.
|
|
func New(audience, dataDir string, opts ...Option) *Server {
|
|
s := &Server{
|
|
store: NewStore(dataDir),
|
|
audience: audience,
|
|
metrics: NewMetrics(),
|
|
challenges: make(map[string]time.Time),
|
|
powChallenges: make(map[string]powChallengeRecord),
|
|
sessions: make(map[string]session),
|
|
gossip: newGossipState(),
|
|
ws: newWSHub(),
|
|
putLimiter: newIPLimiter(60, time.Minute),
|
|
challengeLimiter: newIPLimiter(30, time.Minute),
|
|
powChallengeLimiter: newIPLimiter(powChallengeLimitPerMin, time.Minute),
|
|
}
|
|
for _, opt := range opts {
|
|
opt(s)
|
|
}
|
|
s.ready = true
|
|
return s
|
|
}
|
|
|
|
// Store exposes the underlying object store.
|
|
func (s *Server) Store() *Store { return s.store }
|
|
|
|
// Handler returns the HTTP handler implementing the JSON transport.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("POST /v1/objects", s.rateLimitPut)
|
|
mux.HandleFunc("GET /v1/objects/{id}", s.handleGet)
|
|
mux.HandleFunc("GET /v1/objects", s.handleObjectsBatch)
|
|
mux.HandleFunc("GET /v1/claims", s.handleClaims)
|
|
mux.HandleFunc("GET /v1/requests", s.handleRequests)
|
|
mux.HandleFunc("GET /v1/responses", s.handleResponses)
|
|
mux.HandleFunc("GET /v1/revocations", s.handleRevocations)
|
|
mux.HandleFunc("GET /v1/config", s.handleConfig)
|
|
mux.HandleFunc("GET /v1/metrics", s.handleMetrics)
|
|
mux.HandleFunc("GET /v1/healthz", s.handleHealth)
|
|
mux.HandleFunc("GET /v1/readyz", s.handleReady)
|
|
mux.HandleFunc("POST /v1/auth/challenge", s.rateLimitChallenge)
|
|
mux.HandleFunc("POST /v1/auth/assert", s.handleAssert)
|
|
mux.HandleFunc("POST /v1/pow/challenge", s.rateLimitPowChallenge)
|
|
mux.HandleFunc("GET /v1/checkpoint/latest", s.handleCheckpointLatest)
|
|
mux.HandleFunc("GET /v1/checkpoint/{epoch}", s.handleCheckpointEpoch)
|
|
mux.HandleFunc("GET /v1/proof/object/{id}", s.handleProofObject)
|
|
mux.HandleFunc("GET /v1/proof/absent/{id}", s.handleProofAbsent)
|
|
mux.HandleFunc("POST /v1/gossip/checkpoint", s.handleGossipCheckpoint)
|
|
mux.HandleFunc("GET /v1/peers/heads", s.handlePeerHeads)
|
|
mux.HandleFunc("GET /v1/ws", s.handleWS)
|
|
mux.HandleFunc("POST /v1/bft/proposal", s.handleBFTProposal)
|
|
mux.HandleFunc("POST /v1/bft/vote", s.handleBFTVote)
|
|
mux.HandleFunc("GET /v1/bft/state", s.handleBFTState)
|
|
mux.HandleFunc("GET /v1/bft/certificate/{height}", s.handleBFTCertificate)
|
|
return mux
|
|
}
|
|
|
|
func (s *Server) rateLimitPut(w http.ResponseWriter, r *http.Request) {
|
|
if !s.putLimiter.allow(clientIP(r)) {
|
|
writeErr(w, http.StatusTooManyRequests, "rate limited")
|
|
return
|
|
}
|
|
s.HandlePut(w, r)
|
|
}
|
|
|
|
func (s *Server) rateLimitChallenge(w http.ResponseWriter, r *http.Request) {
|
|
if !s.challengeLimiter.allow(clientIP(r)) {
|
|
writeErr(w, http.StatusTooManyRequests, "rate limited")
|
|
return
|
|
}
|
|
s.handleChallenge(w, r)
|
|
}
|
|
|
|
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
|
s.metrics.Write(w)
|
|
}
|
|
|
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
|
if s.ready {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
|
|
return
|
|
}
|
|
writeErr(w, http.StatusServiceUnavailable, "not ready")
|
|
}
|
|
|
|
func (s *Server) HandlePut(w http.ResponseWriter, r *http.Request) {
|
|
req, ok := decodeWire(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if len(req.TCE) == 0 {
|
|
writeErr(w, http.StatusBadRequest, "missing tce")
|
|
return
|
|
}
|
|
// Admission control binds the proof to this exact submission by hashing
|
|
// over the content ID of the enclosed bytes.
|
|
id := tce.ComputeID(req.TCE)
|
|
var target [pow.TargetSize]byte
|
|
copy(target[:], id.Bytes())
|
|
if ok, msg := s.admitPoW(req.Pow, powPurposePut, target, s.powPutBits); !ok {
|
|
writeErr(w, http.StatusTooManyRequests, msg)
|
|
return
|
|
}
|
|
objectID, created, err := s.store.Put(req.TCE, req.Signature, req.ObjectID)
|
|
if err != nil {
|
|
s.metrics.inc(&s.metrics.objectsRejected)
|
|
writeErr(w, http.StatusUnprocessableEntity, err.Error())
|
|
return
|
|
}
|
|
s.metrics.inc(&s.metrics.objectsStored)
|
|
if created {
|
|
if typ, obj, derr := transport.DecodeObject(req.TCE); derr == nil {
|
|
s.metrics.incStoredType(typ)
|
|
if created {
|
|
s.notifyCheckpoint()
|
|
s.hubBroadcastObj(typ, obj, req.TCE, req.Signature, objectID)
|
|
}
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"object_id": objectID})
|
|
}
|
|
|
|
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
env, err := s.store.Get(id)
|
|
if err != nil {
|
|
writeErr(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, env)
|
|
}
|
|
|
|
// scopeOK reports whether a session may perform an action requiring the given
|
|
// scope. Besides the exact scope, a session granted the generic "read" or "*"
|
|
// capability may read any read endpoint.
|
|
func scopeOK(ses session, required string) bool {
|
|
return ses.scope == required || ses.scope == "read" || ses.scope == "*"
|
|
}
|
|
|
|
// paginate applies optional limit/offset/after query params to a result
|
|
// list. A non-positive limit means "no cap". `after` keeps only entries
|
|
// whose content id is lexicographically greater, giving clients a stable
|
|
// cursor across concurrent inserts.
|
|
func paginate(in []*transport.Envelope, limit, offset int, after string) []*transport.Envelope {
|
|
if after != "" {
|
|
kept := in[:0]
|
|
for _, env := range in {
|
|
if tce.ComputeID(env.TCE).String() > after {
|
|
kept = append(kept, env)
|
|
}
|
|
}
|
|
in = kept
|
|
}
|
|
if offset > 0 {
|
|
if offset >= len(in) {
|
|
return nil
|
|
}
|
|
in = in[offset:]
|
|
}
|
|
if limit > 0 && limit < len(in) {
|
|
in = in[:limit]
|
|
}
|
|
return in
|
|
}
|
|
|
|
// listParams reads limit/offset/after from the query string.
|
|
func listParams(r *http.Request) (limit, offset int, after string) {
|
|
if v := r.URL.Query().Get("limit"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
limit = n
|
|
}
|
|
}
|
|
if v := r.URL.Query().Get("offset"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
offset = n
|
|
}
|
|
}
|
|
return limit, offset, r.URL.Query().Get("after")
|
|
}
|
|
|
|
const maxBatchIDs = 100
|
|
|
|
// handleObjectsBatch serves GET /v1/objects?ids=a,b,c — one round trip for
|
|
// consumers that already know which objects they need. Missing ids are
|
|
// omitted rather than erroring.
|
|
func (s *Server) handleObjectsBatch(w http.ResponseWriter, r *http.Request) {
|
|
raw := r.URL.Query().Get("ids")
|
|
if raw == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing ids")
|
|
return
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
if len(parts) > maxBatchIDs {
|
|
writeErr(w, http.StatusBadRequest, "too many ids")
|
|
return
|
|
}
|
|
clean := make([]string, 0, len(parts))
|
|
for _, id := range parts {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, err := tce.ParseID(id); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "malformed object id: "+id)
|
|
return
|
|
}
|
|
clean = append(clean, id)
|
|
}
|
|
found, err := s.store.GetMany(clean)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"objects": found})
|
|
}
|
|
|
|
func (s *Server) handleClaims(w http.ResponseWriter, r *http.Request) {
|
|
ses, ok := s.sessionByToken(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if !scopeOK(ses, "read:claims") {
|
|
writeErr(w, http.StatusForbidden, "insufficient scope")
|
|
return
|
|
}
|
|
subject := r.URL.Query().Get("subject")
|
|
if subject == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing subject")
|
|
return
|
|
}
|
|
list, err := s.store.ClaimsBySubject(subject)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
lim, off, after := listParams(r)
|
|
writeJSON(w, http.StatusOK, map[string]any{"claims": paginate(list, lim, off, after)})
|
|
}
|
|
|
|
func (s *Server) handleRequests(w http.ResponseWriter, r *http.Request) {
|
|
ses, ok := s.sessionByToken(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if !scopeOK(ses, "read:requests") {
|
|
writeErr(w, http.StatusForbidden, "insufficient scope")
|
|
return
|
|
}
|
|
recipient := r.URL.Query().Get("recipient")
|
|
if recipient == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing recipient")
|
|
return
|
|
}
|
|
list, err := s.store.RequestsByRecipient(recipient)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
lim, off, after := listParams(r)
|
|
writeJSON(w, http.StatusOK, map[string]any{"requests": paginate(list, lim, off, after)})
|
|
}
|
|
|
|
func (s *Server) handleResponses(w http.ResponseWriter, r *http.Request) {
|
|
ses, ok := s.sessionByToken(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if !scopeOK(ses, "read:responses") {
|
|
writeErr(w, http.StatusForbidden, "insufficient scope")
|
|
return
|
|
}
|
|
req := r.URL.Query().Get("request")
|
|
if req == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing request")
|
|
return
|
|
}
|
|
list, err := s.store.ResponsesForRequest(req)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
lim, off, after := listParams(r)
|
|
writeJSON(w, http.StatusOK, map[string]any{"responses": paginate(list, lim, off, after)})
|
|
}
|
|
|
|
func (s *Server) handleRevocations(w http.ResponseWriter, r *http.Request) {
|
|
ses, ok := s.sessionByToken(r)
|
|
if !ok {
|
|
writeErr(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
if !scopeOK(ses, "read:revocations") {
|
|
writeErr(w, http.StatusForbidden, "insufficient scope")
|
|
return
|
|
}
|
|
claim := r.URL.Query().Get("claim")
|
|
if claim == "" {
|
|
writeErr(w, http.StatusBadRequest, "missing claim")
|
|
return
|
|
}
|
|
list, err := s.store.RevocationsForClaim(claim)
|
|
if err != nil {
|
|
writeErr(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
lim, off, after := listParams(r)
|
|
writeJSON(w, http.StatusOK, map[string]any{"revocations": paginate(list, lim, off, after)})
|
|
}
|
|
|
|
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|
out := map[string]string{"audience": s.audience}
|
|
if s.ckpt != nil {
|
|
out["relay_pubkey"] = s.ckpt.pubHex
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
|
|
// Admission control for challenge issuance itself: an anonymous flood of
|
|
// auth attempts must pay per attempt, not merely per IP per minute.
|
|
// The body is optional when no PoW tier is configured.
|
|
var body wireRequest
|
|
raw := readCapped(w, r)
|
|
if len(bytes.TrimSpace(raw)) > 0 {
|
|
if err := json.Unmarshal(raw, &body); err != nil {
|
|
writeErr(w, http.StatusBadRequest, "bad envelope")
|
|
return
|
|
}
|
|
}
|
|
if ok, msg := s.admitPoW(body.Pow, powPurposeAuth, zeroTarget(), s.powAuthBits); !ok {
|
|
writeErr(w, http.StatusTooManyRequests, msg)
|
|
return
|
|
}
|
|
ch := make([]byte, tce.ChallengeSize)
|
|
if _, err := rand.Read(ch); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "challenge")
|
|
return
|
|
}
|
|
chHex := hex.EncodeToString(ch)
|
|
s.mu.Lock()
|
|
// Drop any challenges that have lapsed without being used.
|
|
nowTs := now()
|
|
for k, exp := range s.challenges {
|
|
if exp.Before(nowTs) {
|
|
delete(s.challenges, k)
|
|
}
|
|
}
|
|
s.challenges[chHex] = nowTs.Add(challengeTTL)
|
|
s.mu.Unlock()
|
|
s.metrics.inc(&s.metrics.challengesIssued)
|
|
writeJSON(w, http.StatusOK, map[string]string{"challenge": chHex})
|
|
}
|
|
|
|
func (s *Server) handleAssert(w http.ResponseWriter, r *http.Request) {
|
|
env, ok := decodeEnvelope(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Strict-decode and verify the assertion, bound to this server's audience.
|
|
if _, _, err := transport.DecodeObject(env.TCE); err != nil {
|
|
writeErr(w, http.StatusUnprocessableEntity, "rejected: "+err.Error())
|
|
return
|
|
}
|
|
assert, err := protocol.DecodeAuthAssertion(env.TCE)
|
|
if err != nil {
|
|
writeErr(w, http.StatusUnprocessableEntity, "rejected")
|
|
return
|
|
}
|
|
if _, err := protocol.VerifyAuthAssertion(env.TCE, env.Signature, s.audience); err != nil {
|
|
writeErr(w, http.StatusUnauthorized, "auth failed: "+err.Error())
|
|
return
|
|
}
|
|
// The assertion must be bound to a challenge this server actually issued,
|
|
// and each challenge is single-use: consuming it here blocks replay of a
|
|
// captured assertion.
|
|
chHex := hex.EncodeToString(assert.Challenge)
|
|
s.mu.Lock()
|
|
expiry, issued := s.challenges[chHex]
|
|
if issued {
|
|
delete(s.challenges, chHex)
|
|
}
|
|
s.mu.Unlock()
|
|
if !issued || expiry.Before(now()) {
|
|
s.metrics.inc(&s.metrics.assertionsFailed)
|
|
writeErr(w, http.StatusUnauthorized, "unknown or expired challenge")
|
|
return
|
|
}
|
|
// The assertion is valid for this audience; mint an opaque session token.
|
|
// The server holds no signing key (INV-1): the token is a random reference
|
|
// to the verified identity, stored server-side.
|
|
tok := make([]byte, 32)
|
|
if _, err := rand.Read(tok); err != nil {
|
|
writeErr(w, http.StatusInternalServerError, "session")
|
|
return
|
|
}
|
|
tokHex := hex.EncodeToString(tok)
|
|
id := identityFromPubKey(assert.PubKey)
|
|
s.mu.Lock()
|
|
s.sessions[tokHex] = session{identity: id, scope: assert.Scope, expiry: now().Add(sessionTTL)}
|
|
s.metrics.sessionsActive.Add(1)
|
|
s.mu.Unlock()
|
|
s.metrics.inc(&s.metrics.assertionsOK)
|
|
writeJSON(w, http.StatusOK, map[string]string{
|
|
"session_token": tokHex,
|
|
"identity": id,
|
|
"scope": assert.Scope,
|
|
})
|
|
}
|
|
|
|
// sessionByToken returns a valid, unexpired session for the request's bearer
|
|
// token, if any. Expired sessions are purged on access.
|
|
func (s *Server) sessionByToken(r *http.Request) (session, bool) {
|
|
tok := bearerToken(r)
|
|
if tok == "" {
|
|
return session{}, false
|
|
}
|
|
s.mu.Lock()
|
|
ses, ok := s.sessions[tok]
|
|
if ok {
|
|
if ses.expiry.Before(now()) {
|
|
delete(s.sessions, tok)
|
|
s.metrics.sessionsActive.Add(-1)
|
|
ok = false
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
return ses, ok
|
|
}
|
|
|
|
// bearerToken extracts a session token from the Authorization header or the
|
|
// ?token= query parameter.
|
|
func bearerToken(r *http.Request) string {
|
|
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
|
|
return strings.TrimSpace(h[len("Bearer "):])
|
|
}
|
|
return r.URL.Query().Get("token")
|
|
}
|
|
|
|
func identityFromPubKey(pub []byte) string {
|
|
id, err := identity.FromPubKey(pub)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return id.Address().String()
|
|
}
|
|
|
|
// wireRequest is the JSON body of endpoints that carry a signed envelope:
|
|
// object storage and authentication. The optional pow field carries a solved
|
|
// admission challenge; it is transport metadata and never reaches TCE bytes.
|
|
type wireRequest struct {
|
|
TCE []byte `json:"tce"`
|
|
Signature []byte `json:"signature"`
|
|
ObjectID string `json:"object_id,omitempty"`
|
|
Pow *wirePow `json:"pow,omitempty"`
|
|
}
|
|
|
|
// decodeWire reads a JSON request body from the request, enforcing the
|
|
// server-wide body cap first.
|
|
func decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
|
var req wireRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
var maxErr *http.MaxBytesError
|
|
if errors.As(err, &maxErr) {
|
|
writeErr(w, http.StatusRequestEntityTooLarge, "payload too large")
|
|
} else {
|
|
writeErr(w, http.StatusBadRequest, "bad envelope")
|
|
}
|
|
return nil, false
|
|
}
|
|
return &req, true
|
|
}
|
|
|
|
// decodeEnvelope adapts decodeWire to the plain envelope form for callers
|
|
// that do not care about admission control.
|
|
func decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) {
|
|
req, ok := decodeWire(w, r)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &transport.Envelope{TCE: req.TCE, Signature: req.Signature}, true
|
|
}
|
|
|
|
// readCapped reads the request body under the server-wide cap.
|
|
func readCapped(w http.ResponseWriter, r *http.Request) []byte {
|
|
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return raw
|
|
}
|
|
|
|
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})
|
|
}
|