Two coordinated changes:
1. Desktop client gets a functional Messages section and working pairing
flow, putting it at feature parity with mobile for the v2.2.0 line.
2. Server + both clients teach each other to use the sender's master
Ed25519 (not just their X25519) to address conversations, so a peer
writing from a different linked device still rolls into the same chat.
This is the "new API logic" the desktop scaffold was waiting on.
Server (node/api_relay.go, cmd/node/main.go):
* /relay/inbox items now carry `sender_ed25519_pub` alongside the
per-device `sender_pub`. Empty string for pre-v2.2.0 senders.
* WS `inbox` push summary also includes `sender_ed25519_pub`, so the
client can skip the refetch when the envelope plainly isn't for
the chat they're watching.
* Both existing tests pass.
Mobile client:
* lib/types.ts Envelope grew `sender_ed25519_pub`; fetchInbox normalises
it (default '') for older nodes.
* hooks/useGlobalInbox matches contacts by (master Ed25519 OR legacy
X25519) so an incoming message from a peer's desktop reuses the
existing chat instead of creating a duplicate placeholder.
* hooks/useMessages now takes an optional `contactMasterEd25519` and
exposes a matchesChat() predicate; WS inbox handler uses it too to
avoid spurious refetches.
* chats/[id].tsx passes `contact.address` (master) along with x25519.
Desktop client — all new:
* src/lib/crypto.ts — tweetnacl hex/base64 helpers, generateKeyFile,
encryptMessage/decryptMessage, signBase64, shortAddr. Same signatures
as the mobile lib; uses Chromium's window.crypto, no expo-crypto dep.
* src/lib/tx.ts — buildTransferTx / buildLinkDeviceTx / buildUnlinkDeviceTx
+ submitTx + humanizeTxError, canonical-bytes identical to mobile.
* src/lib/relay.ts — fetchInbox, sendEnvelope, resolveRecipientKeys
(multi-device fan-out with legacy identity.x25519 fallback).
* src/lib/store.ts — zustand state gets messages{}, unread{},
activeChat.
* src/lib/storage.ts — per-chat cache via localStorage (500-msg cap).
* src/hooks/useInboxPoll — 4s polling loop, addresses conversations
by master Ed25519, bumps unread unless chat is active.
* src/sections/messages/* — ChatList (sorted tiles, unread badges),
Conversation (auto-scroll messages + composer + fan-out send,
Enter-to-send / Shift+Enter for newline), EmptyConversation.
* src/auth/Pair.tsx — 6-digit code + device key screen, polls inbox
for a handshake envelope, assembles the KeyFile on arrival.
* Welcome.tsx: Pair button now actually routes to <Pair>; imports
generateKeyFile from lib/crypto (was inlined).
docs/ROADMAP.md delta: alpha5 row flipped to done inline. Alpha6
(feed + wallet) and rc1 (contacts + devices UI + profile) still
pending.
416 lines
13 KiB
Go
416 lines
13 KiB
Go
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: <Envelope JSON>}
|
|
// GET /relay/inbox ?pub=<x25519hex>[&since=<unix_ts>][&limit=N]
|
|
// GET /relay/inbox/count ?pub=<x25519hex>
|
|
// DELETE /relay/inbox/{envID} ?pub=<x25519hex>
|
|
// GET /relay/contacts ?pub=<ed25519hex>
|
|
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=<hex>[&since=<ts>][&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"` // X25519 hex
|
|
SenderEd25519Pub string `json:"sender_ed25519_pub"` // master Ed25519 hex (optional; may be empty for legacy senders)
|
|
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,
|
|
SenderEd25519Pub: env.SenderEd25519PubKey,
|
|
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=<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)
|
|
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=<hex>
|
|
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": "<hex X25519 pub key>",
|
|
// "msg_b64": "<base64-encoded plaintext>",
|
|
// }
|
|
//
|
|
// 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": <relay.Envelope JSON>}
|
|
//
|
|
// 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=<ed25519hex>
|
|
//
|
|
// 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
|
|
}
|