5 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
vsecoder
78d97281f0 fix(relay): canonicalise envelope ID and timestamp on mailbox.Store
The mailbox previously trusted the client-supplied envelope ID and SentAt,
which enabled two attacks:
  - replay via re-broadcast: a malicious relay could resubmit the same
    ciphertext under multiple IDs, causing the recipient to receive the
    same plaintext repeatedly;
  - timestamp spoofing: senders could back-date or future-date messages
    to bypass the 7-day TTL or fake chronology.

Store() now recomputes env.ID as hex(sha256(nonce||ct)[:16]) and
overwrites env.SentAt with time.Now().Unix(). Both values are mutated
on the envelope pointer so downstream gossipsub publishes agree on the
normalised form.

Also documents /relay/send as non-E2E — the endpoint seals with the
relay's own key, which breaks end-to-end authenticity. Clients wanting
real E2E should POST /relay/broadcast with a pre-sealed envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:41:22 +03:00
5 changed files with 177 additions and 25 deletions

View File

@@ -47,6 +47,7 @@ const (
prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON prefixPayChan = "paychan:" // paychan:<channelID> → PayChanState JSON
prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON prefixRelay = "relay:" // relay:<node_pubkey> → RegisterRelayPayload JSON
prefixRelayHB = "relayhb:" // relayhb:<node_pubkey> → unix seconds (int64) of last HB 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 prefixContactIn = "contact_in:" // contact_in:<targetPub>:<requesterPub> → contactRecord JSON
prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active) prefixValidator = "validator:" // validator:<pubkey> → "" (presence = active)
prefixContract = "contract:" // contract:<contractID> → ContractRecord JSON 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 { if err := json.Unmarshal(tx.Payload, &p); err != nil {
return 0, fmt.Errorf("%w: RELAY_PROOF bad payload: %v", ErrTxFailed, err) 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 { if p.SenderPubKey == "" || p.FeeUT == 0 || len(p.FeeSig) == 0 {
return 0, fmt.Errorf("%w: relay proof missing fee authorization fields", ErrTxFailed) 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) authBytes := FeeAuthBytes(p.EnvelopeID, p.FeeUT)
ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig) ok, err := verifyEd25519(p.SenderPubKey, authBytes, p.FeeSig)
if err != nil || !ok { if err != nil || !ok {
@@ -818,6 +831,10 @@ func (c *Chain) applyTx(txn *badger.Txn, tx *Transaction) (uint64, error) {
}); err != nil { }); err != nil {
return 0, err 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: case EventBindWallet:
var p BindWalletPayload 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", return 0, fmt.Errorf("%w: CONTACT_REQUEST: amount %d < MinContactFee %d",
ErrTxFailed, tx.Amount, MinContactFee) 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 { if err := c.debitBalance(txn, tx.From, tx.Amount+tx.Fee); err != nil {
return 0, fmt.Errorf("CONTACT_REQUEST debit: %w", err) 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(), CreatedAt: tx.Timestamp.Unix(),
} }
val, _ := json.Marshal(rec) val, _ := json.Marshal(rec)
key := prefixContactIn + tx.To + ":" + tx.From
if err := txn.Set([]byte(key), val); err != nil { if err := txn.Set([]byte(key), val); err != nil {
return 0, fmt.Errorf("store contact record: %w", err) return 0, fmt.Errorf("store contact record: %w", err)
} }

View File

@@ -920,6 +920,13 @@ func main() {
ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) { ContactRequests: func(pubKey string) ([]blockchain.ContactInfo, error) {
return chain.ContactRequests(pubKey) 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() { go func() {

View File

@@ -2,6 +2,7 @@ package node
import ( import (
"encoding/base64" "encoding/base64"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
@@ -9,6 +10,7 @@ import (
"time" "time"
"go-blockchain/blockchain" "go-blockchain/blockchain"
"go-blockchain/identity"
"go-blockchain/relay" "go-blockchain/relay"
) )
@@ -26,6 +28,12 @@ type RelayConfig struct {
// ContactRequests returns incoming contact records for the given Ed25519 pubkey. // ContactRequests returns incoming contact records for the given Ed25519 pubkey.
ContactRequests func(pubKey string) ([]blockchain.ContactInfo, error) 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. // registerRelayRoutes wires relay mailbox endpoints onto mux.
@@ -37,12 +45,19 @@ type RelayConfig struct {
// DELETE /relay/inbox/{envID} ?pub=<x25519hex> // DELETE /relay/inbox/{envID} ?pub=<x25519hex>
// GET /relay/contacts ?pub=<ed25519hex> // GET /relay/contacts ?pub=<ed25519hex>
func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) { func registerRelayRoutes(mux *http.ServeMux, rc RelayConfig) {
mux.HandleFunc("/relay/send", relaySend(rc)) // Writes go through withSubmitTxGuards: per-IP rate limit (10/s, burst 20)
mux.HandleFunc("/relay/broadcast", relayBroadcast(rc)) // + 256 KiB body cap. Without these, a single attacker could spam
mux.HandleFunc("/relay/inbox/count", relayInboxCount(rc)) // 500 envelopes per victim in a few seconds and evict every real message
mux.HandleFunc("/relay/inbox/", relayInboxDelete(rc)) // via the mailbox FIFO cap.
mux.HandleFunc("/relay/inbox", relayInboxList(rc)) mux.HandleFunc("/relay/send", withSubmitTxGuards(relaySend(rc)))
mux.HandleFunc("/relay/contacts", relayContacts(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] // 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 { func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
const inboxDeleteSkewSecs = 300 // ±5 minutes
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete { if r.Method != http.MethodDelete {
// Also serve GET /relay/inbox/{id} for convenience (fetch single envelope) // Also serve GET /relay/inbox/{id} for convenience (fetch single envelope)
@@ -133,6 +164,61 @@ func relayInboxDelete(rc RelayConfig) http.HandlerFunc {
return 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 { if err := rc.Mailbox.Delete(pub, envID); err != nil {
jsonErr(w, err, 500) jsonErr(w, err, 500)
return return
@@ -167,9 +253,16 @@ func relayInboxCount(rc RelayConfig) http.HandlerFunc {
// "msg_b64": "<base64-encoded plaintext>", // "msg_b64": "<base64-encoded plaintext>",
// } // }
// //
// The relay node seals the message using its own X25519 keypair and broadcasts // WARNING — NOT END-TO-END ENCRYPTED.
// it on the relay gossipsub topic. No on-chain fee is attached — delivery is // The relay node seals the message using its OWN X25519 keypair, not the
// free for light clients using this endpoint. // 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 { func relaySend(rc RelayConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {

View File

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

View File

@@ -100,13 +100,31 @@ func (m *Mailbox) Close() error { return m.db.Close() }
// //
// Anti-spam checks (in order): // Anti-spam checks (in order):
// 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge. // 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge.
// 2. Duplicate envelope ID → silently overwritten (idempotent). // 2. env.ID is recomputed to the canonical value hex(sha256(nonce||ct)[:16])
// 3. Recipient already has MailboxPerRecipientCap entries → oldest evicted first. // — prevents a malicious relay from storing the same ciphertext under
// multiple IDs (real content-level replay protection).
// 3. env.SentAt is overwritten with server time — senders can't back-date
// or future-date messages to bypass ordering or TTL expiry.
// 4. Duplicate envelope ID → silently no-op (idempotent).
// 5. Recipient already has MailboxPerRecipientCap entries → oldest evicted first.
//
// NOTE: Store MUTATES env.ID and env.SentAt to the canonical / server values.
// Callers that re-broadcast (gossipsub publish) after Store will see the
// normalised envelope, which is desirable — peer nodes then agree on the
// same ID and timestamp.
func (m *Mailbox) Store(env *Envelope) error { func (m *Mailbox) Store(env *Envelope) error {
if len(env.Ciphertext) > MailboxMaxEnvelopeSize { if len(env.Ciphertext) > MailboxMaxEnvelopeSize {
return ErrEnvelopeTooLarge return ErrEnvelopeTooLarge
} }
// v1.0.1 — canonicalise id & timestamp. Any client-supplied values are
// replaced with server-computed truth. This is the simplest way to
// prevent:
// - replay-via-rebroadcast (same ciphertext under different IDs),
// - timestamp spoofing (bypass TTL / fake chronology).
env.ID = envelopeID(env.Nonce, env.Ciphertext)
env.SentAt = time.Now().Unix()
key := mailboxKey(env.RecipientPub, env.SentAt, env.ID) key := mailboxKey(env.RecipientPub, env.SentAt, env.ID)
val, err := json.Marshal(env) val, err := json.Marshal(env)
if err != nil { if err != nil {