package node import ( "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "time" "go-blockchain/blockchain" "go-blockchain/identity" "go-blockchain/relay" ) // RelayConfig holds dependencies for the relay HTTP API. type RelayConfig struct { Mailbox *relay.Mailbox // Send seals a message for recipientX25519PubHex and broadcasts it. // Returns the envelope ID. nil disables POST /relay/send. Send func(recipientPubHex string, msg []byte) (string, error) // Broadcast publishes a pre-sealed Envelope on gossipsub and stores it in the mailbox. // nil disables POST /relay/broadcast. Broadcast func(env *relay.Envelope) error // 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. // // POST /relay/send {recipient_pub, msg_b64} // POST /relay/broadcast {envelope: } // GET /relay/inbox ?pub=[&since=][&limit=N] // GET /relay/inbox/count ?pub= // DELETE /relay/inbox/{envID} ?pub= // GET /relay/contacts ?pub= func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) { // Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20) // + 256 KiB body cap. Without these, a single attacker could spam // 500 envelopes per victim in a few seconds and evict every real message // via the mailbox FIFO cap. mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc))) mux.HandleFunc("/relay/broadcast", withSubmitTxGuards(relayBroadcast(rc))) // Reads go through withReadLimit: per-IP rate limit (20/s, burst 40). // Protects against inbox-scraping floods from a single origin. mux.HandleFunc("/relay/inbox/count", withReadLimit(relayInboxCount(rc))) mux.HandleFunc("/relay/inbox/", withReadLimit(relayInboxDelete(rc))) mux.HandleFunc("/relay/inbox", withReadLimit(relayInboxList(rc))) mux.HandleFunc("/relay/contacts", withReadLimit(relayContacts(rc))) } // relayInboxList handles GET /relay/inbox?pub=[&since=][&limit=N] func relayInboxList(rc RelayConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, fmt.Errorf("method not allowed"), 405) return } pub := r.URL.Query().Get("pub") if pub == "" { jsonErr(w, fmt.Errorf("pub parameter required"), 400) return } since := int64(0) if s := r.URL.Query().Get("since"); s != "" { if v, err := parseInt64(s); err == nil && v > 0 { since = v } } limit := queryIntMin0(r, "limit") if limit == 0 { limit = 50 } envelopes, err := rc.Mailbox.List(pub, since, limit) if err != nil { jsonErr(w, err, 500) return } type item struct { ID string `json:"id"` SenderPub string `json:"sender_pub"` RecipientPub string `json:"recipient_pub"` FeeUT uint64 `json:"fee_ut,omitempty"` SentAt int64 `json:"sent_at"` SentAtHuman string `json:"sent_at_human"` Nonce []byte `json:"nonce"` Ciphertext []byte `json:"ciphertext"` } out := make([]item, 0, len(envelopes)) for _, env := range envelopes { out = append(out, item{ ID: env.ID, SenderPub: env.SenderPub, RecipientPub: env.RecipientPub, FeeUT: env.FeeUT, SentAt: env.SentAt, SentAtHuman: time.Unix(env.SentAt, 0).UTC().Format(time.RFC3339), Nonce: env.Nonce, Ciphertext: env.Ciphertext, }) } hasMore := len(out) == limit jsonOK(w, map[string]any{ "pub": pub, "count": len(out), "has_more": hasMore, "items": out, }) } } // 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) if r.Method == http.MethodGet { relayInboxList(rc)(w, r) return } jsonErr(w, fmt.Errorf("method not allowed"), 405) return } envID := strings.TrimPrefix(r.URL.Path, "/relay/inbox/") if envID == "" { jsonErr(w, fmt.Errorf("envelope ID required in path"), 400) return } pub := r.URL.Query().Get("pub") if pub == "" { jsonErr(w, fmt.Errorf("pub parameter required"), 400) 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 } jsonOK(w, map[string]string{"id": envID, "status": "deleted"}) } } // relayInboxCount handles GET /relay/inbox/count?pub= func relayInboxCount(rc RelayConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { pub := r.URL.Query().Get("pub") if pub == "" { jsonErr(w, fmt.Errorf("pub parameter required"), 400) return } count, err := rc.Mailbox.Count(pub) if err != nil { jsonErr(w, err, 500) return } jsonOK(w, map[string]any{"pub": pub, "count": count}) } } // relaySend handles POST /relay/send // // Request body: // // { // "recipient_pub": "", // "msg_b64": "", // } // // WARNING — NOT END-TO-END ENCRYPTED. // The relay node seals the message using its OWN X25519 keypair, not the // sender's. That means: // - The relay can read the plaintext (msg_b64 arrives in the clear). // - The recipient cannot authenticate the sender — they only see "a // message from the relay". // For real E2E messaging, clients should seal the envelope themselves and // use POST /relay/broadcast instead. This endpoint is retained only for // backwards compatibility with legacy integrations and for bootstrap // scenarios where the sender doesn't have a long-lived X25519 key yet. func relaySend(rc RelayConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, fmt.Errorf("method not allowed"), 405) return } if rc.Send == nil { jsonErr(w, fmt.Errorf("relay send not available on this node"), 503) return } var req struct { RecipientPub string `json:"recipient_pub"` MsgB64 string `json:"msg_b64"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) return } if req.RecipientPub == "" { jsonErr(w, fmt.Errorf("recipient_pub is required"), 400) return } if req.MsgB64 == "" { jsonErr(w, fmt.Errorf("msg_b64 is required"), 400) return } msg, err := decodeBase64(req.MsgB64) if err != nil { jsonErr(w, fmt.Errorf("msg_b64: %w", err), 400) return } if len(msg) == 0 { jsonErr(w, fmt.Errorf("msg_b64: empty message"), 400) return } envID, err := rc.Send(req.RecipientPub, msg) if err != nil { jsonErr(w, fmt.Errorf("send failed: %w", err), 500) return } jsonOK(w, map[string]string{ "id": envID, "recipient_pub": req.RecipientPub, "status": "sent", }) } } // decodeBase64 accepts both standard and URL-safe base64. func decodeBase64(s string) ([]byte, error) { // Try URL-safe first (no padding required), then standard. if b, err := base64.RawURLEncoding.DecodeString(s); err == nil { return b, nil } return base64.StdEncoding.DecodeString(s) } // relayBroadcast handles POST /relay/broadcast // // Request body: {"envelope": } // // Light clients use this to publish pre-sealed envelopes without a direct // libp2p connection. The relay node stores it in the mailbox and gossips it. func relayBroadcast(rc RelayConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { jsonErr(w, fmt.Errorf("method not allowed"), 405) return } if rc.Broadcast == nil { jsonErr(w, fmt.Errorf("relay broadcast not available on this node"), 503) return } var req struct { Envelope *relay.Envelope `json:"envelope"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { jsonErr(w, fmt.Errorf("invalid JSON: %w", err), 400) return } if req.Envelope == nil { jsonErr(w, fmt.Errorf("envelope is required"), 400) return } if req.Envelope.ID == "" { jsonErr(w, fmt.Errorf("envelope.id is required"), 400) return } if len(req.Envelope.Ciphertext) == 0 { jsonErr(w, fmt.Errorf("envelope.ciphertext is required"), 400) return } if err := rc.Broadcast(req.Envelope); err != nil { jsonErr(w, fmt.Errorf("broadcast failed: %w", err), 500) return } jsonOK(w, map[string]string{ "id": req.Envelope.ID, "status": "broadcast", }) } } // relayContacts handles GET /relay/contacts?pub= // // Returns all incoming contact requests for the given Ed25519 public key. func relayContacts(rc RelayConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { jsonErr(w, fmt.Errorf("method not allowed"), 405) return } if rc.ContactRequests == nil { jsonErr(w, fmt.Errorf("contacts not available on this node"), 503) return } pub := r.URL.Query().Get("pub") if pub == "" { jsonErr(w, fmt.Errorf("pub parameter required"), 400) return } contacts, err := rc.ContactRequests(pub) if err != nil { jsonErr(w, err, 500) return } jsonOK(w, map[string]any{ "pub": pub, "count": len(contacts), "contacts": contacts, }) } } // parseInt64 parses a string as int64. func parseInt64(s string) (int64, error) { var v int64 if err := json.Unmarshal([]byte(s), &v); err != nil { return 0, err } return v, nil }