Compare commits
2 Commits
v2.2.0-alp
...
v2.2.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af7223b93c | ||
|
|
8940b97cc6 |
@@ -25,7 +25,10 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
|||||||
import { getWSClient } from '@/lib/ws';
|
import { getWSClient } from '@/lib/ws';
|
||||||
import { NavBar } from '@/components/NavBar';
|
import { NavBar } from '@/components/NavBar';
|
||||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||||
import { saveContact } from '@/lib/storage';
|
import {
|
||||||
|
saveContact,
|
||||||
|
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||||
|
} from '@/lib/storage';
|
||||||
import {
|
import {
|
||||||
fetchDevices, buildLinkDeviceTx, submitTx,
|
fetchDevices, buildLinkDeviceTx, submitTx,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
@@ -76,25 +79,59 @@ export default function AppLayout() {
|
|||||||
else ws.setAuthCreds(null);
|
else ws.setAuthCreds(null);
|
||||||
}, [keyFile]);
|
}, [keyFile]);
|
||||||
|
|
||||||
// Multi-device registry bootstrap (v2.2.0). On every sign-in:
|
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||||||
// 1. Fetch our own device list from chain.
|
//
|
||||||
// 2. If our local X25519 pub isn't in the active set, submit a
|
// Three branches, by (chain list × local "was registered" flag):
|
||||||
// LINK_DEVICE tx for it — this makes "this device" discoverable
|
//
|
||||||
// to senders. No-ops after the first successful submission on
|
// 1. Our pub is in the chain's active list →
|
||||||
// a given chain + master key pair.
|
// mark us registered locally (idempotent), done.
|
||||||
// Failures are swallowed: insufficient balance, offline node, or a
|
//
|
||||||
// chain that hasn't upgraded to v2.2.0 all surface the same way
|
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||||||
// (our device just isn't registered yet; next sign-in tries again).
|
// another device issued UNLINK_DEVICE against us. Wipe ALL local
|
||||||
|
// state (master priv, contacts, chats, marker) and redirect to
|
||||||
|
// the auth screen. This is the security-critical path: without
|
||||||
|
// the wipe, a stolen phone after revoke would still decrypt
|
||||||
|
// historical messages.
|
||||||
|
//
|
||||||
|
// 3. Our pub is NOT in the active list, AND we've NEVER registered →
|
||||||
|
// first boot on this chain; submit LINK_DEVICE so senders can
|
||||||
|
// target us. Failures (fee, offline) are swallowed; next launch
|
||||||
|
// retries.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!keyFile) return;
|
if (!keyFile) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
let chainList;
|
||||||
try {
|
try {
|
||||||
const devs = await fetchDevices(keyFile.pub_key);
|
chainList = await fetchDevices(keyFile.pub_key);
|
||||||
if (cancelled) return;
|
} catch {
|
||||||
if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) {
|
// Network unavailable — leave state unchanged; we'll resync on
|
||||||
return; // already registered
|
// the next launch. Do NOT wipe on network error.
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
const inActive = chainList.some(d => d.x25519_pub_key === keyFile.x25519_pub);
|
||||||
|
const previouslyRegistered = await isDeviceRegistered();
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
if (inActive) {
|
||||||
|
// Branch #1 — ensure the local marker is set.
|
||||||
|
if (!previouslyRegistered) await markDeviceRegistered();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (previouslyRegistered) {
|
||||||
|
// Branch #2 — REVOKED. Self-wipe.
|
||||||
|
await wipeAllLocalState();
|
||||||
|
useStore.getState().setKeyFile(null);
|
||||||
|
// The redirect-on-null-keyFile effect below will push the user
|
||||||
|
// back to the welcome screen automatically.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch #3 — first-boot link. Best-effort.
|
||||||
|
try {
|
||||||
const deviceName = Platform.select({
|
const deviceName = Platform.select({
|
||||||
ios: 'iPhone',
|
ios: 'iPhone',
|
||||||
android: 'Android phone',
|
android: 'Android phone',
|
||||||
@@ -107,8 +144,9 @@ export default function AppLayout() {
|
|||||||
privKey: keyFile.priv_key,
|
privKey: keyFile.priv_key,
|
||||||
});
|
});
|
||||||
await submitTx(tx);
|
await submitTx(tx);
|
||||||
|
await markDeviceRegistered();
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort — next launch retries */
|
/* next launch retries */
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
|
|||||||
490
client-app/app/(app)/devices.tsx
Normal file
490
client-app/app/(app)/devices.tsx
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -492,6 +492,18 @@ export default function SettingsScreen() {
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* ── Devices — multi-device registry (v2.2.0) ── */}
|
||||||
|
<SectionLabel>Devices</SectionLabel>
|
||||||
|
<Card>
|
||||||
|
<Row
|
||||||
|
icon="phone-portrait-outline"
|
||||||
|
label="Linked devices"
|
||||||
|
value="Manage the devices that receive your messages"
|
||||||
|
onPress={() => router.push('/(app)/devices' as never)}
|
||||||
|
first
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* ── Account ── */}
|
{/* ── Account ── */}
|
||||||
<SectionLabel>Account</SectionLabel>
|
<SectionLabel>Account</SectionLabel>
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
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 — прижата к правому нижнему краю. */}
|
{/* CTA — прижата к правому нижнему краю. */}
|
||||||
<View style={{
|
<View style={{
|
||||||
flexDirection: 'row', justifyContent: 'flex-end', gap: 10,
|
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
|
<CTASecondary
|
||||||
label="Import"
|
label="Import"
|
||||||
onPress={() => router.push('/(auth)/import' as never)}
|
onPress={() => router.push('/(auth)/import' as never)}
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
|
|||||||
const CONTACTS_KEY = 'dchain_contacts';
|
const CONTACTS_KEY = 'dchain_contacts';
|
||||||
const SETTINGS_KEY = 'dchain_settings';
|
const SETTINGS_KEY = 'dchain_settings';
|
||||||
const CHATS_KEY = 'dchain_chats';
|
const CHATS_KEY = 'dchain_chats';
|
||||||
|
// Remembers (locally, per install) that this device's X25519 pub has been
|
||||||
|
// successfully linked on-chain at least once. Distinguishes "first boot,
|
||||||
|
// not registered yet" from "we were registered and then revoked by another
|
||||||
|
// device". The second case triggers self-wipe. Stored in AsyncStorage —
|
||||||
|
// if it's missing, we simply re-link.
|
||||||
|
const DEVICE_REGISTERED_KEY = 'dchain_device_registered';
|
||||||
|
|
||||||
/** Save the key file in secure storage (encrypted on device). */
|
/** Save the key file in secure storage (encrypted on device). */
|
||||||
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
export async function saveKeyFile(kf: KeyFile): Promise<void> {
|
||||||
@@ -99,3 +105,57 @@ export async function appendMessage(chatId: string, msg: CachedMessage): Promise
|
|||||||
const trimmed = msgs.slice(-500);
|
const trimmed = msgs.slice(-500);
|
||||||
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
await AsyncStorage.setItem(`${CHATS_KEY}_${chatId}`, JSON.stringify(trimmed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Multi-device bookkeeping (v2.2.0) ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* isDeviceRegistered returns true if this device has ever successfully
|
||||||
|
* linked its X25519 pub on-chain under the current master identity.
|
||||||
|
* A true-then-absent transition (registered → not in chain's active list)
|
||||||
|
* is interpreted as a remote revoke and triggers self-wipe.
|
||||||
|
*/
|
||||||
|
export async function isDeviceRegistered(): Promise<boolean> {
|
||||||
|
return (await AsyncStorage.getItem(DEVICE_REGISTERED_KEY)) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** markDeviceRegistered is called after a LINK_DEVICE commits or is
|
||||||
|
observed in the registry on startup. */
|
||||||
|
export async function markDeviceRegistered(): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clearDeviceRegistered is part of wipeAllLocalState; also called on
|
||||||
|
explicit logout. */
|
||||||
|
export async function clearDeviceRegistered(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(DEVICE_REGISTERED_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* wipeAllLocalState zeroes out every on-device artifact tied to the
|
||||||
|
* current identity: secure-store key, settings, contacts, chats cache,
|
||||||
|
* registered-device marker. Safe to call multiple times.
|
||||||
|
*
|
||||||
|
* Called in two scenarios:
|
||||||
|
* 1. Explicit "Delete account" in Settings.
|
||||||
|
* 2. Self-detected revoke — the chain says our X25519 pub is no longer
|
||||||
|
* in the active registry but we previously marked it registered,
|
||||||
|
* so another device issued UNLINK_DEVICE against us. We must not
|
||||||
|
* keep using the master priv any more — it still works at the
|
||||||
|
* crypto level, but the social contract is that we're revoked.
|
||||||
|
*/
|
||||||
|
export async function wipeAllLocalState(): Promise<void> {
|
||||||
|
// Secure store (key).
|
||||||
|
await SecureStore.deleteItemAsync(KEYFILE_KEY).catch(() => {});
|
||||||
|
// AsyncStorage — enumerate our known keys. We don't clear() the whole
|
||||||
|
// store because a share-provider or other app shard could live there.
|
||||||
|
const ks = await AsyncStorage.getAllKeys();
|
||||||
|
const ours = ks.filter(k =>
|
||||||
|
k === CONTACTS_KEY ||
|
||||||
|
k === SETTINGS_KEY ||
|
||||||
|
k === DEVICE_REGISTERED_KEY ||
|
||||||
|
k.startsWith(`${CHATS_KEY}_`),
|
||||||
|
);
|
||||||
|
if (ours.length > 0) {
|
||||||
|
await AsyncStorage.multiRemove(ours);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user