289 lines
8.0 KiB
TypeScript
289 lines
8.0 KiB
TypeScript
/**
|
|
* EncryptID Encrypted Account Vault
|
|
*
|
|
* Stores all account data (profile, emails, devices, addresses, wallets,
|
|
* preferences) as a single JSON document, encrypted client-side with
|
|
* AES-256-GCM using the DocCrypto key hierarchy, and persisted as an
|
|
* opaque blob via the backup API. The server never sees plaintext.
|
|
*
|
|
* Key derivation:
|
|
* Master Key (from WebAuthn PRF)
|
|
* → deriveSpaceKey("__vault")
|
|
* → deriveDocKey(spaceKey, "account-vault")
|
|
* → AES-256-GCM encrypt/decrypt
|
|
*
|
|
* Any device with the user's passkey derives the same vault key
|
|
* (deterministic HKDF), so no key sync is needed.
|
|
*/
|
|
|
|
import { DocCrypto } from '../../shared/local-first/crypto';
|
|
|
|
// ============================================================================
|
|
// TYPES
|
|
// ============================================================================
|
|
|
|
export interface AccountVault {
|
|
version: 1;
|
|
profile: { displayName: string | null; avatarUrl: string | null };
|
|
emails: Array<{ address: string; verified: boolean; addedAt: number }>;
|
|
devices: Array<{ credentialIdPrefix: string; name: string; platform: string; addedAt: number }>;
|
|
addresses: Array<{
|
|
id: string; label: string; street1: string; city: string;
|
|
state: string; postalCode: string; country: string;
|
|
}>;
|
|
wallets: Array<{
|
|
id: string; safeAddress: string; chainId: number;
|
|
eoaAddress: string; label: string; addedAt: number;
|
|
}>;
|
|
preferences: Record<string, unknown>;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// CONSTANTS
|
|
// ============================================================================
|
|
|
|
const VAULT_SPACE = '__vault';
|
|
const VAULT_DOC = 'account-vault';
|
|
const CACHE_KEY = 'encryptid_vault_cache';
|
|
const TOKEN_KEY = 'encryptid_token';
|
|
|
|
// ============================================================================
|
|
// VaultManager
|
|
// ============================================================================
|
|
|
|
export class VaultManager {
|
|
private crypto: DocCrypto;
|
|
private vaultKey: CryptoKey | null = null;
|
|
private vault: AccountVault | null = null;
|
|
|
|
constructor(docCrypto: DocCrypto) {
|
|
this.crypto = docCrypto;
|
|
}
|
|
|
|
/**
|
|
* Derive the deterministic vault key.
|
|
*/
|
|
private async getVaultKey(): Promise<CryptoKey> {
|
|
if (!this.vaultKey) {
|
|
this.vaultKey = await this.crypto.deriveDocKeyDirect(VAULT_SPACE, VAULT_DOC);
|
|
}
|
|
return this.vaultKey;
|
|
}
|
|
|
|
/**
|
|
* Get the auth token for backup API calls.
|
|
*/
|
|
private getToken(): string | null {
|
|
try {
|
|
return localStorage.getItem(TOKEN_KEY);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load vault from server (or localStorage cache if offline).
|
|
* On 404: returns empty vault. Caches encrypted blob locally for offline.
|
|
*/
|
|
async load(): Promise<AccountVault> {
|
|
const key = await this.getVaultKey();
|
|
const token = this.getToken();
|
|
|
|
let packed: Uint8Array | null = null;
|
|
|
|
// Try server first
|
|
if (token) {
|
|
try {
|
|
const res = await fetch(`/api/backup/${VAULT_SPACE}/${VAULT_DOC}`, {
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
if (res.ok) {
|
|
packed = new Uint8Array(await res.arrayBuffer());
|
|
// Cache encrypted blob locally
|
|
this.cacheBlob(packed);
|
|
} else if (res.status !== 404) {
|
|
console.warn('Vault: server returned', res.status);
|
|
}
|
|
} catch (err) {
|
|
console.warn('Vault: server fetch failed, trying cache', err);
|
|
}
|
|
}
|
|
|
|
// Fallback to localStorage cache
|
|
if (!packed) {
|
|
packed = this.loadCachedBlob();
|
|
}
|
|
|
|
if (!packed || packed.length < 13) {
|
|
// No vault exists yet — return empty
|
|
this.vault = VaultManager.empty();
|
|
return this.vault;
|
|
}
|
|
|
|
// Decrypt
|
|
const blob = DocCrypto.unpack(packed);
|
|
const plainBytes = await this.crypto.decrypt(key, blob);
|
|
const json = new TextDecoder().decode(plainBytes);
|
|
this.vault = JSON.parse(json) as AccountVault;
|
|
return this.vault;
|
|
}
|
|
|
|
/**
|
|
* Save vault to server (and localStorage cache).
|
|
*/
|
|
async save(): Promise<void> {
|
|
if (!this.vault) return;
|
|
|
|
const key = await this.getVaultKey();
|
|
const token = this.getToken();
|
|
if (!token) throw new Error('Vault: not authenticated');
|
|
|
|
this.vault.updatedAt = Date.now();
|
|
|
|
const json = JSON.stringify(this.vault);
|
|
const plainBytes = new TextEncoder().encode(json);
|
|
const blob = await this.crypto.encrypt(key, plainBytes);
|
|
const packed = DocCrypto.pack(blob);
|
|
|
|
// Upload to server
|
|
const res = await fetch(`/api/backup/${VAULT_SPACE}/${VAULT_DOC}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
'Content-Type': 'application/octet-stream',
|
|
},
|
|
body: packed,
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => ({}));
|
|
throw new Error((data as any).error || `Vault save failed (${res.status})`);
|
|
}
|
|
|
|
// Update local cache
|
|
this.cacheBlob(packed);
|
|
}
|
|
|
|
/**
|
|
* Get the in-memory vault (loads from server/cache if needed).
|
|
*/
|
|
async get(): Promise<AccountVault> {
|
|
if (!this.vault) {
|
|
await this.load();
|
|
}
|
|
return this.vault!;
|
|
}
|
|
|
|
/**
|
|
* Merge partial updates into the vault and auto-save.
|
|
*/
|
|
async update(partial: Partial<Omit<AccountVault, 'version' | 'createdAt' | 'updatedAt'>>): Promise<AccountVault> {
|
|
const v = await this.get();
|
|
Object.assign(v, partial);
|
|
await this.save();
|
|
return v;
|
|
}
|
|
|
|
/**
|
|
* Check if a vault exists on the server (without downloading full blob).
|
|
*/
|
|
async exists(): Promise<boolean> {
|
|
const token = this.getToken();
|
|
if (!token) return false;
|
|
try {
|
|
const res = await fetch(`/api/backup/${VAULT_SPACE}/${VAULT_DOC}`, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': `Bearer ${token}` },
|
|
});
|
|
return res.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear in-memory state (on logout). Does NOT delete server data.
|
|
*/
|
|
clear(): void {
|
|
this.vault = null;
|
|
this.vaultKey = null;
|
|
}
|
|
|
|
// ==========================================================================
|
|
// PRIVATE: localStorage cache
|
|
// ==========================================================================
|
|
|
|
private cacheBlob(packed: Uint8Array): void {
|
|
try {
|
|
// Store as base64
|
|
const binary = String.fromCharCode(...packed);
|
|
localStorage.setItem(CACHE_KEY, btoa(binary));
|
|
} catch {
|
|
// localStorage full or unavailable
|
|
}
|
|
}
|
|
|
|
private loadCachedBlob(): Uint8Array | null {
|
|
try {
|
|
const b64 = localStorage.getItem(CACHE_KEY);
|
|
if (!b64) return null;
|
|
const binary = atob(b64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ==========================================================================
|
|
// STATIC: empty vault
|
|
// ==========================================================================
|
|
|
|
static empty(): AccountVault {
|
|
const now = Date.now();
|
|
return {
|
|
version: 1,
|
|
profile: { displayName: null, avatarUrl: null },
|
|
emails: [],
|
|
devices: [],
|
|
addresses: [],
|
|
wallets: [],
|
|
preferences: {},
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// SINGLETON
|
|
// ============================================================================
|
|
|
|
let vaultInstance: VaultManager | null = null;
|
|
|
|
/**
|
|
* Get or create the vault manager singleton.
|
|
* Requires a DocCrypto instance (from getDocBridge().getDocCrypto()).
|
|
*/
|
|
export function getVaultManager(docCrypto: DocCrypto): VaultManager {
|
|
if (!vaultInstance) {
|
|
vaultInstance = new VaultManager(docCrypto);
|
|
}
|
|
return vaultInstance;
|
|
}
|
|
|
|
/**
|
|
* Reset the vault manager (on logout).
|
|
*/
|
|
export function resetVaultManager(): void {
|
|
if (vaultInstance) {
|
|
vaultInstance.clear();
|
|
vaultInstance = null;
|
|
}
|
|
}
|