diff --git a/cmd/node/main.go b/cmd/node/main.go index 83fe3d2..f6d9afc 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -920,6 +920,13 @@ func main() { ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) { return chain.ContactRequests(pubKey) }, + ResolveX25519: func(ed25519PubHex string) string { + info, err := chain.Identity(ed25519PubHex) + if err != nil || info == nil { + return "" + } + return info.X25519PubKey + }, } go func() { diff --git a/node/api_relay.go b/node/api_relay.go index eb3ce53..687d66f 100644 --- a/node/api_relay.go +++ b/node/api_relay.go @@ -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= +// relayInboxDelete handles DELETE /relay/inbox/{envelopeID}?pub= +// +// Auth model: +// Query: ?pub= +// Body: {"ed25519_pub":"", "sig":"", "ts":} +// 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