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:
@@ -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