Files
dchain/client-app/app/(auth)/pair.tsx
vsecoder af7223b93c feat(client): device pairing flow (v2.2.0-alpha3)
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.
2026-04-22 16:32:34 +03:00

301 lines
11 KiB
TypeScript

/**
* 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<string | null>(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<typeof setTimeout> | 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 (
<View style={{
flex: 1, backgroundColor: '#000000',
paddingTop: insets.top + 12,
paddingBottom: Math.max(insets.bottom, 20),
}}>
<ScrollView
contentContainerStyle={{ padding: 24 }}
showsVerticalScrollIndicator={false}
>
{/* Header */}
<Pressable
onPress={() => safeBack('/')}
hitSlop={8}
style={{ alignSelf: 'flex-start', marginBottom: 20 }}
>
<Ionicons name="chevron-back" size={28} color="#ffffff" />
</Pressable>
<View style={{ alignItems: 'center', marginBottom: 28 }}>
<View style={{
width: 80, height: 80, borderRadius: 22,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
alignItems: 'center', justifyContent: 'center',
}}>
<Ionicons name="link" size={40} color="#1d9bf0" />
</View>
<Text style={{
color: '#ffffff', fontSize: 22, fontWeight: '800',
marginTop: 14, textAlign: 'center',
}}>
Pair with your other device
</Text>
<Text style={{
color: '#8b8b8b', fontSize: 13, lineHeight: 19,
marginTop: 8, textAlign: 'center', maxWidth: 300,
}}>
On a device where you're already signed in,
open Settings → Devices → Link new device,
and enter these values.
</Text>
</View>
{/* Code */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 18, paddingHorizontal: 16,
marginBottom: 14, alignItems: 'center',
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
1. Code
</Text>
<Text style={{
color: '#ffffff', fontSize: 38, fontWeight: '800',
letterSpacing: 6, marginTop: 4, fontFamily: 'monospace',
}}>
{session.code.slice(0, 3)} {session.code.slice(3)}
</Text>
<Pressable onPress={copyCode} hitSlop={6} style={{ marginTop: 6 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy code
</Text>
</Pressable>
</View>
{/* Device key */}
<View style={{
borderRadius: 14,
backgroundColor: '#0a0a0a',
borderWidth: 1, borderColor: '#1f1f1f',
paddingVertical: 16, paddingHorizontal: 16,
marginBottom: 20,
}}>
<Text style={{
color: '#5a5a5a', fontSize: 11, fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 1.2,
}}>
2. Device key
</Text>
<Text
selectable
style={{
color: '#ffffff', fontSize: 12, fontFamily: 'monospace',
marginTop: 6, lineHeight: 17,
}}
>
{session.x25519Pub}
</Text>
<Pressable onPress={copyPub} hitSlop={6} style={{ marginTop: 8 }}>
<Text style={{ color: '#1d9bf0', fontSize: 12, fontWeight: '600' }}>
Copy key
</Text>
</Pressable>
</View>
{/* Status */}
<View style={{ alignItems: 'center', minHeight: 56 }}>
{status === 'waiting' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<ActivityIndicator size="small" color="#1d9bf0" />
<Text style={{ color: '#8b8b8b', fontSize: 13 }}>
Waiting for your other device…
</Text>
</View>
)}
{status === 'success' && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Ionicons name="checkmark-circle" size={20} color="#3ba55d" />
<Text style={{ color: '#3ba55d', fontSize: 13, fontWeight: '700' }}>
Paired. Opening your chats…
</Text>
</View>
)}
{status === 'error' && (
<Text style={{ color: '#f4212e', fontSize: 13, textAlign: 'center' }}>
{err ?? 'Something went wrong. Please retry.'}
</Text>
)}
</View>
</ScrollView>
</View>
);
}
// ─── 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(),
};
}