fix(relay): require signed Ed25519 auth on DELETE /relay/inbox/{id}

Previously the endpoint accepted an unauthenticated DELETE with just
?pub=X — anyone who knew (or enumerated) a pub could wipe that pub's
entire inbox, a trivial griefing vector. Now the handler requires a
JSON body with {ed25519_pub, sig, ts} where sig signs
"inbox-delete:<envID>:<pub>:<ts>" under the Ed25519 privkey. The
server then looks up the identity on-chain and verifies that the
registered X25519 public key matches the ?pub= query — closing the
gap between "I can sign" and "my identity owns this mailbox."

Timestamp window: ±300s to prevent replay of captured DELETEs.

Wires RelayConfig.ResolveX25519 via chain.Identity() in cmd/node/main.go.
When ResolveX25519 is nil the endpoint returns 503 (feature unavailable)
rather than silently allowing anonymous deletes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
vsecoder
2026-04-18 17:57:24 +03:00
parent 15d0ed306b
commit f2cb5586ca
2 changed files with 87 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ package node
import (
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
@@ -9,6 +10,7 @@ import (
"time"
"go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/relay"
)
@@ -26,6 +28,12 @@ type RelayConfig struct {
// ContactRequests returns incoming contact records for the given Ed25519 pubkey.
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error)
// ResolveX25519 returns the X25519 hex published by the Ed25519 identity,
// or "" if the identity has not registered or does not exist. Used by
// authenticated mutating endpoints (e.g. DELETE /relay/inbox) to link a
// signing key back to its mailbox pubkey. nil disables those endpoints.
ResolveX25519 func(ed25519PubHex string) string
}
// registerRelayRoutes wires relay mailbox endpoints onto mux.
@@ -116,8 +124,24 @@ func relayInboxList(rc RelayConfig) http.HandlerFunc {
}
}
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<hex>
// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub=<x25519hex>
//
// Auth model:
// Query: ?pub=<x25519hex>
// Body: {"ed25519_pub":"<hex>", "sig":"<base64>", "ts":<unix_seconds>}
// sig = Ed25519(privEd25519,
// "inbox-delete:" + envID + ":" + x25519Pub + ":" + ts)
// ts must be within ±5 minutes of server clock (anti-replay).
//
// Server then:
// 1. Verifies sig over the canonical bytes above.
// 2. Looks up identity(ed25519_pub).X25519Pub — must equal the ?pub= query.
//
// This links the signing key to the mailbox key without exposing the user's
// X25519 private material.
func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
const inboxDeleteSkewSecs = 300 // ±5 minutes
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
@@ -140,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
return
}
// Auth. Unauthenticated DELETE historically let anyone wipe any
// mailbox by just knowing the pub — fixed in v1.0.2 via signed
// Ed25519 identity linked to the x25519 via identity registry.
if rc.ResolveX25519 == nil {
jsonErr(w, fmt.Errorf("mailbox delete not available on this node"), 503)
return
}
var body struct {
Ed25519Pub string `json:"ed25519_pub"`
Sig string `json:"sig"`
Ts int64 `json:"ts"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonErr(w, fmt.Errorf("invalid JSON body: %w", err), 400)
return
}
if body.Ed25519Pub == "" || body.Sig == "" || body.Ts == 0 {
jsonErr(w, fmt.Errorf("ed25519_pub, sig, ts are required"), 400)
return
}
now := time.Now().Unix()
if body.Ts < now-inboxDeleteSkewSecs || body.Ts > now+inboxDeleteSkewSecs {
jsonErr(w, fmt.Errorf("timestamp out of range (±%ds)", inboxDeleteSkewSecs), 400)
return
}
sigBytes, err := base64.StdEncoding.DecodeString(body.Sig)
if err != nil {
// Also try URL-safe for defensive UX.
sigBytes, err = base64.RawURLEncoding.DecodeString(body.Sig)
if err != nil {
jsonErr(w, fmt.Errorf("sig: invalid base64"), 400)
return
}
}
if _, err := hex.DecodeString(body.Ed25519Pub); err != nil {
jsonErr(w, fmt.Errorf("ed25519_pub: invalid hex"), 400)
return
}
msg := []byte(fmt.Sprintf("inbox-delete:%s:%s:%d", envID, pub, body.Ts))
ok, err := identity.Verify(body.Ed25519Pub, msg, sigBytes)
if err != nil || !ok {
jsonErr(w, fmt.Errorf("signature invalid"), 403)
return
}
// Link ed25519 → x25519 via identity registry.
registeredX := rc.ResolveX25519(body.Ed25519Pub)
if registeredX == "" {
jsonErr(w, fmt.Errorf("identity has no registered X25519 key"), 403)
return
}
if !strings.EqualFold(registeredX, pub) {
jsonErr(w, fmt.Errorf("pub does not match identity's registered X25519"), 403)
return
}
if err := rc.Mailbox.Delete(pub, envID); err != nil {
jsonErr(w, err, 500)
return