From af7223b93c8b0a9720aabead1b73e79be6707413 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 16:32:34 +0300 Subject: [PATCH] feat(client): device pairing flow (v2.2.0-alpha3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes PR #3 of the multi-device roadmap. Two devices of the same identity can now be linked via a six-digit code + relay envelope handshake. Chain-level fan-out (alpha2) and self-wipe on revoke (this release, earlier commit) already work end-to-end. New device — app/(auth)/pair.tsx: * Generates fresh X25519 keypair + six-digit code locally. * Displays them for transcription on the primary device. * Polls /relay/inbox on its own x25519 pub every 2.5s. * Decrypts each envelope with the session priv + sender_pub. * Accepts only payloads where {v=1, type=pair-handshake, code matches}. * On success, assembles a KeyFile (master Ed25519 from envelope, x25519 from session) and redirects into (app). Primary device — app/(app)/devices.tsx: * "Link new device" opens a modal asking for {code, device key, name}. * On submit: builds+submits LINK_DEVICE tx for the new pub, then sends a one-shot relay envelope carrying {master_pub, master_priv, master_x25519_pub, code} encrypted for the new device's x25519 pub. * Optimistic local insert so the new row appears immediately. Entry point — app/index.tsx: * Third button on welcome slide 3: "Pair". Routes to /auth/pair. Create / Import remain unchanged for fresh identities. Security: * Master Ed25519 priv leaves the source device ONLY inside an envelope sealed with NaCl box for the new device's X25519 pub. The relay node sees only ciphertext. * Six-digit code (~20 bits) gates acceptance — an attacker who guesses both a session pub AND the code is still filtered by the X25519 decryption itself (code match is belt-and-suspenders). * Envelope stays in relay mailbox until TTL — no DELETE call yet; idempotent on our side (saveKeyFile overwrites, session pub never polled after redirect). Known trade-offs: * Manual transcription of a 64-char hex key is ugly. Alpha4 will offer a QR fallback on phones with cameras; desktop keeps typing. * No rate limit on the polling. Fine for a 1-minute handshake, needs cap-on-stale if a user leaves the screen open. --- client-app/app/(app)/devices.tsx | 252 ++++++++++++++++++++++++-- client-app/app/(auth)/pair.tsx | 300 +++++++++++++++++++++++++++++++ client-app/app/index.tsx | 7 +- 3 files changed, 548 insertions(+), 11 deletions(-) create mode 100644 client-app/app/(auth)/pair.tsx diff --git a/client-app/app/(app)/devices.tsx b/client-app/app/(app)/devices.tsx index f7cdcf3..ff6dfa5 100644 --- a/client-app/app/(app)/devices.tsx +++ b/client-app/app/(app)/devices.tsx @@ -16,15 +16,18 @@ import React, { useCallback, useEffect, useState } from 'react'; import { View, Text, ScrollView, Pressable, ActivityIndicator, Alert, RefreshControl, + TextInput, KeyboardAvoidingView, Platform, Modal, } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useStore } from '@/lib/store'; import { - fetchDevices, buildUnlinkDeviceTx, submitTx, humanizeTxError, + fetchDevices, buildLinkDeviceTx, buildUnlinkDeviceTx, submitTx, + sendEnvelope, humanizeTxError, type DeviceInfo, } from '@/lib/api'; +import { encryptMessage } from '@/lib/crypto'; import { Header } from '@/components/Header'; import { IconButton } from '@/components/IconButton'; import { safeBack } from '@/lib/utils'; @@ -99,6 +102,80 @@ export default function DevicesScreen() { const meX25519 = keyFile?.x25519_pub ?? ''; + // Pairing modal state — filled by the operator reading values off the + // new device's /auth/pair screen. + const [pairOpen, setPairOpen] = useState(false); + const [pairCode, setPairCode] = useState(''); + const [pairKey, setPairKey] = useState(''); + const [pairName, setPairName] = useState(''); + const [pairBusy, setPairBusy] = useState(false); + + const submitPair = useCallback(async () => { + if (!keyFile) return; + const code = pairCode.replace(/\s+/g, '').trim(); + const key = pairKey.replace(/\s+/g, '').trim().toLowerCase(); + if (!/^\d{6}$/.test(code)) { + Alert.alert('Invalid code', 'The pairing code is 6 digits.'); + return; + } + if (!/^[0-9a-f]{64}$/.test(key)) { + Alert.alert('Invalid key', 'The device key must be 64 hex characters.'); + return; + } + const name = pairName.trim() || 'New device'; + setPairBusy(true); + try { + // 1. LINK_DEVICE on-chain so senders learn the new pub. + const tx = buildLinkDeviceTx({ + from: keyFile.pub_key, + x25519Pub: key, + deviceName: name, + privKey: keyFile.priv_key, + }); + await submitTx(tx); + // 2. Ship the handshake payload to the new device. Encrypted for + // its x25519 pub so only it can read — master priv in plaintext + // would be catastrophic, E2E is the whole point. + const payload = JSON.stringify({ + v: 1, + type: 'pair-handshake', + code, + master_pub: keyFile.pub_key, + master_priv: keyFile.priv_key, + master_x25519_pub: keyFile.x25519_pub, + }); + const { nonce, ciphertext } = encryptMessage( + payload, keyFile.x25519_priv, key, + ); + await sendEnvelope({ + senderPub: keyFile.x25519_pub, + recipientPub: key, + senderEd25519Pub: keyFile.pub_key, + nonce, ciphertext, + }); + // 3. Optimistic local insert so the row shows up without waiting + // for the next pull/refresh round-trip. + setDevices(prev => { + if (prev.some(d => d.x25519_pub_key === key)) return prev; + return [...prev, { + x25519_pub_key: key, + device_name: name, + added_at: Math.floor(Date.now() / 1000), + }]; + }); + setPairOpen(false); + setPairCode(''); setPairKey(''); setPairName(''); + Alert.alert( + 'Pairing sent', + 'The new device should finish pairing in a few seconds.', + ); + } catch (e: any) { + Alert.alert('Pairing failed', humanizeTxError(e)); + } finally { + setPairBusy(false); + } + }, [keyFile, pairCode, pairKey, pairName]); + return (
)} - {/* Pair new device — stub for v2.2.0-alpha3 pairing flow. Disabled - until the QR protocol lands; left visible so operators know - where the entry point will live. */} + {/* Link new device — opens a modal with Code + DeviceKey inputs + that the operator transcribes from the new device's + /auth/pair screen. */} setPairOpen(true)} + style={({ pressed }) => ({ paddingVertical: 13, paddingHorizontal: 16, borderRadius: 14, + backgroundColor: pressed ? '#1a1a1a' : '#0a0a0a', borderWidth: 1, borderColor: '#1f1f1f', flexDirection: 'row', alignItems: 'center', gap: 10, - opacity: 0.5, - }} + })} > - + Link new device - Pairing flow — coming in v2.2.0-alpha3 + Enter the 6-digit code from the new device + + + {/* ── Pair new device modal ───────────────────────────────────── */} + !pairBusy && setPairOpen(false)} + > + + + + + Link new device + + !pairBusy && setPairOpen(false)} + hitSlop={8} + > + + + + + + Open the new device, tap Pair on + the welcome screen, then transcribe the code + device key shown there. + + + + + + + + setPairOpen(false)} + disabled={pairBusy} + style={({ pressed }) => ({ + paddingHorizontal: 16, paddingVertical: 10, + borderRadius: 999, + backgroundColor: pressed ? '#1a1a1a' : 'transparent', + opacity: pairBusy ? 0.5 : 1, + })} + > + + Cancel + + + ({ + paddingHorizontal: 18, paddingVertical: 10, + borderRadius: 999, + backgroundColor: pressed ? '#1580c8' : '#1d9bf0', + opacity: pairBusy ? 0.7 : 1, + minWidth: 90, alignItems: 'center', + })} + > + {pairBusy ? ( + + ) : ( + + Link + + )} + + + + + + + ); +} + +function PairInput({ + label, value, onChangeText, placeholder, keyboardType, autoCapitalize, + maxLength, monospace, +}: { + label: string; + value: string; + onChangeText: (v: string) => void; + placeholder?: string; + keyboardType?: 'default' | 'number-pad'; + autoCapitalize?: 'none' | 'sentences'; + maxLength?: number; + monospace?: boolean; +}) { + return ( + + + {label} + + ); } diff --git a/client-app/app/(auth)/pair.tsx b/client-app/app/(auth)/pair.tsx new file mode 100644 index 0000000..8e5d1c3 --- /dev/null +++ b/client-app/app/(auth)/pair.tsx @@ -0,0 +1,300 @@ +/** + * 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(), + }; +} diff --git a/client-app/app/index.tsx b/client-app/app/index.tsx index 7cf5d6a..a3c5b9a 100644 --- a/client-app/app/index.tsx +++ b/client-app/app/index.tsx @@ -346,8 +346,13 @@ export default function WelcomeScreen() { {/* CTA — прижата к правому нижнему краю. */} + router.push('/(auth)/pair' as never)} + /> router.push('/(auth)/import' as never)}