From 8940b97cc6c4a39aeec5077f78ee0ce89f15efd1 Mon Sep 17 00:00:00 2001 From: vsecoder Date: Wed, 22 Apr 2026 16:28:16 +0300 Subject: [PATCH] feat(client): Devices screen + revoke self-wipe (v2.2.0-alpha3 wip) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- client-app/app/(app)/_layout.tsx | 70 ++++++-- client-app/app/(app)/devices.tsx | 258 ++++++++++++++++++++++++++++++ client-app/app/(app)/settings.tsx | 12 ++ client-app/lib/storage.ts | 60 +++++++ 4 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 client-app/app/(app)/devices.tsx diff --git a/client-app/app/(app)/_layout.tsx b/client-app/app/(app)/_layout.tsx index 7aedd2b..d628e97 100644 --- a/client-app/app/(app)/_layout.tsx +++ b/client-app/app/(app)/_layout.tsx @@ -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 { + 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 devs = await fetchDevices(keyFile.pub_key); - if (cancelled) return; - if (devs.some(d => d.x25519_pub_key === keyFile.x25519_pub)) { - return; // already registered - } 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; }; diff --git a/client-app/app/(app)/devices.tsx b/client-app/app/(app)/devices.tsx new file mode 100644 index 0000000..f7cdcf3 --- /dev/null +++ b/client-app/app/(app)/devices.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [unlinking, setUnlinking] = useState(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 ( + +
safeBack()} />} + /> + + load(true)} + tintColor="#1d9bf0" + /> + } + > + + Every linked device has its own encryption key. Messages sent to you + are delivered to all active devices. + + + {loading ? ( + + + + ) : devices.length === 0 ? ( + + + + No devices registered yet + + + This device auto-registers when the next network-fee is available. + Top up your balance and pull to refresh. + + + ) : ( + + {devices.map((d, i) => { + const isMe = d.x25519_pub_key === meX25519; + const busy = unlinking === d.x25519_pub_key; + return ( + + {i > 0 && } + + + + + + {d.device_name || 'Unnamed device'} + + {isMe && ( + + + THIS DEVICE + + + )} + + + {shortPub(d.x25519_pub_key)} + + + Linked {formatDate(d.added_at)} + + + {!isMe && ( + 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 ? ( + + ) : ( + + Unlink + + )} + + )} + + + ); + })} + + )} + + {/* 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 + + + Pairing flow — coming in v2.2.0-alpha3 + + + + + + + ); +} diff --git a/client-app/app/(app)/settings.tsx b/client-app/app/(app)/settings.tsx index 8414a9c..687d9ab 100644 --- a/client-app/app/(app)/settings.tsx +++ b/client-app/app/(app)/settings.tsx @@ -492,6 +492,18 @@ export default function SettingsScreen() { /> + {/* ── Devices — multi-device registry (v2.2.0) ── */} + Devices + + router.push('/(app)/devices' as never)} + first + /> + + {/* ── Account ── */} Account diff --git a/client-app/lib/storage.ts b/client-app/lib/storage.ts index 1f2e94a..eac101c 100644 --- a/client-app/lib/storage.ts +++ b/client-app/lib/storage.ts @@ -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 { @@ -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 { + 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 { + await AsyncStorage.setItem(DEVICE_REGISTERED_KEY, '1'); +} + +/** clearDeviceRegistered is part of wipeAllLocalState; also called on + explicit logout. */ +export async function clearDeviceRegistered(): Promise { + 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 { + // 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); + } +}