feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip)
Part of PR #3. Pairing flow still to come. Devices screen — app/(app)/devices.tsx: * Lists every active device from /api/devices/{self}. * "THIS DEVICE" badge on our own row, Unlink button on every other. * Unlink confirms + submits UNLINK_DEVICE tx, optimistic local removal. * Pull-to-refresh; empty state when balance is too low for auto-link. * Placeholder row for "Link new device" — wired in next commit. Settings → Devices entry row: added under a new "Devices" section. Self-wipe on revoke — lib/storage.ts + app/(app)/_layout.tsx: * New AsyncStorage marker `dchain_device_registered` tracks whether this install ever made it into the on-chain registry. * wipeAllLocalState() zeroes secure-store key + contacts + settings + chats cache + marker. Safe-idempotent. * Bootstrap effect in app layout splits three branches by (our_pub in chain's active list × marker_set): - in list → mark registered, done. - not in list + was registered → REVOKED → wipe + redirect to auth. - not in list + never registered → first boot, LINK_DEVICE. * Network errors never trigger wipe — only an explicit "pub missing from chain response" decides it. Belt-and-suspenders against a misbehaving node spuriously dropping records. Next: pairing flow so a second device (desktop, tablet, new phone) can come online, show a 6-digit code, receive master priv via a one-shot relay envelope encrypted to its fresh device X25519 pub, then self-link.
This commit is contained in:
@@ -25,7 +25,10 @@ import { useGlobalInbox } from '@/hooks/useGlobalInbox';
|
||||
import { getWSClient } from '@/lib/ws';
|
||||
import { NavBar } from '@/components/NavBar';
|
||||
import { AnimatedSlot } from '@/components/AnimatedSlot';
|
||||
import { saveContact } from '@/lib/storage';
|
||||
import {
|
||||
saveContact,
|
||||
isDeviceRegistered, markDeviceRegistered, wipeAllLocalState,
|
||||
} from '@/lib/storage';
|
||||
import {
|
||||
fetchDevices, buildLinkDeviceTx, submitTx,
|
||||
} from '@/lib/api';
|
||||
@@ -76,25 +79,59 @@ export default function AppLayout() {
|
||||
else ws.setAuthCreds(null);
|
||||
}, [keyFile]);
|
||||
|
||||
// Multi-device registry bootstrap (v2.2.0). On every sign-in:
|
||||
// 1. Fetch our own device list from chain.
|
||||
// 2. If our local X25519 pub isn't in the active set, submit a
|
||||
// LINK_DEVICE tx for it — this makes "this device" discoverable
|
||||
// to senders. No-ops after the first successful submission on
|
||||
// a given chain + master key pair.
|
||||
// Failures are swallowed: insufficient balance, offline node, or a
|
||||
// chain that hasn't upgraded to v2.2.0 all surface the same way
|
||||
// (our device just isn't registered yet; next sign-in tries again).
|
||||
// Multi-device registry bootstrap + revoke-detection (v2.2.0).
|
||||
//
|
||||
// Three branches, by (chain list × local "was registered" flag):
|
||||
//
|
||||
// 1. Our pub is in the chain's active list →
|
||||
// mark us registered locally (idempotent), done.
|
||||
//
|
||||
// 2. Our pub is NOT in the active list, AND we've registered before →
|
||||
// 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(() => {
|
||||
if (!keyFile) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
let chainList;
|
||||
try {
|
||||
const devs = await fetchDevices(keyFile.pub_key);
|
||||
if (cancelled) return;
|
||||
if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) {
|
||||
return; // already registered
|
||||
chainList = await fetchDevices(keyFile.pub_key);
|
||||
} catch {
|
||||
// Network unavailable — leave state unchanged; we'll resync on
|
||||
// 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({
|
||||
ios: 'iPhone',
|
||||
android: 'Android phone',
|
||||
@@ -107,8 +144,9 @@ export default function AppLayout() {
|
||||
privKey: keyFile.priv_key,
|
||||
});
|
||||
await submitTx(tx);
|
||||
await markDeviceRegistered();
|
||||
} catch {
|
||||
/* best-effort — next launch retries */
|
||||
/* next launch retries */
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
|
||||
258
client-app/app/(app)/devices.tsx
Normal file
258
client-app/app/(app)/devices.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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,
|
||||
type DeviceInfo,
|
||||
} from '@/lib/api';
|
||||
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 ?? '';
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<View style={{ marginTop: 18 }}>
|
||||
<Pressable
|
||||
disabled
|
||||
style={{
|
||||
paddingVertical: 13, paddingHorizontal: 16,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1, borderColor: '#1f1f1f',
|
||||
flexDirection: 'row', alignItems: 'center', gap: 10,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
>
|
||||
<Ionicons name="qr-code-outline" size={18} color="#8b8b8b" />
|
||||
<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
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -492,6 +492,18 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</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 ── */}
|
||||
<SectionLabel>Account</SectionLabel>
|
||||
<Card>
|
||||
|
||||
@@ -14,6 +14,12 @@ const KEYFILE_KEY = 'dchain_keyfile';
|
||||
const CONTACTS_KEY = 'dchain_contacts';
|
||||
const SETTINGS_KEY = 'dchain_settings';
|
||||
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). */
|
||||
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);
|
||||
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