diff --git a/node/api_relay.go b/node/api_relay.go index ac949d6..e7ac306 100644 --- a/node/api_relay.go +++ b/node/api_relay.go @@ -167,9 +167,16 @@ func relayInboxCount(rc RelayConfig) http.HandlerFunc { // "msg_b64": "", // } // -// The relay node seals the message using its own X25519 keypair and broadcasts -// it on the relay gossipsub topic. No on-chain fee is attached — delivery is -// free for light clients using this endpoint. +// 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 { diff --git a/relay/mailbox.go b/relay/mailbox.go index 755cd4e..88ba5f5 100644 --- a/relay/mailbox.go +++ b/relay/mailbox.go @@ -100,13 +100,31 @@ func (m *Mailbox) Close() error { return m.db.Close() } // // Anti-spam checks (in order): // 1. Ciphertext > MailboxMaxEnvelopeSize → returns ErrEnvelopeTooLarge. -// 2. Duplicate envelope ID → silently overwritten (idempotent). -// 3. Recipient already has MailboxPerRecipientCap entries → oldest evicted first. +// 2. env.ID is recomputed to the canonical value hex(sha256(nonce||ct)[:16]) +// — 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 { if len(env.Ciphertext) > MailboxMaxEnvelopeSize { 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) val, err := json.Marshal(env) if err != nil {