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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user