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)}