4 Commits

Author SHA1 Message Date
vsecoder
f2cb5586ca 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>
2026-04-18 17:57:24 +03:00
vsecoder
15d0ed306b fix(ws): hard-deny inbox:* / typing:* when authX is empty
The WS topic-auth check had a soft-fail fallback: if the authenticated
identity had no registered X25519 public key (authX == ""), the
topic-ownership check was skipped and the client could subscribe to
any inbox:* or typing:* topic. Exploit: register an Ed25519 identity
without an X25519 key, subscribe to the victim's inbox topic, receive
their envelope notifications.

Now both topics hard-require a registered X25519. Clients must call
REGISTER_KEY (publishing X25519) before subscribing. The scope is
narrow — only identities that haven't completed REGISTER_KEY yet could
have exploited this — but a hard fail is still correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:55:11 +03:00
vsecoder
8082dd0bf7 fix(node): rate-limit relay HTTP endpoints
Relay routes were not wrapped in any guards — /relay/broadcast accepted
unlimited writes from any IP, and /relay/inbox could be scraped at line
rate. Combined with the per-recipient FIFO eviction (MailboxPerRecipientCap=500),
an unauthenticated attacker could wipe a victim's real messages by
spamming 500 garbage envelopes. This commit wraps writes in
withSubmitTxGuards (10/s per IP + 256 KiB body cap) and reads in
withReadLimit (20/s per IP) — the same limits already used for
/api/tx and /api/address.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:54:08 +03:00
vsecoder
32eec62ba4 fix(chain): RELAY_PROOF dedup by envelopeID + sticky BlockContact
RELAY_PROOF previously had no per-envelope dedup — every relay that
saw the gossipsub re-broadcast could extract the sender's FeeSig from
the envelope and submit its own RELAY_PROOF claim with its own
RelayPubKey. The tx-ID uniqueness check didn't help because tx.ID =
sha256(relayPubKey||envelopeID)[:16], which is unique per (relay,
envelope) pair. A malicious mesh of N relays could drain N× the fee
from the sender's balance for a single message.

Fix: record prefixRelayProof:<envelopeID> on first successful apply
and reject subsequent claims for the same envelope.

CONTACT_REQUEST previously overwrote any prior record (including a
blocked one) back to pending, letting spammers unblock themselves by
paying another MinContactFee. Now the handler reads the existing
record first and rejects the tx with "recipient has blocked sender"
when prev.Status == ContactBlocked. Block becomes sticky.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:51:14 +03:00
4 changed files with 147 additions and 20 deletions

View File

@@ -47,6 +47,7 @@ const (
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB
prefixRelayProof = "relayproof:" // relayproof:<envelopeID> → claimant node_pubkey (1 claim per envelope)
prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON
@@ -795,9 +796,21 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err)
}
if p.EnvelopeID == "" {
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope_id is required", ErrTxFailed)
}
if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed)
}
// Per-envelope dedup — only one relay may claim the fee for a given
// envelope. Without this check, every relay that saw the gossipsub
// re-broadcast could extract the sender's FeeSig and submit its own
// RELAY_PROOF, draining the sender's balance by N× for one message.
proofKey := []byte(prefixRelayProof + p.EnvelopeID)
if _, err := txn.Get(proofKey); err == nil {
return 0, fmt.Errorf("%w: RELAY_PROOF: envelope %s already claimed",
ErrTxFailed, p.EnvelopeID)
}
authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok {
@@ -818,6 +831,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
}); err != nil {
return 0, err
}
// Mark envelope as claimed — prevents replay by other relays.
if err := txn.Set(proofKey, []byte(p.RelayPubKey)); err != nil {
return 0, fmt.Errorf("mark relay proof: %w", err)
}
case EventBindWallet:
var p BindWalletPayload
@@ -956,6 +973,19 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
ErrTxFailed, tx.Amount, MinContactFee)
}
// Sticky block — if recipient previously blocked this sender, refuse
// the new request instead of silently overwriting the blocked status
// back to pending. Prevents unblock-via-respam.
key := prefixContactIn + tx.To + ":" + tx.From
if item, err := txn.Get([]byte(key)); err == nil {
var prev contactRecord
if verr := item.Value(func(val []byte) error {
return json.Unmarshal(val, &prev)
}); verr == nil && prev.Status == string(ContactBlocked) {
return 0, fmt.Errorf("%w: CONTACT_REQUEST: recipient has blocked sender",
ErrTxFailed)
}
}
if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err)
}
@@ -970,7 +1000,6 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
CreatedAt: tx.Timestamp.Unix(),
}
val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store contact record: %w", err)
}

View File

@@ -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() {

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.
@@ -37,12 +45,19 @@ type RelayConfig struct {
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
mux.HandleFunc("/relay/send", relaySend(rc))
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc))
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc))
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc))
mux.HandleFunc("/relay/inbox", relayInboxList(rc))
mux.HandleFunc("/relay/contacts", relayContacts(rc))
// 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=<hex>[&since=<ts>][&limit=N]
@@ -109,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)
@@ -133,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

View File

@@ -521,13 +521,17 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" {
return fmt.Errorf("inbox:* requires auth")
}
// If we have an x25519 mapping, enforce it; otherwise accept
// (best-effort — identity may not be registered yet).
if authX != "" {
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
}
// Hard-require a registered X25519 identity — otherwise an
// Ed25519-only identity could subscribe to ANY inbox topic by
// design (authX == "" skipped the equality check). Fixed: we
// now refuse the subscription until the client publishes an
// X25519 key via REGISTER_KEY.
if authX == "" {
return fmt.Errorf("inbox:* requires a registered X25519 identity (send REGISTER_KEY first)")
}
want := strings.TrimPrefix(topic, "inbox:")
if want != authX {
return fmt.Errorf("inbox:* only for your own x25519")
}
return nil
}
@@ -536,11 +540,12 @@ func (h *WSHub) authorizeSubscribe(c *wsClient, topic string) error {
if authed == "" {
return fmt.Errorf("typing:* requires auth")
}
if authX != "" {
want := strings.TrimPrefix(topic, "typing:")
if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
}
if authX == "" {
return fmt.Errorf("typing:* requires a registered X25519 identity")
}
want := strings.TrimPrefix(topic, "typing:")
if want != authX {
return fmt.Errorf("typing:* only for your own x25519")
}
return nil
}