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.
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
// Relay mailbox client. Same wire format + semantics as
|
|
// client-app/lib/api.ts, narrowed to the calls the desktop actually
|
|
// needs right now: broadcast sealed envelopes, fetch inbox, resolve a
|
|
// recipient's device pubs for fan-out.
|
|
|
|
import { get, post, fetchDevices, getIdentity } from './api';
|
|
import {
|
|
hexToBytes, bytesToHex, bytesToBase64, base64ToBytes,
|
|
} from './crypto';
|
|
|
|
export interface Envelope {
|
|
id: string;
|
|
sender_pub: string; // X25519 hex (per-device key)
|
|
/**
|
|
* sender_ed25519_pub (v2.2.0+): master Ed25519 identity of the sender.
|
|
* Empty for legacy senders; when present, clients should use this as
|
|
* the conversation address so messages from any of the sender's
|
|
* linked devices roll into a single chat.
|
|
*/
|
|
sender_ed25519_pub: string;
|
|
recipient_pub: string;
|
|
nonce: string; // hex
|
|
ciphertext: string; // hex
|
|
timestamp: number; // unix seconds
|
|
}
|
|
|
|
// ─── Inbox ───────────────────────────────────────────────────────────────
|
|
|
|
interface InboxItemWire {
|
|
id: string;
|
|
sender_pub: string;
|
|
sender_ed25519_pub?: string; // v2.2.0+; omitted by older nodes
|
|
recipient_pub: string;
|
|
sent_at: number;
|
|
nonce: string; // base64 on the wire
|
|
ciphertext: string; // base64 on the wire
|
|
}
|
|
interface InboxResponseWire {
|
|
pub: string;
|
|
count: number;
|
|
has_more: boolean;
|
|
items: InboxItemWire[];
|
|
}
|
|
|
|
/**
|
|
* GET /relay/inbox?pub=<x25519> → envelopes addressed to that pub.
|
|
* Converts base64 nonce/ciphertext (Go wire format) to hex so they
|
|
* line up with what crypto.decryptMessage expects.
|
|
*/
|
|
export async function fetchInbox(x25519Pub: string): Promise<Envelope[]> {
|
|
const resp = await get<InboxResponseWire>(`/relay/inbox?pub=${x25519Pub}`);
|
|
const items = Array.isArray(resp?.items) ? resp.items : [];
|
|
return items.map((it): Envelope => ({
|
|
id: it.id,
|
|
sender_pub: it.sender_pub,
|
|
sender_ed25519_pub: it.sender_ed25519_pub ?? '',
|
|
recipient_pub: it.recipient_pub,
|
|
nonce: bytesToHex(base64ToBytes(it.nonce)),
|
|
ciphertext: bytesToHex(base64ToBytes(it.ciphertext)),
|
|
timestamp: it.sent_at ?? 0,
|
|
}));
|
|
}
|
|
|
|
// ─── Broadcast ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* POST /relay/broadcast — submits a pre-sealed E2E envelope. The node
|
|
* relays without ever reading the plaintext; only the recipient's
|
|
* X25519 priv can open it. Sender_ed25519_pub is advisory for future
|
|
* fee-proof flows; current node ignores it when fee_ut = 0.
|
|
*/
|
|
export async function sendEnvelope(params: {
|
|
senderPub: string; // X25519 hex
|
|
recipientPub: string; // X25519 hex
|
|
nonce: string; // hex
|
|
ciphertext: string; // hex
|
|
senderEd25519Pub?: string; // optional
|
|
}): Promise<{ id: string; status: string }> {
|
|
const sentAt = Math.floor(Date.now() / 1000);
|
|
const nonceB64 = bytesToBase64(hexToBytes(params.nonce));
|
|
const ctB64 = bytesToBase64(hexToBytes(params.ciphertext));
|
|
// Envelope.id is server-facing dedup key; first 16 bytes of the nonce
|
|
// are cryptographically random, reuse them to avoid another RNG call.
|
|
const id = bytesToHex(hexToBytes(params.nonce).slice(0, 16));
|
|
return post<{ id: string; status: string }>('/relay/broadcast', {
|
|
envelope: {
|
|
id,
|
|
sender_pub: params.senderPub,
|
|
recipient_pub: params.recipientPub,
|
|
sender_ed25519_pub: params.senderEd25519Pub ?? '',
|
|
fee_ut: 0,
|
|
fee_sig: null,
|
|
nonce: nonceB64,
|
|
ciphertext: ctB64,
|
|
sent_at: sentAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
// ─── Recipient resolution (multi-device v2.2.0) ──────────────────────────
|
|
|
|
/**
|
|
* For a recipient identity, return every X25519 pub we should ship an
|
|
* envelope to. Device registry first, identity.x25519_pub as fall-back.
|
|
* Same helper lives in client-app — copied here rather than imported so
|
|
* the desktop build stays React-Native-free.
|
|
*/
|
|
export async function resolveRecipientKeys(masterPub: string): Promise<string[]> {
|
|
const devs = await fetchDevices(masterPub);
|
|
if (devs.length > 0) return devs.map(d => d.x25519_pub_key);
|
|
const id = await getIdentity(masterPub);
|
|
return id?.x25519_pub ? [id.x25519_pub] : [];
|
|
}
|