rspace-online/src/encryptid/linked-wallets.ts

279 lines
8.6 KiB
TypeScript

/**
* EncryptID Linked Wallet Store
*
* Client-side encrypted store for externally linked wallets (MetaMask,
* Rainbow, pre-existing Safes). Follows the same AES-256-GCM pattern
* as WalletStore (wallet-store.ts).
*
* Privacy model: Server stores only encrypted blobs + keccak256(address)
* for dedup. All cleartext data is decrypted client-side only.
*/
import { encryptData, decryptDataAsString, type EncryptedData } from './key-derivation';
import { bufferToBase64url, base64urlToBuffer } from './webauthn';
// ============================================================================
// TYPES
// ============================================================================
export interface LinkedWalletEntry {
id: string;
address: string;
type: 'eoa' | 'safe';
chainIds: number[];
label: string;
isDefault: boolean;
addedAt: number;
verifiedAt: number;
providerRdns?: string;
providerName?: string;
safeInfo?: {
threshold: number;
ownerCount: number;
isEncryptIdOwner: boolean;
};
}
interface StoredLinkedWalletData {
version: 1;
wallets: LinkedWalletEntry[];
}
interface PersistedBlob {
c: string;
iv: string;
}
// ============================================================================
// CONSTANTS
// ============================================================================
const STORAGE_KEY = 'encryptid_linked_wallets';
// ============================================================================
// LINKED WALLET STORE
// ============================================================================
export class LinkedWalletStore {
private encryptionKey: CryptoKey;
private cache: LinkedWalletEntry[] | null = null;
constructor(encryptionKey: CryptoKey) {
this.encryptionKey = encryptionKey;
}
async list(): Promise<LinkedWalletEntry[]> {
if (this.cache) return [...this.cache];
const wallets = await this.load();
this.cache = wallets;
return [...wallets];
}
async getDefault(): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(w => w.isDefault) || wallets[0] || null;
}
async get(address: string): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(
w => w.address.toLowerCase() === address.toLowerCase(),
) || null;
}
async getById(id: string): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
return wallets.find(w => w.id === id) || null;
}
async add(entry: Omit<LinkedWalletEntry, 'id' | 'isDefault' | 'addedAt'> & {
isDefault?: boolean;
}): Promise<LinkedWalletEntry> {
const wallets = await this.list();
// Dedup by address
const existing = wallets.find(
w => w.address.toLowerCase() === entry.address.toLowerCase(),
);
if (existing) {
existing.label = entry.label;
existing.chainIds = entry.chainIds;
existing.providerRdns = entry.providerRdns;
existing.providerName = entry.providerName;
existing.verifiedAt = entry.verifiedAt;
if (entry.safeInfo) existing.safeInfo = entry.safeInfo;
if (entry.isDefault) {
wallets.forEach(w => w.isDefault = false);
existing.isDefault = true;
}
await this.save(wallets);
return { ...existing };
}
const wallet: LinkedWalletEntry = {
id: crypto.randomUUID(),
address: entry.address,
type: entry.type,
chainIds: entry.chainIds,
label: entry.label,
isDefault: entry.isDefault ?? wallets.length === 0,
addedAt: Date.now(),
verifiedAt: entry.verifiedAt,
providerRdns: entry.providerRdns,
providerName: entry.providerName,
safeInfo: entry.safeInfo,
};
if (wallet.isDefault) {
wallets.forEach(w => w.isDefault = false);
}
wallets.push(wallet);
await this.save(wallets);
return { ...wallet };
}
async update(id: string, updates: Partial<Pick<LinkedWalletEntry, 'label' | 'isDefault' | 'chainIds' | 'safeInfo'>>): Promise<LinkedWalletEntry | null> {
const wallets = await this.list();
const wallet = wallets.find(w => w.id === id);
if (!wallet) return null;
if (updates.label !== undefined) wallet.label = updates.label;
if (updates.chainIds !== undefined) wallet.chainIds = updates.chainIds;
if (updates.safeInfo !== undefined) wallet.safeInfo = updates.safeInfo;
if (updates.isDefault) {
wallets.forEach(w => w.isDefault = false);
wallet.isDefault = true;
}
await this.save(wallets);
return { ...wallet };
}
async remove(id: string): Promise<boolean> {
const wallets = await this.list();
const idx = wallets.findIndex(w => w.id === id);
if (idx === -1) return false;
const wasDefault = wallets[idx].isDefault;
wallets.splice(idx, 1);
if (wasDefault && wallets.length > 0) {
wallets[0].isDefault = true;
}
await this.save(wallets);
return true;
}
async clear(): Promise<void> {
this.cache = [];
try {
localStorage.removeItem(STORAGE_KEY);
} catch {}
}
// ==========================================================================
// ENCRYPTION HELPERS
// ==========================================================================
async encryptEntry(entry: LinkedWalletEntry): Promise<{ ciphertext: string; iv: string }> {
const json = JSON.stringify(entry);
const encrypted = await encryptData(this.encryptionKey, json);
return {
ciphertext: bufferToBase64url(encrypted.ciphertext),
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
};
}
async decryptEntry(ciphertext: string, iv: string): Promise<LinkedWalletEntry> {
const encrypted: EncryptedData = {
ciphertext: base64urlToBuffer(ciphertext),
iv: new Uint8Array(base64urlToBuffer(iv)),
};
const json = await decryptDataAsString(this.encryptionKey, encrypted);
return JSON.parse(json);
}
// ==========================================================================
// PRIVATE: Encrypt/Decrypt localStorage
// ==========================================================================
private async load(): Promise<LinkedWalletEntry[]> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return [];
const blob: PersistedBlob = JSON.parse(raw);
if (!blob.c || !blob.iv) return [];
const encrypted: EncryptedData = {
ciphertext: base64urlToBuffer(blob.c),
iv: new Uint8Array(base64urlToBuffer(blob.iv)),
};
const json = await decryptDataAsString(this.encryptionKey, encrypted);
const data: StoredLinkedWalletData = JSON.parse(json);
if (data.version !== 1) {
console.warn('EncryptID LinkedWalletStore: Unknown schema version', data.version);
return [];
}
return data.wallets;
} catch (err) {
console.warn('EncryptID LinkedWalletStore: Failed to load wallets', err);
return [];
}
}
private async save(wallets: LinkedWalletEntry[]): Promise<void> {
this.cache = [...wallets];
const data: StoredLinkedWalletData = { version: 1, wallets };
const json = JSON.stringify(data);
const encrypted = await encryptData(this.encryptionKey, json);
const blob: PersistedBlob = {
c: bufferToBase64url(encrypted.ciphertext),
iv: bufferToBase64url(encrypted.iv.buffer as ArrayBuffer),
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(blob));
} catch (err) {
console.error('EncryptID LinkedWalletStore: Failed to save wallets', err);
}
}
}
// ============================================================================
// SINGLETON
// ============================================================================
let linkedWalletStoreInstance: LinkedWalletStore | null = null;
export function getLinkedWalletStore(encryptionKey: CryptoKey): LinkedWalletStore {
if (!linkedWalletStoreInstance) {
linkedWalletStoreInstance = new LinkedWalletStore(encryptionKey);
}
return linkedWalletStoreInstance;
}
export function resetLinkedWalletStore(): void {
linkedWalletStoreInstance = null;
}
// ============================================================================
// UTILITIES
// ============================================================================
export async function hashAddress(address: string, userId?: string): Promise<string> {
// Salt with userId to prevent cross-user address correlation
const normalized = (userId ? userId + ':' : '') + address.toLowerCase();
const encoded = new TextEncoder().encode(normalized);
const hash = await crypto.subtle.digest('SHA-256', encoded);
return bufferToBase64url(hash);
}