niko_trust/internal/server/server.go
Niko Marmeladkov 79df689f7a refactor: drop PoW, checkpoint chain, gossip, light node and BFT; keep WS
The decentralization stack overcomplicated the project. Removed:
internal/pow, internal/smt, internal/checkpoint, internal/bft,
internal/lightnode, cmd/lightnode, relay gossip/checkpoint/proof/BFT
endpoints, their docs, vectors and the blake3 dependency.

Kept: WebSocket streaming on the relay, the full protocol v1 object set
including DelegationClaim (0x07) and KeyRotation request/confirm
(0x08/0x09) with chain resolution in verify.Graph, TrustedIssuers,
batch fetch, stable cursor pagination, per-type metrics.

INV-1 reverts to its original form: the relay holds no keys again.
Everything removed remains reachable at commit 20cc52c.
2026-08-26 00:49:25 +03:00

550 lines
16 KiB
Go

package server
import (
"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/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// 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
maxBodyBytes int64
challengeTTL time.Duration
sessionTTL time.Duration
putLimiter *ipLimiter
challengeLimiter *ipLimiter
mu sync.Mutex
challenges map[string]time.Time // challenge hex -> expiry
sessions map[string]session // session token -> session
// ws streams newly stored envelopes to subscribed sessions.
ws *wsHubState
}
type session struct {
identity string
scope string
expiry time.Time
}
// New builds a relay from the given Config. Any zero-valued field falls back to
// DefaultConfig, so callers may pass a partially-populated Config.
func New(cfg Config) *Server {
if cfg.ListenAddr == "" {
cfg.ListenAddr = ":8080"
}
if cfg.Audience == "" {
cfg.Audience = "trust.n1ko.dev"
}
if cfg.LogLevel == "" {
cfg.LogLevel = "info"
}
if cfg.MaxBodyBytes <= 0 {
cfg.MaxBodyBytes = tce.MaxClaimTCE*2 + 1024
}
if cfg.PutLimit <= 0 {
cfg.PutLimit = 60
}
if cfg.PutWindow <= 0 {
cfg.PutWindow = time.Minute
}
if cfg.ChallengeLimit <= 0 {
cfg.ChallengeLimit = 30
}
if cfg.ChallengeWindow <= 0 {
cfg.ChallengeWindow = time.Minute
}
if cfg.MaxPerSubject <= 0 {
cfg.MaxPerSubject = 1000
}
if cfg.ChallengeTTL <= 0 {
cfg.ChallengeTTL = 5 * time.Minute
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 30 * time.Minute
}
s := &Server{
store: NewStore(cfg.DataDir, cfg.MaxPerSubject),
audience: cfg.Audience,
metrics: NewMetrics(),
maxBodyBytes: cfg.MaxBodyBytes,
challengeTTL: cfg.ChallengeTTL,
sessionTTL: cfg.SessionTTL,
challenges: make(map[string]time.Time),
sessions: make(map[string]session),
ws: newWSHub(),
putLimiter: newIPLimiter(cfg.PutLimit, cfg.PutWindow),
challengeLimiter: newIPLimiter(cfg.ChallengeLimit, cfg.ChallengeWindow),
}
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("GET /v1/ws", s.handleWS)
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 := s.decodeWire(w, r)
if !ok {
return
}
if len(req.TCE) == 0 {
writeErr(w, http.StatusBadRequest, "missing tce")
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)
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) {
writeJSON(w, http.StatusOK, map[string]string{"audience": s.audience})
}
func (s *Server) handleChallenge(w http.ResponseWriter, r *http.Request) {
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(s.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 := s.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(s.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"`
}
// decodeWire reads a JSON request body from the request, enforcing the
// server-wide body cap first.
func (s *Server) decodeWire(w http.ResponseWriter, r *http.Request) (*wireRequest, bool) {
r.Body = http.MaxBytesReader(w, r.Body, s.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 (s *Server) decodeEnvelope(w http.ResponseWriter, r *http.Request) (*transport.Envelope, bool) {
req, ok := s.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 (s *Server) readCapped(w http.ResponseWriter, r *http.Request) []byte {
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, s.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})
}