From 423d30712514a29dfc66a0434635d7b0b90e3ac3 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 16:24:36 +0300 Subject: [PATCH] feat(client): multi-device fan-out + auto-link (v2.2.0-alpha2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #2 of the multi-device roadmap — wires the messenger pipeline against the on-chain registry landed in v2.2.0-alpha1. lib/api.ts: - DeviceInfo type mirroring blockchain.DeviceInfo. - IdentityInfo.device_count (optional; populated from /api/identity). - fetchDevices(masterPub) → /api/devices/{master_pub}, returns []. Errors swallowed so a downed endpoint doesn't block messaging. - resolveRecipientKeys(masterPub) — the routing primitive. Returns devices[] if registered, else falls back to IdentityInfo.x25519_pub (pre-v2.2.0 path). Empty only when recipient has published nothing. - buildLinkDeviceTx / buildUnlinkDeviceTx — signed by master Ed25519, min-fee cost, canonical JSON payload matching the chain-side LinkDevicePayload / UnlinkDevicePayload. app/(app)/chats/[id].tsx: - sendCore now fans out: encrypts once per recipient device pub (Promise.all, any failure rejects the batch), falls back to the cached contact.x25519Pub if the registry lookup returns nothing. - Saved Messages short-circuit preserved; no devices lookup for self. app/(app)/_layout.tsx: - On every sign-in, auto-submit LINK_DEVICE for this device if its X25519 pub isn't already in the master's registry. Device name picks "iPhone" / "Android phone" / "Device" by Platform. Errors (insufficient balance / legacy chain without LINK_DEVICE support) are silent — next launch retries. Backward compatibility: senders fall back to identity.x25519_pub when the recipient has no registry entries, so pre-v2.2.0 clients still receive messages. Chain-side already gates new validation on the event types existing; old clients simply never emit LINK_DEVICE and keep working with a single X25519. Next — PR #3 (Settings → Devices screen + QR pairing flow + receive-side self-wipe on revoke detection). --- client-app/app/(app)/_layout.tsx | 43 ++++++++- client-app/app/(app)/chats/[id].tsx | 39 ++++++--- client-app/lib/api.ts | 131 ++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 11 deletions(-) diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx index ab6b95a..7aedd2b 100644 --- a/client-app/app/(app)/_layout.tsx +++ b/client-app/app/(app)/_layout.tsx @@ -13,7 +13,7 @@ * один раз; переходы между tab'ами их не перезапускают. */ import React, { useEffect } from 'react'; -import { View } from 'react-native'; +import { View, Platform } from 'react-native'; import { router, usePathname } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; @@ -26,6 +26,9 @@ import { getWSClient } from '@/lib/ws'; import { NavBar } from '@/components/NavBar'; import { AnimatedSlot } from '@/components/AnimatedSlot'; import { saveContact } from '@/lib/storage'; +import { + fetchDevices, buildLinkDeviceTx, submitTx, +} from '@/lib/api'; export default function AppLayout() { const keyFile = useStore(s => s.keyFile); @@ -73,6 +76,44 @@ export default function AppLayout() { else ws.setAuthCreds(null); }, [keyFile]); + // Multi-device registry bootstrap (v2.2.0). On every sign-in: + // 1. Fetch our own device list from chain. + // 2. If our local X25519 pub isn't in the active set, submit a + // LINK_DEVICE tx for it — this makes "this device" discoverable + // to senders. No-ops after the first successful submission on + // a given chain + master key pair. + // Failures are swallowed: insufficient balance, offline node, or a + // chain that hasn't upgraded to v2.2.0 all surface the same way + // (our device just isn't registered yet; next sign-in tries again). + useEffect(() => { + if (!keyFile) return; + let cancelled = false; + (async () => { + try { + const devs = await fetchDevices(keyFile.pub_key); + if (cancelled) return; + if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) { + return; // already registered + } + const deviceName = Platform.select({ + ios: 'iPhone', + android: 'Android phone', + default: 'Device', + }) ?? 'Device'; + const tx = buildLinkDeviceTx({ + from: keyFile.pub_key, + x25519Pub: keyFile.x25519_pub, + deviceName, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + } catch { + /* best-effort — next launch retries */ + } + })(); + return () => { cancelled = true; }; + }, [keyFile]); + useEffect(() => { if (keyFile === null) { const t = setTimeout(() => { diff --git a/client-app/app/(app)/chats/[id].tsx b/client-app/app/(app)/chats/[id].tsx index 075079c..52f8436 100644 --- a/client-app/app/(app)/chats/[id].tsx +++ b/client-app/app/(app)/chats/[id].tsx @@ -23,7 +23,7 @@ import * as Clipboard from 'expo-clipboard'; import { useStore } from '@/lib/store'; import { useMessages } from '@/hooks/useMessages'; import { encryptMessage } from '@/lib/crypto'; -import { sendEnvelope } from '@/lib/api'; +import { sendEnvelope, resolveRecipientKeys } from '@/lib/api'; import { getWSClient } from '@/lib/ws'; import { appendMessage, loadMessages } from '@/lib/storage'; import { randomId, safeBack } from '@/lib/utils'; @@ -212,15 +212,34 @@ export default function ChatScreen() { // leaves the device, so no encryption/fee/network round-trip is needed. // Regular chats still go through the NaCl + relay pipeline below. if (hasText && !isSavedMessages) { - const { nonce, ciphertext } = encryptMessage( - actualText.trim(), keyFile.x25519_priv, contact.x25519Pub, - ); - await sendEnvelope({ - senderPub: keyFile.x25519_pub, - recipientPub: contact.x25519Pub, - senderEd25519Pub: keyFile.pub_key, - nonce, ciphertext, - }); + // Multi-device fan-out (v2.2.0): resolve the recipient's active + // device X25519 pubs via /api/devices. Legacy identities (no + // devices registered) fall back to their published identity + // x25519 pub, preserving the pre-v2.2.0 single-device path. + // `contact.x25519Pub` stays the floor — if both network calls + // fail we still attempt delivery to the cached pub so a flaky + // connection doesn't block outgoing messages. + let recipientPubs = await resolveRecipientKeys(contact.address); + if (recipientPubs.length === 0 && contact.x25519Pub) { + recipientPubs = [contact.x25519Pub]; + } + if (recipientPubs.length === 0) { + throw new Error('recipient has no encryption key published'); + } + // One sealed envelope per recipient device. Parallel — slow + // relays don't block each other; any individual failure + // rejects the whole send (user retries). + await Promise.all(recipientPubs.map(async (rpub) => { + const { nonce, ciphertext } = encryptMessage( + actualText.trim(), keyFile.x25519_priv, rpub, + ); + await sendEnvelope({ + senderPub: keyFile.x25519_pub, + recipientPub: rpub, + senderEd25519Pub: keyFile.pub_key, + nonce, ciphertext, + }); + })); } const msg: Message = { diff --git a/client-app/lib/api.ts b/client-app/lib/api.ts index 38e4532..53c42ba 100644 --- a/client-app/lib/api.ts +++ b/client-app/lib/api.ts @@ -365,6 +365,25 @@ export interface IdentityInfo { x25519_pub: string; // hex Curve25519 key; empty string if not published nickname: string; registered: boolean; + /** + * Number of active (non-revoked) devices linked to this master identity + * via LINK_DEVICE (v2.2.0). 0 for legacy identities that only published + * a single X25519 via REGISTER_KEY — senders should fall back to + * `x25519_pub` above and skip the device fan-out path. + */ + device_count?: number; +} + +/** + * One active device in an identity's multi-device registry. Returned by + * GET /api/devices/{master_pub} as part of `devices[]`. Senders use the + * list to fan out one sealed envelope per X25519 pub so all of the + * recipient's devices receive the message. + */ +export interface DeviceInfo { + x25519_pub_key: string; + device_name: string; + added_at: number; // unix seconds } /** @@ -409,6 +428,56 @@ export async function getIdentity(pubkeyOrAddr: string): Promise { + try { + const resp = await get(`/api/devices/${masterPub}`); + return resp.devices ?? []; + } catch { + return []; + } +} + +/** + * Pick the right set of recipient X25519 pubs for a sender's fan-out. + * Two paths, in priority order: + * + * 1. New path — /api/devices returns ≥1 entry. Send to each device. + * 2. Legacy path — identity published an X25519 via REGISTER_KEY + * (pre-v2.2.0 clients). Send to just that one. + * + * Returns an empty array only when the recipient has published nothing + * at all — caller must surface "no encryption key" to the user rather + * than drop the message on the floor. + */ +export async function resolveRecipientKeys( + recipientMasterPub: string, +): Promise { + const devs = await fetchDevices(recipientMasterPub); + if (devs.length > 0) { + return devs.map(d => d.x25519_pub_key); + } + const identity = await getIdentity(recipientMasterPub); + if (identity?.x25519_pub) { + return [identity.x25519_pub]; + } + return []; +} + // ─── Contract API ───────────────────────────────────────────────────────────── /** @@ -712,6 +781,68 @@ export function buildTransferTx(params: { }; } +/** + * LINK_DEVICE transaction — publish a per-device X25519 pub in the + * identity's device registry so senders can fan out envelopes across + * every active device. Signed by the master Ed25519 (= `from`). + * + * `deviceName` is a short human label shown in Settings → Devices + * (≤ 64 bytes, printable ASCII/UTF-8, no control chars). + */ +export function buildLinkDeviceTx(params: { + from: string; // master Ed25519 pubkey + x25519Pub: string; // per-device X25519 pubkey (64 hex chars, lowercase) + deviceName: string; + privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payloadObj = { + x25519_pub_key: params.x25519Pub, + device_name: params.deviceName, + }; + const payload = strToBase64(JSON.stringify(payloadObj)); + + const canonical = txCanonicalBytes({ + id, type: 'LINK_DEVICE', from: params.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + + return { + id, type: 'LINK_DEVICE', from: params.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + +/** + * UNLINK_DEVICE transaction — revoke a previously-linked device so senders + * stop shipping envelopes to its X25519 pub. The revoked device itself, + * when it next comes online and sees its own pub in the revoked list, + * is expected to wipe local state (master priv + cached chats). + */ +export function buildUnlinkDeviceTx(params: { + from: string; // master Ed25519 pubkey + x25519Pub: string; // pub to revoke + privKey: string; +}): RawTx { + const id = newTxID(); + const timestamp = rfc3339Now(); + const payloadObj = { x25519_pub_key: params.x25519Pub }; + const payload = strToBase64(JSON.stringify(payloadObj)); + + const canonical = txCanonicalBytes({ + id, type: 'UNLINK_DEVICE', from: params.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + }); + + return { + id, type: 'UNLINK_DEVICE', from: params.from, to: '', + amount: 0, fee: MIN_TX_FEE, payload, timestamp, + signature: signBase64(canonical, params.privKey), + }; +} + /** * CONTACT_REQUEST transaction. *