279 lines
8.6 KiB
TypeScript
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);
|
|
}
|