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.
491 lines
18 KiB
TypeScript
491 lines
18 KiB
TypeScript
/**
|
|
* Devices screen — Settings → Linked devices.
|
|
*
|
|
* Multi-device registry (v2.2.0). Lists every X25519 device published
|
|
* on-chain under this identity's master Ed25519 key. Operators can:
|
|
* - see added-at timestamps
|
|
* - rename this device (local alias for now; rename via LINK_DEVICE
|
|
* with same pub + new name is a v2.3 polish)
|
|
* - revoke a remote device via UNLINK_DEVICE (requires fee)
|
|
* - pair a new device (Phase 3 — separate modal, stub for now)
|
|
*
|
|
* This device is NEVER listed with an Unlink button — revoking yourself
|
|
* is a footgun (you'd wipe your own state on next launch). Export/import
|
|
* your key first, then revoke from the new device.
|
|
*/
|
|
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, 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';
|
|
|
|
function shortPub(p: string, n = 8): string {
|
|
if (!p) return '—';
|
|
return p.length <= n * 2 + 1 ? p : `${p.slice(0, n)}…${p.slice(-n)}`;
|
|
}
|
|
|
|
function formatDate(unixSec: number): string {
|
|
return new Date(unixSec * 1000).toLocaleString();
|
|
}
|
|
|
|
export default function DevicesScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const keyFile = useStore(s => s.keyFile);
|
|
|
|
const [devices, setDevices] = useState<DeviceInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [unlinking, setUnlinking] = useState<string | null>(null); // pub being revoked
|
|
|
|
const load = useCallback(async (isRefresh = false) => {
|
|
if (!keyFile) return;
|
|
if (isRefresh) setRefreshing(true);
|
|
else setLoading(true);
|
|
try {
|
|
const list = await fetchDevices(keyFile.pub_key);
|
|
setDevices(list);
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [keyFile]);
|
|
|
|
useEffect(() => { load(false); }, [load]);
|
|
|
|
const onUnlink = useCallback((dev: DeviceInfo) => {
|
|
if (!keyFile) return;
|
|
Alert.alert(
|
|
'Unlink device?',
|
|
`"${dev.device_name}" will stop receiving messages sent to you. ` +
|
|
`This costs a small network fee. The revoked device wipes its ` +
|
|
`local state the next time it checks in.`,
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{
|
|
text: 'Unlink',
|
|
style: 'destructive',
|
|
onPress: async () => {
|
|
setUnlinking(dev.x25519_pub_key);
|
|
try {
|
|
const tx = buildUnlinkDeviceTx({
|
|
from: keyFile.pub_key,
|
|
x25519Pub: dev.x25519_pub_key,
|
|
privKey: keyFile.priv_key,
|
|
});
|
|
await submitTx(tx);
|
|
// Optimistic — drop from local list immediately; next load
|
|
// reconciles. Chain tx takes ~1 block to commit.
|
|
setDevices(prev => prev.filter(d => d.x25519_pub_key !== dev.x25519_pub_key));
|
|
} catch (e: any) {
|
|
Alert.alert('Unlink failed', humanizeTxError(e));
|
|
} finally {
|
|
setUnlinking(null);
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
}, [keyFile]);
|
|
|
|
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
|
|
title="Devices"
|
|
divider
|
|
left={<IconButton icon="chevron-back" size={36} onPress={() => safeBack()} />}
|
|
/>
|
|
|
|
<ScrollView
|
|
contentContainerStyle={{ padding: 14, paddingBottom: insets.bottom + 30 }}
|
|
refreshControl={
|
|
<RefreshControl
|
|
refreshing={refreshing}
|
|
onRefresh={() => load(true)}
|
|
tintColor="#1d9bf0"
|
|
/>
|
|
}
|
|
>
|
|
<Text style={{ color: '#8b8b8b', fontSize: 12, lineHeight: 17, marginBottom: 14 }}>
|
|
Every linked device has its own encryption key. Messages sent to you
|
|
are delivered to all active devices.
|
|
</Text>
|
|
|
|
{loading ? (
|
|
<View style={{ paddingTop: 60, alignItems: 'center' }}>
|
|
<ActivityIndicator color="#1d9bf0" />
|
|
</View>
|
|
) : devices.length === 0 ? (
|
|
<View style={{
|
|
paddingTop: 60, alignItems: 'center', paddingHorizontal: 24,
|
|
}}>
|
|
<Ionicons name="phone-portrait-outline" size={36} color="#3a3a3a" />
|
|
<Text style={{
|
|
color: '#ffffff', fontSize: 15, fontWeight: '700',
|
|
marginTop: 10,
|
|
}}>
|
|
No devices registered yet
|
|
</Text>
|
|
<Text style={{
|
|
color: '#8b8b8b', fontSize: 13, textAlign: 'center',
|
|
marginTop: 6, lineHeight: 19,
|
|
}}>
|
|
This device auto-registers when the next network-fee is available.
|
|
Top up your balance and pull to refresh.
|
|
</Text>
|
|
</View>
|
|
) : (
|
|
<View style={{
|
|
borderRadius: 14,
|
|
backgroundColor: '#0a0a0a',
|
|
borderWidth: 1, borderColor: '#1f1f1f',
|
|
overflow: 'hidden',
|
|
}}>
|
|
{devices.map((d, i) => {
|
|
const isMe = d.x25519_pub_key === meX25519;
|
|
const busy = unlinking === d.x25519_pub_key;
|
|
return (
|
|
<View key={d.x25519_pub_key}>
|
|
{i > 0 && <View style={{ height: 1, backgroundColor: '#1f1f1f' }} />}
|
|
<View style={{
|
|
flexDirection: 'row', alignItems: 'center',
|
|
paddingHorizontal: 14, paddingVertical: 14, gap: 12,
|
|
}}>
|
|
<Ionicons
|
|
name={isMe ? 'phone-portrait' : 'phone-portrait-outline'}
|
|
size={22}
|
|
color={isMe ? '#1d9bf0' : '#d0d0d0'}
|
|
/>
|
|
<View style={{ flex: 1, minWidth: 0 }}>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
|
<Text
|
|
style={{ color: '#ffffff', fontSize: 15, fontWeight: '700' }}
|
|
numberOfLines={1}
|
|
>
|
|
{d.device_name || 'Unnamed device'}
|
|
</Text>
|
|
{isMe && (
|
|
<View style={{
|
|
paddingHorizontal: 6, paddingVertical: 1,
|
|
borderRadius: 6, backgroundColor: '#0d2540',
|
|
}}>
|
|
<Text style={{ color: '#1d9bf0', fontSize: 10, fontWeight: '700' }}>
|
|
THIS DEVICE
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<Text
|
|
style={{
|
|
color: '#8b8b8b', fontSize: 11, fontFamily: 'monospace',
|
|
marginTop: 3,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{shortPub(d.x25519_pub_key)}
|
|
</Text>
|
|
<Text style={{ color: '#6a6a6a', fontSize: 11, marginTop: 2 }}>
|
|
Linked {formatDate(d.added_at)}
|
|
</Text>
|
|
</View>
|
|
{!isMe && (
|
|
<Pressable
|
|
onPress={() => onUnlink(d)}
|
|
disabled={busy}
|
|
style={({ pressed }) => ({
|
|
paddingHorizontal: 12, paddingVertical: 7,
|
|
borderRadius: 999,
|
|
borderWidth: 1, borderColor: '#3a2020',
|
|
backgroundColor: pressed ? '#2a1414' : 'transparent',
|
|
opacity: busy ? 0.5 : 1,
|
|
})}
|
|
>
|
|
{busy ? (
|
|
<ActivityIndicator size="small" color="#ff6b6b" />
|
|
) : (
|
|
<Text style={{ color: '#ff6b6b', fontSize: 12, fontWeight: '700' }}>
|
|
Unlink
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
</View>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
|
|
{/* 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
|
|
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,
|
|
})}
|
|
>
|
|
<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 }}>
|
|
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>
|
|
);
|
|
}
|