/** * Pair — secondary-device onboarding. * * Flow (new device, this screen): * 1. Generate a fresh X25519 keypair locally + random 6-digit code. * 2. Display { code, device x25519 pub }. User enters both on their * primary device (Settings → Devices → Link new device). * 3. Primary device: * - submits LINK_DEVICE tx to publish our pub under its master, * - sends a relay envelope to our x25519 pub, encrypted with its * own x25519 priv, containing { code, master_pub, master_priv, * master_x25519_pub }. * 4. We poll /relay/inbox every few seconds; when a decryptable * envelope arrives whose payload.code matches our displayed code, * we treat it as the handshake, assemble a KeyFile, save it, and * redirect into (app). * * Security notes: * - Master Ed25519 priv travels only via this envelope, encrypted for * this device's X25519 pub (which only this device holds the priv * for). Exposure is limited to one successful decrypt; we DELETE * the envelope from the mailbox immediately. * - The 6-digit code defends against a confused primary device paired * with a different victim, or a race with an attacker who guesses * our X25519 pub. Envelope without matching code → ignored. * - Envelope is short-lived in the mailbox: we DELETE on first decrypt * and the relay node has its own TTL. */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { View, Text, Pressable, ActivityIndicator, ScrollView, } from 'react-native'; import { router } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import * as Clipboard from 'expo-clipboard'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import nacl from 'tweetnacl'; import { useStore } from '@/lib/store'; import { bytesToHex, decryptMessage } from '@/lib/crypto'; import { fetchInbox } from '@/lib/api'; import { saveKeyFile, markDeviceRegistered } from '@/lib/storage'; import { safeBack } from '@/lib/utils'; import type { KeyFile } from '@/lib/types'; // Protocol constant — bump if payload shape changes. const PAIR_ENVELOPE_VERSION = 1; interface PairEnvelopePayload { v: number; type: 'pair-handshake'; code: string; master_pub: string; // Ed25519 pub, hex master_priv: string; // Ed25519 priv, hex master_x25519_pub: string; // primary device's x25519 pub, hex (for nothing special — just FYI) } function randomCode(): string { // Six decimal digits. Entropy ~20 bits. Good enough for a one-shot // rendezvous code gated by an out-of-band delivery channel (envelope // targeted at a freshly-generated X25519 pub that only this device // holds the priv for). const n = Math.floor(Math.random() * 1_000_000); return n.toString().padStart(6, '0'); } export default function PairScreen() { const insets = useSafeAreaInsets(); const setKeyFile = useStore(s => s.setKeyFile); // One-shot keypair + code for this pairing session. Regenerate only on // manual Retry (unmount+remount). const session = useRef(genSession()).current; const [status, setStatus] = useState<'waiting' | 'success' | 'error'>('waiting'); const [err, setErr] = useState(null); const copyCode = useCallback(async () => { await Clipboard.setStringAsync(session.code); }, [session.code]); const copyPub = useCallback(async () => { await Clipboard.setStringAsync(session.x25519Pub); }, [session.x25519Pub]); // Poll mailbox until a matching handshake envelope arrives, or user // backs out. Interval 2.5s — conservative on battery, fine for a // flow the user is staring at for a minute. useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; const tick = async () => { if (cancelled) return; try { const envs = await fetchInbox(session.x25519Pub); for (const env of envs) { // Decrypt each envelope with our session priv. We don't know // the primary's x25519 pub up front — it's inside the envelope // metadata. decryptMessage needs both pubs, so we pass the // envelope's sender_pub directly. const plain = decryptMessage( env.ciphertext, env.nonce, env.sender_pub, session.x25519Priv, ); if (!plain) continue; let payload: PairEnvelopePayload; try { payload = JSON.parse(plain); } catch { continue; } if ( payload.v !== PAIR_ENVELOPE_VERSION || payload.type !== 'pair-handshake' || payload.code !== session.code || !payload.master_pub || !payload.master_priv ) continue; // Success — materialise a KeyFile and redirect. const kf: KeyFile = { pub_key: payload.master_pub, priv_key: payload.master_priv, x25519_pub: session.x25519Pub, x25519_priv: session.x25519Priv, }; await saveKeyFile(kf); // The root layout auto-links us on first boot if needed, but // the primary device already submitted LINK_DEVICE for our // pub as part of the pairing, so the registry is already // correct. Mark ourselves registered so the revoke-detection // branch doesn't spuriously wipe on next launch. await markDeviceRegistered(); setKeyFile(kf); // Envelope stays in mailbox until relay TTL eviction; the // single-shot handshake is idempotent (saveKeyFile overwrites) // and our session pub won't be polled again after redirect. setStatus('success'); setTimeout(() => { if (!cancelled) router.replace('/(app)/chats' as never); }, 600); return; } } catch { /* transient — retry */ } if (!cancelled) timer = setTimeout(tick, 2_500); }; tick(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [session, setKeyFile]); return ( {/* Header */} safeBack('/')} hitSlop={8} style={{ alignSelf: 'flex-start', marginBottom: 20 }} > Pair with your other device On a device where you're already signed in, open Settings → Devices → Link new device, and enter these values. {/* Code */} 1. Code {session.code.slice(0, 3)} {session.code.slice(3)} Copy code {/* Device key */} 2. Device key {session.x25519Pub} Copy key {/* Status */} {status === 'waiting' && ( Waiting for your other device… )} {status === 'success' && ( Paired. Opening your chats… )} {status === 'error' && ( {err ?? 'Something went wrong. Please retry.'} )} ); } // ─── session helper ────────────────────────────────────────────────────── interface PairSession { x25519Pub: string; // hex x25519Priv: string; // hex code: string; } function genSession(): PairSession { const kp = nacl.box.keyPair(); return { x25519Pub: bytesToHex(kp.publicKey), x25519Priv: bytesToHex(kp.secretKey), code: randomCode(), }; }