rspace-online/src/encryptid/vault.ts

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;
}
}