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.
This commit is contained in:
@@ -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 (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000', paddingTop: insets.top }}>
|
||||
<Header
|
||||
@@ -227,32 +304,187 @@ export default function DevicesScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<View style={{ marginTop: 18 }}>
|
||||
<Pressable
|
||||
disabled
|
||||
style={{
|
||||
onPress={() => 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,
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={18} color="#8b8b8b" />
|
||||
<Ionicons name="link-outline" size={18} color="#1d9bf0" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, marginTop: 2 }}>
|
||||
Pairing flow — coming in v2.2.0-alpha3
|
||||
Enter the 6-digit code from the new device
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={18} color="#6a6a6a" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
{/* ── Pair new device modal ───────────────────────────────────── */}
|
||||
<Modal
|
||||
visible={pairOpen}
|
||||
animationType="fade"
|
||||
transparent
|
||||
onRequestClose={() => !pairBusy && setPairOpen(false)}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'center', alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
width: '100%', maxWidth: 420,
|
||||
backgroundColor: '#0a0a0a',
|
||||
borderRadius: 18,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
padding: 20, gap: 12,
|
||||
}}>
|
||||
<View style={{
|
||||
flexDirection: 'row', alignItems: 'center',
|
||||
justifyContent: 'space-between', marginBottom: 4,
|
||||
}}>
|
||||
<Text style={{ color: '#ffffff', fontSize: 18, fontWeight: '800' }}>
|
||||
Link new device
|
||||
</Text>
|
||||
<Pressable
|
||||
onPress={() => !pairBusy && setPairOpen(false)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="close" size={22} color="#8b8b8b" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 18 }}>
|
||||
Open the new device, tap <Text style={{ color: '#ffffff' }}>Pair</Text> on
|
||||
the welcome screen, then transcribe the code + device key shown there.
|
||||
</Text>
|
||||
|
||||
<PairInput
|
||||
label="6-digit code"
|
||||
value={pairCode}
|
||||
onChangeText={setPairCode}
|
||||
placeholder="000000"
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Device key"
|
||||
value={pairKey}
|
||||
onChangeText={setPairKey}
|
||||
placeholder="64 hex chars"
|
||||
autoCapitalize="none"
|
||||
maxLength={64}
|
||||
monospace
|
||||
/>
|
||||
<PairInput
|
||||
label="Name for this device (optional)"
|
||||
value={pairName}
|
||||
onChangeText={setPairName}
|
||||
placeholder="e.g. Alice's laptop"
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end',
|
||||
gap: 10, marginTop: 8,
|
||||
}}>
|
||||
<Pressable
|
||||
onPress={() => setPairOpen(false)}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 16, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1a1a1a' : 'transparent',
|
||||
opacity: pairBusy ? 0.5 : 1,
|
||||
})}
|
||||
>
|
||||
<Text style={{ color: '#8b8b8b', fontSize: 14, fontWeight: '700' }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={submitPair}
|
||||
disabled={pairBusy}
|
||||
style={({ pressed }) => ({
|
||||
paddingHorizontal: 18, paddingVertical: 10,
|
||||
borderRadius: 999,
|
||||
backgroundColor: pressed ? '#1580c8' : '#1d9bf0',
|
||||
opacity: pairBusy ? 0.7 : 1,
|
||||
minWidth: 90, alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
{pairBusy ? (
|
||||
<ActivityIndicator size="small" color="#ffffff" />
|
||||
) : (
|
||||
<Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '700' }}>
|
||||
Link
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View>
|
||||
<Text style={{
|
||||
color: '#8b8b8b', fontSize: 11, fontWeight: '700',
|
||||
textTransform: 'uppercase', letterSpacing: 1, marginBottom: 6,
|
||||
}}>
|
||||
{label}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="#3a3a3a"
|
||||
keyboardType={keyboardType}
|
||||
autoCapitalize={autoCapitalize ?? 'sentences'}
|
||||
autoCorrect={false}
|
||||
maxLength={maxLength}
|
||||
style={{
|
||||
color: '#ffffff', fontSize: monospace ? 13 : 14,
|
||||
fontFamily: monospace ? 'monospace' : undefined,
|
||||
backgroundColor: '#000000',
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12, paddingVertical: 10,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
300
client-app/app/(auth)/pair.tsx
Normal file
300
client-app/app/(auth)/pair.tsx
Normal file
@@ -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<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(),
|
||||
};
|
||||
}
|
||||
@@ -346,8 +346,13 @@ export default function WelcomeScreen() {
|
||||
{/* CTA — прижата к правому нижнему краю. */}
|
||||
<View style={{
|
||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
||||
paddingHorizontal: 24, paddingBottom: 8,
|
||||
paddingHorizontal: 24, paddingBottom: 8, flexWrap: 'wrap',
|
||||
}}>
|
||||
<CTASecondary
|
||||
label="Pair"
|
||||
icon="link"
|
||||
onPress={() => router.push('/(auth)/pair' as never)}
|
||||
/>
|
||||
<CTASecondary
|
||||
label="Import"
|
||||
onPress={() => router.push('/(auth)/import' as never)}
|
||||
|
||||
Reference in New Issue
Block a user