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.
162 lines
6.4 KiB
TypeScript
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);
|
|
}
|
|
}
|