Files
dchain/client-app/lib/storage.ts
vsecoder 8940b97cc6 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.
2026-04-22 16:28:16 +03:00

162 lines
6.4 KiB
TypeScript

/**
* Persistent storage for keys and app settings.
* On mobile: expo-secure-store for key material, AsyncStorage for settings.
* On web: falls back to localStorage (dev only).
*/
import * as SecureStore from 'expo-secure-store';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { KeyFile, Contact, NodeSettings } from './types';
// ─── Keys ─────────────────────────────────────────────────────────────────────
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> {
await SecureStore.setItemAsync(KEYFILE_KEY, JSON.stringify(kf));
}
/** Load key file. Returns null if not set. */
export async function loadKeyFile(): Promise<KeyFile | null> {
const raw = await SecureStore.getItemAsync(KEYFILE_KEY);
if (!raw) return null;
return JSON.parse(raw) as KeyFile;
}
/** Delete key file (logout / factory reset). */
export async function deleteKeyFile(): Promise<void> {
await SecureStore.deleteItemAsync(KEYFILE_KEY);
}
// ─── Node settings ─────────────────────────────────────────────────────────────
const DEFAULT_SETTINGS: NodeSettings = {
nodeUrl: 'http://localhost:8081',
contractId: '',
};
export async function loadSettings(): Promise<NodeSettings> {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return DEFAULT_SETTINGS;
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
}
export async function saveSettings(s: Partial<NodeSettings>): Promise<void> {
const current = await loadSettings();
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...current, ...s }));
}
// ─── Contacts ─────────────────────────────────────────────────────────────────
export async function loadContacts(): Promise<Contact[]> {
const raw = await AsyncStorage.getItem(CONTACTS_KEY);
if (!raw) return [];
return JSON.parse(raw) as Contact[];
}
export async function saveContact(c: Contact): Promise<void> {
const contacts = await loadContacts();
const idx = contacts.findIndex(x => x.address === c.address);
if (idx >= 0) contacts[idx] = c;
else contacts.push(c);
await AsyncStorage.setItem(CONTACTS_KEY, JSON.stringify(contacts));
}
export async function deleteContact(address: string): Promise<void> {
const contacts = await loadContacts();
await AsyncStorage.setItem(
CONTACTS_KEY,
JSON.stringify(contacts.filter(c => c.address !== address)),
);
}
// ─── Message cache (per-chat local store) ────────────────────────────────────
export interface CachedMessage {
id: string;
from: string;
text: string;
timestamp: number;
mine: boolean;
}
export async function loadMessages(chatId: string): Promise<CachedMessage[]> {
const raw = await AsyncStorage.getItem(`${CHATS_KEY}_${chatId}`);
if (!raw) return [];
return JSON.parse(raw) as CachedMessage[];
}
export async function appendMessage(chatId: string, msg: CachedMessage): Promise<void> {
const msgs = await loadMessages(chatId);
// Deduplicate by id
if (msgs.find(m => m.id === msg.id)) return;
msgs.push(msg);
// Keep last 500 messages per chat
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);
}
}