From 6b424b8f9a51d9759867bb115139dc2608d366be Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 3 Jul 2026 15:17:46 +0300 Subject: [PATCH 1/2] Add external signature verification endpoint - POST /api/v1/verify checks pilot-req-v1 envelopes against registered node keys; uniform valid:false failures (no existence oracle); signed verdicts, negatives included - GET /api/v1/verify/keys publishes the verdict issuer key - verdict key auto-generated and persisted next to the snapshot (vfy-v1) - online = last_seen within 180s, decoupled from the reaper threshold - dashboard.verify breaker, 60/min per-IP limit, 8KB body cap - JSON lookup responses gain last_seen_unix and key_generation --- dashboard/dashboard.go | 124 ++++++++++++++++- dashboard/zz_verify_http_test.go | 215 ++++++++++++++++++++++++++++ directory/directory.go | 8 ++ server.go | 10 ++ server_lifecycle.go | 15 ++ server_verify.go | 162 ++++++++++++++++++++++ verify_key.go | 137 ++++++++++++++++++ zz_verify_key_test.go | 110 +++++++++++++++ zz_verify_test.go | 231 +++++++++++++++++++++++++++++++ 9 files changed, 1011 insertions(+), 1 deletion(-) create mode 100644 dashboard/zz_verify_http_test.go create mode 100644 server_verify.go create mode 100644 verify_key.go create mode 100644 zz_verify_key_test.go create mode 100644 zz_verify_test.go diff --git a/dashboard/dashboard.go b/dashboard/dashboard.go index 2b35d68..cf751d0 100644 --- a/dashboard/dashboard.go +++ b/dashboard/dashboard.go @@ -117,6 +117,14 @@ type Callbacks struct { // implicitly allowed (default-Closed semantics, same as a missing // breakers.json). BreakerAllow func(name string) (allowed bool, reason string) + // VerifyRequest verifies an external request-signature envelope + // (reqsig canonical form) plus its base64 signature and returns the + // JSON-serializable verification response (server.VerifyResponse). + // nil disables POST /api/v1/verify. + VerifyRequest func(canonical, sigB64 string) interface{} + // VerifyKeys returns the verdict-issuer public keys published on + // GET /api/v1/verify/keys. Each entry carries kid, algo, public_key. + VerifyKeys func() []map[string]string // BreakerList returns the live snapshot for /api/breakers — one // entry per known name with state, reason, counters, last-denied // timestamp. Required for the read endpoint; nil disables it. @@ -307,6 +315,12 @@ type Handler struct { // Per-IP rate limiter for public badge endpoints. badgeLimiter *ipRateLimiter + // Per-IP rate limiters for the public verification endpoints. + // Deliberately separate buckets from badgeLimiter (and from each + // other) so verify traffic can't starve badge fetches or vice versa. + verifyLimiter *ipRateLimiter + verifyKeysLimiter *ipRateLimiter + // Probe state — per-probe health history ring. probeMu sync.Mutex probeStates map[string]*ProbeState @@ -321,7 +335,12 @@ type Handler struct { // NewHandler creates a ready-to-use dashboard Handler backed by cb. func NewHandler(cb Callbacks) *Handler { - return &Handler{cb: cb, badgeLimiter: newIPRateLimiter()} + return &Handler{ + cb: cb, + badgeLimiter: newIPRateLimiter(), + verifyLimiter: newIPRateLimiter(), + verifyKeysLimiter: newIPRateLimiter(), + } } // SetWhitelistCallbacks wires the GET/PUT /api/admin/whitelist endpoints @@ -781,6 +800,104 @@ func localhostOnly(next http.HandlerFunc) http.HandlerFunc { } } +// -------------------------------------------------------------------------- +// External request verification — /api/v1/verify +// -------------------------------------------------------------------------- + +// maxVerifyBodyBytes caps the POST /api/v1/verify request body. A canonical +// envelope plus base64 signature fits comfortably under 1 KB; 8 KB leaves +// headroom without letting anonymous callers stream junk. +const maxVerifyBodyBytes = 8 << 10 + +// registerVerifyRoutes registers the public verification endpoints on mux. +// Called from Serve; split out so tests can mount the routes on a bare mux +// without spinning up the full Serve lifecycle. +func (h *Handler) registerVerifyRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/verify", h.verifyLimiter.middleware(60, time.Minute, h.handleVerify)) + mux.HandleFunc("/api/v1/verify/keys", h.verifyKeysLimiter.middleware(120, time.Minute, h.handleVerifyKeys)) +} + +// verifyBreakerDenied writes the 503 breaker payload (same shape as +// /api/public-stats) and reports whether the request was denied. Both +// verification endpoints share the single "dashboard.verify" switch. +func (h *Handler) verifyBreakerDenied(w http.ResponseWriter) bool { + if h.cb.BreakerAllow == nil { + return false + } + ok, reason := h.cb.BreakerAllow("dashboard.verify") + if ok { + return false + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + body := map[string]string{"status": "unavailable"} + if reason != "" { + body["reason"] = reason + } + _ = json.NewEncoder(w).Encode(body) + return true +} + +// handleVerify serves POST /api/v1/verify — public (no admin token), +// breaker-gated, per-IP rate-limited. Body: {"envelope":"...","signature":"..."}. +// The response is the server's VerifyResponse; failures inside verification +// still return 200 with valid:false (uniform shape, no existence oracle). +func (h *Handler) handleVerify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.verifyBreakerDenied(w) { + return + } + if h.cb.VerifyRequest == nil { + http.Error(w, "verification not available", http.StatusServiceUnavailable) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxVerifyBodyBytes) + defer r.Body.Close() + var req struct { + Envelope string `json:"envelope"` + Signature string `json:"signature"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // MaxBytesReader turns an oversized body into a read error, so + // malformed JSON and oversized bodies both land here. + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if req.Envelope == "" || req.Signature == "" { + http.Error(w, "envelope and signature are required", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(h.cb.VerifyRequest(req.Envelope, req.Signature)) +} + +// handleVerifyKeys serves GET /api/v1/verify/keys — the verdict-issuer public +// keys, so consumers can pin them and check verdicts offline. +func (h *Handler) handleVerifyKeys(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if h.verifyBreakerDenied(w) { + return + } + keys := []map[string]string{} + if h.cb.VerifyKeys != nil { + if k := h.cb.VerifyKeys(); k != nil { + keys = k + } + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys}) +} + // -------------------------------------------------------------------------- // Serve // -------------------------------------------------------------------------- @@ -799,6 +916,11 @@ func (h *Handler) Serve(addr string) error { func (h *Handler) buildMux() *http.ServeMux { mux := http.NewServeMux() + // /api/v1/verify + /api/v1/verify/keys — public external + // request-signature verification (no admin token, breaker-gated, + // per-IP rate-limited). + h.registerVerifyRoutes(mux) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) diff --git a/dashboard/zz_verify_http_test.go b/dashboard/zz_verify_http_test.go new file mode 100644 index 0000000..87c584c --- /dev/null +++ b/dashboard/zz_verify_http_test.go @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package dashboard + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newVerifyMux mounts only the verification routes on a bare mux, mirroring +// how tests elsewhere in this package exercise single endpoints without the +// full Serve lifecycle. registerVerifyRoutes is the SAME registration Serve +// uses, so there is no shim to drift. +func newVerifyMux(cb Callbacks) (*Handler, *http.ServeMux) { + h := NewHandler(cb) + mux := http.NewServeMux() + h.registerVerifyRoutes(mux) + return h, mux +} + +func verifyCallbacks(t *testing.T) Callbacks { + t.Helper() + cb := minimalCallbacks() + cb.VerifyRequest = func(canonical, sigB64 string) interface{} { + return map[string]interface{}{ + "valid": true, + "envelope": canonical, + "signature": sigB64, + } + } + cb.VerifyKeys = func() []map[string]string { + return []map[string]string{{ + "kid": "vfy-v1", + "algo": "ed25519", + "public_key": "AAAAC3NzaC1lZDI1NTE5AAAA", + }} + } + return cb +} + +func postVerify(mux *http.ServeMux, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/v1/verify", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + return rec +} + +// TestVerifyHTTPHappyPath: a well-formed POST reaches the callback and the +// callback's response is returned verbatim as JSON. +func TestVerifyHTTPHappyPath(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + rec := postVerify(mux, `{"envelope":"pilot-req-v1|deadbeef","signature":"c2ln"}`) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type = %q", ct) + } + var payload map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload["valid"] != true { + t.Fatalf("valid = %v, want true", payload["valid"]) + } + if payload["envelope"] != "pilot-req-v1|deadbeef" || payload["signature"] != "c2ln" { + t.Fatalf("callback did not receive envelope/signature: %v", payload) + } +} + +// TestVerifyHTTPMethodNotAllowed: only POST is accepted on /api/v1/verify. +func TestVerifyHTTPMethodNotAllowed(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} { + req := httptest.NewRequest(method, "/api/v1/verify", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("%s: status = %d, want 405", method, rec.Code) + } + } +} + +// TestVerifyHTTPMalformedBody: junk JSON and missing fields both 400. +func TestVerifyHTTPMalformedBody(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + for _, body := range []string{"{not json", "{}", `{"envelope":"only"}`, `{"signature":"only"}`} { + rec := postVerify(mux, body) + if rec.Code != http.StatusBadRequest { + t.Fatalf("body %q: status = %d, want 400", body, rec.Code) + } + } +} + +// TestVerifyHTTPOversizedBody: bodies beyond the 8KB cap are rejected. +func TestVerifyHTTPOversizedBody(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + big := `{"envelope":"` + strings.Repeat("a", maxVerifyBodyBytes+1) + `","signature":"c2ln"}` + rec := postVerify(mux, big) + if rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("oversized body: status = %d, want 400 or 413", rec.Code) + } +} + +// TestVerifyHTTPBreakerOpen: an open dashboard.verify breaker turns both +// endpoints into 503s with the standard unavailable payload. +func TestVerifyHTTPBreakerOpen(t *testing.T) { + t.Parallel() + cb := verifyCallbacks(t) + cb.BreakerAllow = func(name string) (bool, string) { + if name == "dashboard.verify" { + return false, "maintenance" + } + return true, "" + } + _, mux := newVerifyMux(cb) + + rec := postVerify(mux, `{"envelope":"e","signature":"s"}`) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("verify with open breaker: status = %d, want 503", rec.Code) + } + var payload map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal 503 body: %v", err) + } + if payload["status"] != "unavailable" || payload["reason"] != "maintenance" { + t.Fatalf("503 payload = %v", payload) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/verify/keys", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("keys with open breaker: status = %d, want 503", rec.Code) + } +} + +// TestVerifyHTTPRateLimit: the 61st request within a minute from one IP is +// rejected with 429; the verify limiter is independent of badgeLimiter. +func TestVerifyHTTPRateLimit(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + body := `{"envelope":"e","signature":"s"}` + for i := 0; i < 60; i++ { + rec := postVerify(mux, body) + if rec.Code != http.StatusOK { + t.Fatalf("request %d: status = %d, want 200", i+1, rec.Code) + } + } + rec := postVerify(mux, body) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("request 61: status = %d, want 429", rec.Code) + } +} + +// TestVerifyHTTPKeys: GET returns the issuer key list; POST is a 405. +func TestVerifyHTTPKeys(t *testing.T) { + t.Parallel() + _, mux := newVerifyMux(verifyCallbacks(t)) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/verify/keys", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + var payload struct { + Keys []map[string]string `json:"keys"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Keys) != 1 { + t.Fatalf("keys length = %d, want 1", len(payload.Keys)) + } + k := payload.Keys[0] + if k["kid"] != "vfy-v1" || k["algo"] != "ed25519" || k["public_key"] == "" { + t.Fatalf("key entry = %v", k) + } + + req = httptest.NewRequest(http.MethodPost, "/api/v1/verify/keys", bytes.NewReader(nil)) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST keys: status = %d, want 405", rec.Code) + } +} + +// TestVerifyHTTPNoCallback: with no VerifyRequest wired the endpoint reports +// unavailability instead of panicking. +func TestVerifyHTTPNoCallback(t *testing.T) { + t.Parallel() + cb := verifyCallbacks(t) + cb.VerifyRequest = nil + _, mux := newVerifyMux(cb) + + rec := postVerify(mux, `{"envelope":"e","signature":"s"}`) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503", rec.Code) + } +} diff --git a/directory/directory.go b/directory/directory.go index bff00ae..50a06f6 100644 --- a/directory/directory.go +++ b/directory/directory.go @@ -1040,6 +1040,14 @@ func (st *Store) HandleLookup(msg map[string]interface{}) (map[string]interface{ "public_key": crypto.EncodePublicKey(node.PublicKey), "public": node.Public, } + // Additive enrichment for external verifiers (JSON path only — the + // binary lookup wire format is untouched). + if ls := node.GetLastSeen(); !ls.IsZero() { + resp["last_seen_unix"] = ls.Unix() + } else { + resp["last_seen_unix"] = int64(0) + } + resp["key_generation"] = node.KeyMeta.RotateCount if node.Hostname != "" { resp["hostname"] = node.Hostname } diff --git a/server.go b/server.go index eb151e6..bdc0fca 100644 --- a/server.go +++ b/server.go @@ -3,6 +3,7 @@ package server import ( + "crypto/ed25519" "sync" "sync/atomic" "time" @@ -232,6 +233,15 @@ type Server struct { // Clock (overridable for testing) now func() time.Time + // Verdict signing key for the external verification endpoint + // (POST /api/v1/verify). Lazily initialized on first use via + // verdictKeyOnce; persisted as verdict-key.json next to the registry + // snapshot when persistence is configured, ephemeral otherwise. + // Immutable after initialization — read without holding s.mu. + verdictKeyOnce sync.Once + verdictKid string + verdictPriv ed25519.PrivateKey + // staleNodeThreshold controls how long since last heartbeat a node is // considered online for dashboard / reap purposes. Defaults to // defaultStaleNodeThreshold; settable via SetStaleNodeThreshold or the diff --git a/server_lifecycle.go b/server_lifecycle.go index ba9bd36..d61eb6a 100644 --- a/server_lifecycle.go +++ b/server_lifecycle.go @@ -3,6 +3,7 @@ package server import ( + "encoding/base64" "encoding/json" "fmt" "io" @@ -1090,6 +1091,20 @@ func NewWithStore(beaconAddr, storePath string) *Server { BreakerSet: s.BreakerSet, BreakerDelete: s.BreakerDelete, HealthSnapshot: s.HealthSnapshot, + VerifyRequest: func(canonical, sigB64 string) interface{} { + return s.VerifyRequest(canonical, sigB64) + }, + VerifyKeys: func() []map[string]string { + pub := s.VerdictPublicKey() + if len(pub) == 0 { + return nil + } + return []map[string]string{{ + "kid": s.VerdictKid(), + "algo": "ed25519", + "public_key": base64.StdEncoding.EncodeToString(pub), + }} + }, ReplicationStatus: replSnap, NetworksList: s.AdminListNetworks, MembersList: s.AdminListMembers, diff --git a/server_verify.go b/server_verify.go new file mode 100644 index 0000000..b9c07f2 --- /dev/null +++ b/server_verify.go @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package server + +import ( + "crypto/ed25519" + "log/slog" + "time" + + "github.com/pilot-protocol/common/protocol" + "github.com/pilot-protocol/common/reqsig" +) + +// verifyOnlineWindow is how recently a node must have heartbeated for the +// verification endpoint to report it online: three missed 60s heartbeats. +// Deliberately NOT StaleNodeThreshold (default 30 min) — that threshold +// answers "should the reaper delete this node", not "is this node +// responsive right now". +const verifyOnlineWindow = 180 * time.Second + +// VerifyResponse is the JSON payload returned by POST /api/v1/verify. +// On any failure (parse, freshness, lookup, key expiry, signature) the +// response is UNIFORM: valid=false with every other field zeroed except the +// signed negative verdict — no distinction between unknown-node, reaped, +// bad-signature, and expired-key, so the endpoint is not an existence oracle. +type VerifyResponse struct { + Valid bool `json:"valid"` + Online bool `json:"online"` + NetworkMember bool `json:"network_member"` + Address string `json:"address,omitempty"` + LastSeen string `json:"last_seen,omitempty"` + Nonce string `json:"nonce,omitempty"` + LastSeenUnix int64 `json:"last_seen_unix"` + KeyGeneration int64 `json:"key_generation"` + StaleThresholdSecs int64 `json:"stale_threshold_secs"` + Verdict string `json:"verdict,omitempty"` + VerdictSig string `json:"verdict_sig,omitempty"` + VerdictKid string `json:"verdict_kid,omitempty"` +} + +// VerifyRequest verifies an external request-signature envelope (reqsig +// canonical form) plus its base64 Ed25519 signature against the registry's +// node table and returns a registry-signed verdict. Safe for concurrent use. +func (s *Server) VerifyRequest(canonical, sigB64 string) VerifyResponse { + now := s.now() + s.metrics.RequestsTotal.WithLabel("verify").Inc() + + // fail returns the uniform valid:false response. The failure kind is + // tracked only in the labeled error counter (pilot_errors_total) — + // nothing in the response body distinguishes the cases. + fail := func(kind string, network uint16, node uint32) VerifyResponse { + s.metrics.ErrorsTotal.WithLabel("verify_" + kind).Inc() + resp := VerifyResponse{} + s.signVerdict(&resp, reqsig.Verdict{ + EnvHash: reqsig.HashEnvelope(canonical), + Network: network, + Node: node, + VerifiedAt: now.Unix(), + }) + return resp + } + + e, err := reqsig.Parse(canonical) + if err != nil { + return fail("parse", 0, 0) + } + if err := reqsig.CheckFresh(e, now, 0); err != nil { + return fail("stale_envelope", e.Network, e.Node) + } + + // Phase 1: snapshot node fields under the read lock. No signature + // verification while holding s.mu — see the lock-ordering invariants + // in server.go. + s.mu.RLock() + node, ok := s.nodes[e.Node] + var pubKey []byte + var networks []uint16 + var keyExpiresAt time.Time + var rotateCount int + if ok { + pubKey = append([]byte(nil), node.PublicKey...) + networks = append([]uint16(nil), node.Networks...) + keyExpiresAt = node.KeyMeta.ExpiresAt + rotateCount = node.KeyMeta.RotateCount + } + s.mu.RUnlock() + + if !ok { + return fail("unknown_node", e.Network, e.Node) + } + // Expired keys block heartbeats (see directory.HandleHeartbeat) — + // mirror that here: a signature from an expired key proves nothing. + if !keyExpiresAt.IsZero() && keyExpiresAt.Before(now) { + return fail("expired_key", e.Network, e.Node) + } + if len(pubKey) != ed25519.PublicKeySize { + return fail("bad_node_key", e.Network, e.Node) + } + + // Phase 2: Ed25519 verification outside every lock. + if _, err := reqsig.Verify(ed25519.PublicKey(pubKey), canonical, sigB64); err != nil { + return fail("bad_signature", e.Network, e.Node) + } + + lastSeen := node.GetLastSeen() // atomic accessor — safe without locks + online := !lastSeen.IsZero() && now.Sub(lastSeen) <= verifyOnlineWindow + member := false + for _, netID := range networks { + if netID == e.Network { + member = true + break + } + } + var lastSeenUnix int64 + if !lastSeen.IsZero() && lastSeen.Unix() > 0 { + lastSeenUnix = lastSeen.Unix() + } + + resp := VerifyResponse{ + Valid: true, + Online: online, + NetworkMember: member, + Address: protocol.Addr{Network: e.Network, Node: e.Node}.String(), + Nonce: e.Nonce, + LastSeenUnix: lastSeenUnix, + KeyGeneration: int64(rotateCount), + StaleThresholdSecs: int64(s.StaleNodeThreshold() / time.Second), + } + if lastSeenUnix > 0 { + resp.LastSeen = lastSeen.UTC().Format(time.RFC3339) + } + s.signVerdict(&resp, reqsig.Verdict{ + EnvHash: reqsig.HashEnvelope(canonical), + Network: e.Network, + Node: e.Node, + Valid: true, + Online: online, + NetworkMember: member, + LastSeenUnix: lastSeenUnix, + KeyGeneration: int64(rotateCount), + VerifiedAt: now.Unix(), + }) + return resp +} + +// signVerdict signs v with the verdict key and fills the verdict fields on +// resp. A signing failure leaves the fields empty — the boolean answer still +// stands, callers just cannot forward it as offline proof. +func (s *Server) signVerdict(resp *VerifyResponse, v reqsig.Verdict) { + s.initVerdictKey() + if s.verdictPriv == nil { + return + } + canon, sig, err := reqsig.SignVerdict(s.verdictPriv, v) + if err != nil { + slog.Warn("verdict signing failed", "err", err) + return + } + resp.Verdict = canon + resp.VerdictSig = sig + resp.VerdictKid = s.verdictKid +} diff --git a/verify_key.go b/verify_key.go new file mode 100644 index 0000000..3f3cb01 --- /dev/null +++ b/verify_key.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package server + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" +) + +// verdictKeyKid is the key ID published on GET /api/v1/verify/keys and echoed +// as verdict_kid in every verification response. Bump when the verdict key +// format or rotation policy changes. +const verdictKeyKid = "vfy-v1" + +// verdictKeyFileName is the on-disk file holding the verdict signing key. +// It lives next to the registry snapshot (same directory as registry.json). +const verdictKeyFileName = "verdict-key.json" + +// verdictKeyFile is the JSON shape persisted at verdictKeyFileName. The +// private key is the full 64-byte Ed25519 private key (seed + public half), +// base64 std encoding. +type verdictKeyFile struct { + Kid string `json:"kid"` + PrivateKey string `json:"private_key"` +} + +// verdictKeyPath returns the on-disk path for the verdict key, or "" when +// the server runs without a persistence path. +func (s *Server) verdictKeyPath() string { + if s.storePath == "" { + return "" + } + return filepath.Join(filepath.Dir(s.storePath), verdictKeyFileName) +} + +// initVerdictKey lazily loads or generates the verdict signing keypair. +// Without a persistence path the key is ephemeral (in-memory only); +// otherwise it is loaded from — or generated and persisted to — +// verdict-key.json next to the registry snapshot. The private key is +// NEVER logged; error values carry paths only, no key material. +func (s *Server) initVerdictKey() { + s.verdictKeyOnce.Do(func() { + kid, priv, err := loadOrCreateVerdictKey(s.verdictKeyPath()) + if err != nil { + // A persistence failure must not take the verification + // endpoint down: fall back to an ephemeral key and surface + // the error. Verdicts stay verifiable via /api/v1/verify/keys, + // which always serves the live public key. + slog.Warn("verdict key persistence failed; using ephemeral key", "err", err) + _, ephemeral, genErr := ed25519.GenerateKey(rand.Reader) + if genErr != nil { + slog.Error("verdict key generation failed; verdict signing disabled", "err", genErr) + return + } + kid, priv = verdictKeyKid, ephemeral + } + s.verdictKid = kid + s.verdictPriv = priv + }) +} + +// VerdictKid returns the key ID of the verdict signing key. +func (s *Server) VerdictKid() string { + s.initVerdictKey() + return s.verdictKid +} + +// VerdictPublicKey returns the public half of the verdict signing key, or +// nil when verdict signing is unavailable. +func (s *Server) VerdictPublicKey() ed25519.PublicKey { + s.initVerdictKey() + if s.verdictPriv == nil { + return nil + } + return s.verdictPriv.Public().(ed25519.PublicKey) +} + +// loadOrCreateVerdictKey loads the verdict keypair from path, generating and +// persisting a fresh one (mode 0600, atomic tmp+rename) when the file does +// not exist yet. path == "" generates an in-memory ephemeral key. +func loadOrCreateVerdictKey(path string) (kid string, priv ed25519.PrivateKey, err error) { + if path == "" { + _, priv, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", nil, fmt.Errorf("verdict key generate: %w", err) + } + return verdictKeyKid, priv, nil + } + + data, readErr := os.ReadFile(path) + if readErr == nil { + var f verdictKeyFile + if err := json.Unmarshal(data, &f); err != nil { + return "", nil, fmt.Errorf("verdict key %s: %w", path, err) + } + raw, err := base64.StdEncoding.DecodeString(f.PrivateKey) + if err != nil { + return "", nil, fmt.Errorf("verdict key %s: private_key not base64: %w", path, err) + } + if len(raw) != ed25519.PrivateKeySize { + return "", nil, fmt.Errorf("verdict key %s: bad private key length %d", path, len(raw)) + } + if f.Kid == "" { + f.Kid = verdictKeyKid + } + return f.Kid, ed25519.PrivateKey(raw), nil + } + if !os.IsNotExist(readErr) { + return "", nil, fmt.Errorf("verdict key %s: %w", path, readErr) + } + + _, priv, err = ed25519.GenerateKey(rand.Reader) + if err != nil { + return "", nil, fmt.Errorf("verdict key generate: %w", err) + } + blob, err := json.Marshal(verdictKeyFile{ + Kid: verdictKeyKid, + PrivateKey: base64.StdEncoding.EncodeToString(priv), + }) + if err != nil { + return "", nil, fmt.Errorf("verdict key marshal: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, blob, 0600); err != nil { + return "", nil, fmt.Errorf("verdict key write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return "", nil, fmt.Errorf("verdict key rename %s: %w", path, err) + } + return verdictKeyKid, priv, nil +} diff --git a/zz_verify_key_test.go b/zz_verify_key_test.go new file mode 100644 index 0000000..27b861b --- /dev/null +++ b/zz_verify_key_test.go @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package server + +import ( + "bytes" + "crypto/ed25519" + "os" + "path/filepath" + "testing" +) + +// TestVerdictKeyLoadOrGenerateRoundTrip: first call generates + persists, +// second call loads the identical key; the file is mode 0600 and no .tmp +// residue is left behind. +func TestVerdictKeyLoadOrGenerateRoundTrip(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "verdict-key.json") + + kid1, priv1, err := loadOrCreateVerdictKey(path) + if err != nil { + t.Fatalf("generate: %v", err) + } + if kid1 != verdictKeyKid { + t.Fatalf("kid = %q, want %q", kid1, verdictKeyKid) + } + if len(priv1) != ed25519.PrivateKeySize { + t.Fatalf("private key length = %d", len(priv1)) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("key file mode = %o, want 0600", perm) + } + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatalf("tmp file left behind after atomic write") + } + + kid2, priv2, err := loadOrCreateVerdictKey(path) + if err != nil { + t.Fatalf("load: %v", err) + } + if kid2 != kid1 || !bytes.Equal(priv1, priv2) { + t.Fatalf("load did not round-trip the generated key") + } +} + +// TestVerdictKeyCorruptFileFailsLoad: a corrupt key file must surface an +// error (the server then falls back to an ephemeral key) rather than +// silently regenerating over it. +func TestVerdictKeyCorruptFileFailsLoad(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "verdict-key.json") + if err := os.WriteFile(path, []byte("{not json"), 0600); err != nil { + t.Fatalf("write: %v", err) + } + if _, _, err := loadOrCreateVerdictKey(path); err == nil { + t.Fatalf("corrupt key file should error") + } +} + +// TestVerdictKeyEphemeralWithoutStore: with no persistence path configured +// the server still exposes a working verdict key (in-memory only). +func TestVerdictKeyEphemeralWithoutStore(t *testing.T) { + t.Parallel() + s := newTestServer(t, "") + pub := s.VerdictPublicKey() + if len(pub) != ed25519.PublicKeySize { + t.Fatalf("ephemeral verdict public key length = %d", len(pub)) + } + if s.VerdictKid() != verdictKeyKid { + t.Fatalf("kid = %q, want %q", s.VerdictKid(), verdictKeyKid) + } +} + +// TestVerdictKeyPersistsNextToStore: with persistence configured the key +// lands in verdict-key.json beside the registry snapshot and survives a +// server restart with the same public key. +func TestVerdictKeyPersistsNextToStore(t *testing.T) { + t.Parallel() + dir := t.TempDir() + storePath := filepath.Join(dir, "registry.json") + + s1 := NewWithStore("", storePath) + pub1 := append(ed25519.PublicKey(nil), s1.VerdictPublicKey()...) + if err := s1.Close(); err != nil { + t.Fatalf("close s1: %v", err) + } + if len(pub1) != ed25519.PublicKeySize { + t.Fatalf("verdict public key length = %d", len(pub1)) + } + + keyPath := filepath.Join(dir, "verdict-key.json") + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("verdict key not persisted next to store: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("key file mode = %o, want 0600", perm) + } + + s2 := NewWithStore("", storePath) + t.Cleanup(func() { _ = s2.Close() }) + if !bytes.Equal(pub1, s2.VerdictPublicKey()) { + t.Fatalf("verdict key changed across restart") + } +} diff --git a/zz_verify_test.go b/zz_verify_test.go new file mode 100644 index 0000000..1df4fad --- /dev/null +++ b/zz_verify_test.go @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package server + +import ( + "testing" + "time" + + "github.com/pilot-protocol/common/crypto" + "github.com/pilot-protocol/common/reqsig" +) + +// seedVerifyNode registers a node with a real keypair plus the network +// memberships the verification tests exercise. +func seedVerifyNode(t *testing.T, s *Server, nodeID uint32, owner string, networks []uint16) *crypto.Identity { + t.Helper() + id, _ := seedNodeWithIdentity(t, s, nodeID, owner) + s.mu.Lock() + s.nodes[nodeID].Networks = append([]uint16(nil), networks...) + s.mu.Unlock() + return id +} + +// signedEnvelope builds a canonical envelope for (network, node) at ts and +// signs it with id. +func signedEnvelope(t *testing.T, id *crypto.Identity, network uint16, node uint32, ts int64) (canonical, sigB64 string) { + t.Helper() + nonce, err := reqsig.NewNonce() + if err != nil { + t.Fatalf("nonce: %v", err) + } + e := reqsig.Envelope{ + Network: network, + Node: node, + Timestamp: ts, + Nonce: nonce, + BodyHash: reqsig.HashBody([]byte("test-body")), + Audience: "test.consumer", + } + canonical, sigB64, err = reqsig.Sign(id.PrivateKey, e) + if err != nil { + t.Fatalf("sign envelope: %v", err) + } + return canonical, sigB64 +} + +// assertUniformFailure checks the no-existence-oracle contract: valid:false +// with every non-verdict field zeroed, plus a signed NEGATIVE verdict that +// verifies against the server's verdict key. +func assertUniformFailure(t *testing.T, s *Server, resp VerifyResponse, label string) { + t.Helper() + if resp.Valid || resp.Online || resp.NetworkMember { + t.Fatalf("%s: flags not all false: %+v", label, resp) + } + if resp.Address != "" || resp.LastSeen != "" || resp.Nonce != "" { + t.Fatalf("%s: string fields not zeroed: %+v", label, resp) + } + if resp.LastSeenUnix != 0 || resp.KeyGeneration != 0 || resp.StaleThresholdSecs != 0 { + t.Fatalf("%s: numeric fields not zeroed: %+v", label, resp) + } + if resp.Verdict == "" || resp.VerdictSig == "" || resp.VerdictKid == "" { + t.Fatalf("%s: failure must still carry a signed negative verdict: %+v", label, resp) + } + v, err := reqsig.VerifyVerdictWithKey(s.VerdictPublicKey(), resp.Verdict, resp.VerdictSig) + if err != nil { + t.Fatalf("%s: negative verdict does not verify: %v", label, err) + } + if v.Valid || v.Online || v.NetworkMember { + t.Fatalf("%s: negative verdict flags not all false: %+v", label, v) + } + if v.LastSeenUnix != 0 || v.KeyGeneration != 0 { + t.Fatalf("%s: negative verdict leaks node state: %+v", label, v) + } +} + +// TestVerifyRequestValid locks down the happy path: correct signature from a +// registered, recently-seen node that is a member of the envelope's network. +func TestVerifyRequestValid(t *testing.T) { + t.Parallel() + s := newTestServer(t, "") + id := seedVerifyNode(t, s, 100, "alice", []uint16{0, 7}) + + s.mu.Lock() + s.nodes[100].KeyMeta.RotateCount = 3 + s.mu.Unlock() + + canonical, sigB64 := signedEnvelope(t, id, 7, 100, time.Now().Unix()) + resp := s.VerifyRequest(canonical, sigB64) + + if !resp.Valid { + t.Fatalf("valid = false, want true: %+v", resp) + } + if !resp.Online { + t.Fatalf("online = false for a just-seeded node: %+v", resp) + } + if !resp.NetworkMember { + t.Fatalf("network_member = false, node is in network 7: %+v", resp) + } + if resp.Address == "" { + t.Fatalf("address missing on valid response") + } + if resp.Nonce == "" { + t.Fatalf("nonce not echoed on valid response") + } + if resp.KeyGeneration != 3 { + t.Fatalf("key_generation = %d, want 3", resp.KeyGeneration) + } + if resp.LastSeenUnix == 0 || resp.LastSeen == "" { + t.Fatalf("last_seen fields missing: %+v", resp) + } + if want := int64(s.StaleNodeThreshold() / time.Second); resp.StaleThresholdSecs != want { + t.Fatalf("stale_threshold_secs = %d, want %d", resp.StaleThresholdSecs, want) + } + if resp.VerdictKid != s.VerdictKid() { + t.Fatalf("verdict_kid = %q, want %q", resp.VerdictKid, s.VerdictKid()) + } + + // The verdict must verify offline against the published key and bind + // the exact envelope by hash. + v, err := reqsig.VerifyVerdictWithKey(s.VerdictPublicKey(), resp.Verdict, resp.VerdictSig) + if err != nil { + t.Fatalf("verdict verify: %v", err) + } + if v.EnvHash != reqsig.HashEnvelope(canonical) { + t.Fatalf("verdict env hash mismatch") + } + if !v.Valid || !v.Online || !v.NetworkMember { + t.Fatalf("verdict flags = %+v, want all true", v) + } + if v.Network != 7 || v.Node != 100 { + t.Fatalf("verdict address = %d/%d, want 7/100", v.Network, v.Node) + } + if v.KeyGeneration != 3 { + t.Fatalf("verdict key generation = %d, want 3", v.KeyGeneration) + } + if v.LastSeenUnix != resp.LastSeenUnix { + t.Fatalf("verdict last_seen %d != response %d", v.LastSeenUnix, resp.LastSeenUnix) + } +} + +// TestVerifyRequestNonMember: a valid signature for a network the node does +// NOT belong to yields valid:true but network_member:false. +func TestVerifyRequestNonMember(t *testing.T) { + t.Parallel() + s := newTestServer(t, "") + id := seedVerifyNode(t, s, 110, "bob", []uint16{0, 7}) + + canonical, sigB64 := signedEnvelope(t, id, 9, 110, time.Now().Unix()) + resp := s.VerifyRequest(canonical, sigB64) + if !resp.Valid { + t.Fatalf("valid = false: %+v", resp) + } + if resp.NetworkMember { + t.Fatalf("network_member = true for non-member network 9") + } +} + +// TestVerifyRequestUniformFailures: wrong-key signature, unknown node, stale +// timestamp, expired key, and unparseable envelope must all produce the SAME +// uniform valid:false shape — no existence oracle. +func TestVerifyRequestUniformFailures(t *testing.T) { + t.Parallel() + s := newTestServer(t, "") + id := seedVerifyNode(t, s, 120, "carol", []uint16{0}) + now := time.Now().Unix() + + // Expired-key node. + idExpired := seedVerifyNode(t, s, 121, "dave", []uint16{0}) + s.mu.Lock() + s.nodes[121].KeyMeta.ExpiresAt = time.Now().Add(-time.Hour) + s.mu.Unlock() + + attacker, err := crypto.GenerateIdentity() + if err != nil { + t.Fatalf("gen attacker: %v", err) + } + + wrongSigCanon, wrongSig := signedEnvelope(t, attacker, 0, 120, now) + unknownCanon, unknownSig := signedEnvelope(t, id, 0, 9999, now) + staleCanon, staleSig := signedEnvelope(t, id, 0, 120, now-400) + expiredCanon, expiredSig := signedEnvelope(t, idExpired, 0, 121, now) + + cases := []struct { + label string + canonical string + sig string + }{ + {"wrong_key_signature", wrongSigCanon, wrongSig}, + {"unknown_node", unknownCanon, unknownSig}, + {"stale_timestamp", staleCanon, staleSig}, + {"expired_key", expiredCanon, expiredSig}, + {"garbage_envelope", "not-an-envelope", "bm90LWEtc2ln"}, + } + for _, tc := range cases { + resp := s.VerifyRequest(tc.canonical, tc.sig) + assertUniformFailure(t, s, resp, tc.label) + } +} + +// TestVerifyRequestOnlineWindow: online must flip to false once LastSeen is +// older than the 180s verifyOnlineWindow, while validity is unaffected — +// and it must NOT use the 30-minute StaleNodeThreshold. +func TestVerifyRequestOnlineWindow(t *testing.T) { + t.Parallel() + s := newTestServer(t, "") + id := seedVerifyNode(t, s, 130, "erin", []uint16{0}) + + s.mu.Lock() + s.nodes[130].SetLastSeen(time.Now().Add(-verifyOnlineWindow - time.Second)) + s.mu.Unlock() + + canonical, sigB64 := signedEnvelope(t, id, 0, 130, time.Now().Unix()) + resp := s.VerifyRequest(canonical, sigB64) + if !resp.Valid { + t.Fatalf("valid = false: %+v", resp) + } + if resp.Online { + t.Fatalf("online = true for node last seen %s ago", verifyOnlineWindow+time.Second) + } + if resp.LastSeenUnix == 0 { + t.Fatalf("last_seen_unix should still be reported for a valid node") + } + + v, err := reqsig.VerifyVerdictWithKey(s.VerdictPublicKey(), resp.Verdict, resp.VerdictSig) + if err != nil { + t.Fatalf("verdict verify: %v", err) + } + if v.Online { + t.Fatalf("verdict online = true, want false") + } +} From c2652ee11fe3c919c790b3baadaadbddd39c5ded Mon Sep 17 00:00:00 2001 From: Teodor Calin Date: Fri, 3 Jul 2026 23:47:20 +0300 Subject: [PATCH 2/2] Bump common to v0.5.7 for reqsig --- go.mod | 6 ++---- go.sum | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index fb43094..845b8e5 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,8 @@ module github.com/pilot-protocol/rendezvous -go 1.25.10 +go 1.25.11 -toolchain go1.25.11 - -require github.com/pilot-protocol/common v0.5.6 +require github.com/pilot-protocol/common v0.5.7 require ( github.com/coder/websocket v1.8.14 // indirect diff --git a/go.sum b/go.sum index 678f537..763dde3 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9 github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/pilot-protocol/beacon v0.2.6 h1:grxwaVyPRUT0W6coyjYfNkO0rpzOIrwrKn94S21DuVE= github.com/pilot-protocol/beacon v0.2.6/go.mod h1:I/UhEv097g1z/qtAVDZbEhf3R5tzM0Dp71vGHah52A4= -github.com/pilot-protocol/common v0.5.6 h1:0A3W3HUQSPryGyh+zA89hEHHLcYVd9v9Vgk8EzOqpQI= -github.com/pilot-protocol/common v0.5.6/go.mod h1:yrAwPXGVMbXU+SADvOCmbdXjK/wJ3uA0KshyLvRlej4= +github.com/pilot-protocol/common v0.5.7 h1:GY6KhB+PwV713HAAhVrVEZJN9e5DsnbfElPiOAEpzJE= +github.com/pilot-protocol/common v0.5.7/go.mod h1:Y6yaOsywr8J4aGvyoyuOtDpwZTheFM7GOjDt6Y/ZB9I= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=