// 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= → 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 { const resp = await get(`/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 { 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] : []; }