Compare commits
5 Commits
546d2c503f
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2cb5586ca | ||
|
|
15d0ed306b | ||
|
|
8082dd0bf7 | ||
|
|
32eec62ba4 | ||
|
|
78d97281f0 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
@@ -167,9 +253,16 @@ func relayInboxCount(rc RelayConfig) http.HandlerFunc {
|
||||
// "msg_b64": "<base64-encoded plaintext>",
|
||||
// }
|
||||
//
|
||||
// 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 {
|
||||
|
||||
29
node/ws.go
29
node/ws.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user