From e2e12afc965c526892a866a0d7d396a9a2448e62 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 3 Mar 2026 11:13:41 -0800 Subject: [PATCH] feat: add encrypted server-side account vault for EncryptID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-knowledge vault stores account data (profile, emails, devices, wallets, preferences) as AES-256-GCM encrypted blob via backup API. Key derived from WebAuthn PRF — server never sees plaintext. Dashboard UI with save/restore buttons triggers passkey re-auth for encryption. Co-Authored-By: Claude Opus 4.6 --- src/encryptid/index.ts | 11 ++ src/encryptid/server.ts | 169 +++++++++++++++++- src/encryptid/ui/login-button.ts | 18 +- src/encryptid/vault.ts | 288 +++++++++++++++++++++++++++++++ 4 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 src/encryptid/vault.ts diff --git a/src/encryptid/index.ts b/src/encryptid/index.ts index 7886fc5..7992dee 100644 --- a/src/encryptid/index.ts +++ b/src/encryptid/index.ts @@ -67,6 +67,17 @@ export { type RecoveryRequest, } from './recovery'; +// ============================================================================ +// ENCRYPTED VAULT +// ============================================================================ + +export { + VaultManager, + getVaultManager, + resetVaultManager, + type AccountVault, +} from './vault'; + // ============================================================================ // UI COMPONENTS // ============================================================================ diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 85a31ac..2e36825 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -2759,8 +2759,8 @@ app.get('/', (c) => { .passkey-date { color: #64748b; } /* Recovery email */ - .recovery-section, .guardians-section, .devices-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } - .recovery-section h4, .guardians-section h4, .devices-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } + .recovery-section, .guardians-section, .devices-section, .vault-section { margin-top: 1.5rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.08); } + .recovery-section h4, .guardians-section h4, .devices-section h4, .vault-section h4 { font-size: 0.9rem; margin-bottom: 0.75rem; color: #94a3b8; } .recovery-row { display: flex; gap: 0.5rem; } .recovery-row input { flex: 1; } .recovery-row button { white-space: nowrap; } @@ -2891,6 +2891,7 @@ app.get('/', (c) => {
Recovery email
Second device
Guardians (0/3)
+
Encrypted vault backup
@@ -2941,6 +2942,17 @@ app.get('/', (c) => {
+
+

Encrypted Account Vault

+

Your account data is encrypted with your passkey and stored securely. Only you can unlock it.

+
Status: Not synced
+
+ + +
+ +
+
SDK Demo @@ -2995,6 +3007,7 @@ app.get('/', (c) => { getKeyManager, getSessionManager, detectCapabilities, + authenticatePasskey, } from '/dist/index.js'; const TOKEN_KEY = 'encryptid_token'; @@ -3198,8 +3211,9 @@ app.get('/', (c) => { } } catch { /* ignore */ } - // Load guardians + // Load guardians and check vault status loadGuardians(); + checkVaultStatus(); } window.handleLogout = () => { @@ -3389,6 +3403,155 @@ app.get('/', (c) => { } } + // ── Vault helpers ── + + const VAULT_SPACE = '__vault'; + const VAULT_DOC = 'account-vault'; + const enc = new TextEncoder(); + + async function deriveVaultKey(prfOutput) { + // Import PRF output as HKDF material + const master = await crypto.subtle.importKey('raw', prfOutput, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']); + // Derive space key: info = "rspace:__vault" + const spaceBits = await crypto.subtle.deriveBits( + { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-space-key-v1'), info: enc.encode('rspace:' + VAULT_SPACE) }, + master, 256 + ); + const spaceKey = await crypto.subtle.importKey('raw', spaceBits, { name: 'HKDF' }, false, ['deriveKey', 'deriveBits']); + // Derive doc key: info = "doc:account-vault" + return crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: enc.encode('rspace-doc-key-v1'), info: enc.encode('doc:' + VAULT_DOC) }, + spaceKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] + ); + } + + function vaultMsg(text, ok) { + const el = document.getElementById('vault-msg'); + el.textContent = text; + el.style.color = ok ? '#86efac' : '#fca5a5'; + el.style.display = 'block'; + } + + function gatherVaultData() { + const username = document.getElementById('profile-username')?.textContent || null; + const email = document.getElementById('recovery-email')?.value?.trim() || null; + return { + version: 1, + profile: { displayName: username, avatarUrl: null }, + emails: email ? [{ address: email, verified: false, addedAt: Date.now() }] : [], + devices: [], + addresses: [], + wallets: [], + preferences: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + } + + window.saveVault = async () => { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) { vaultMsg('Not authenticated', false); return; } + try { + vaultMsg('Authenticate with your passkey to encrypt...', true); + const result = await authenticatePasskey(); + if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; } + + const key = await deriveVaultKey(result.prfOutput); + const vault = gatherVaultData(); + const json = new TextEncoder().encode(JSON.stringify(vault)); + const nonce = crypto.getRandomValues(new Uint8Array(12)); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, key, json)); + // Pack: [12-byte nonce][ciphertext] + const packed = new Uint8Array(12 + ct.length); + packed.set(nonce, 0); + packed.set(ct, 12); + + 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) throw new Error('Server returned ' + res.status); + vaultMsg('Vault saved and encrypted successfully.', true); + updateVaultChecklist(true); + document.getElementById('vault-status').textContent = 'Status: Synced — ' + new Date().toLocaleString(); + } catch (err) { + if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; } + vaultMsg('Save failed: ' + err.message, false); + } + }; + + window.restoreVault = async () => { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) { vaultMsg('Not authenticated', false); return; } + try { + vaultMsg('Authenticate with your passkey to decrypt...', true); + const result = await authenticatePasskey(); + if (!result.prfOutput) { vaultMsg('PRF not available — vault requires a PRF-capable passkey', false); return; } + + const blobRes = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, { + headers: { 'Authorization': 'Bearer ' + token }, + }); + if (blobRes.status === 404) { vaultMsg('No vault backup found on server.', false); return; } + if (!blobRes.ok) throw new Error('Server returned ' + blobRes.status); + + const packed = new Uint8Array(await blobRes.arrayBuffer()); + if (packed.length < 13) throw new Error('Invalid vault data'); + const nonce = packed.slice(0, 12); + const ct = packed.slice(12); + + const key = await deriveVaultKey(result.prfOutput); + const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct); + const vault = JSON.parse(new TextDecoder().decode(plainBuf)); + + // Populate DOM + if (vault.profile?.displayName) { + const el = document.getElementById('profile-username'); + if (el) el.textContent = vault.profile.displayName; + } + if (vault.emails?.length > 0) { + const el = document.getElementById('recovery-email'); + if (el) el.value = vault.emails[0].address; + } + vaultMsg('Vault restored successfully. ' + (vault.emails?.length || 0) + ' email(s), ' + (vault.wallets?.length || 0) + ' wallet(s).', true); + updateVaultChecklist(true); + document.getElementById('vault-status').textContent = 'Status: Restored — ' + new Date(vault.updatedAt).toLocaleString(); + } catch (err) { + if (err.name === 'NotAllowedError') { vaultMsg('Passkey authentication cancelled.', false); return; } + vaultMsg('Restore failed: ' + err.message, false); + } + }; + + async function checkVaultStatus() { + const token = localStorage.getItem(TOKEN_KEY); + if (!token) return; + try { + const res = await fetch('/api/backup/' + VAULT_SPACE + '/' + VAULT_DOC, { + headers: { 'Authorization': 'Bearer ' + token }, + }); + if (res.ok) { + updateVaultChecklist(true); + document.getElementById('vault-status').textContent = 'Status: Backup exists on server'; + } else { + updateVaultChecklist(false); + } + } catch { /* offline or error */ } + } + + function updateVaultChecklist(exists) { + const icon = document.getElementById('check-vault'); + const text = document.getElementById('check-vault-text'); + if (exists) { + icon.className = 'check-icon done'; + icon.innerHTML = '✓'; + text.textContent = 'Encrypted vault backup'; + } else { + icon.className = 'check-icon todo'; + icon.innerHTML = '○'; + text.textContent = 'Encrypted vault backup'; + } + } + // ── Checklist update ── function updateChecklist(guardians) { diff --git a/src/encryptid/ui/login-button.ts b/src/encryptid/ui/login-button.ts index 5b40f6b..36e7975 100644 --- a/src/encryptid/ui/login-button.ts +++ b/src/encryptid/ui/login-button.ts @@ -15,6 +15,7 @@ import { import { getKeyManager } from '../key-derivation'; import { getSessionManager, AuthLevel } from '../session'; import { getDocBridge, resetDocBridge } from '../../../shared/local-first/encryptid-bridge'; +import { getVaultManager, resetVaultManager } from '../vault'; // ============================================================================ // STYLES @@ -411,6 +412,13 @@ export class EncryptIDLoginButton extends HTMLElement { await keyManager.initFromPRF(result.prfOutput); // Initialize doc encryption bridge for local-first encrypted storage await getDocBridge().initFromAuth(result.prfOutput); + // Load encrypted account vault in background + const docCrypto = getDocBridge().getDocCrypto(); + if (docCrypto) { + getVaultManager(docCrypto).load().catch(err => + console.warn('Vault load failed:', err) + ); + } } // Get derived keys @@ -505,7 +513,8 @@ export class EncryptIDLoginButton extends HTMLElement { const keyManager = getKeyManager(); keyManager.clear(); - // Clear doc encryption bridge key material + // Clear vault and doc encryption bridge key material + resetVaultManager(); resetDocBridge(); this.dispatchEvent(new CustomEvent('logout', { bubbles: true })); @@ -522,6 +531,13 @@ export class EncryptIDLoginButton extends HTMLElement { if (result.prfOutput) { await keyManager.initFromPRF(result.prfOutput); await getDocBridge().initFromAuth(result.prfOutput); + // Load encrypted account vault in background + const docCrypto = getDocBridge().getDocCrypto(); + if (docCrypto) { + getVaultManager(docCrypto).load().catch(err => + console.warn('Vault load failed:', err) + ); + } } const keys = await keyManager.getKeys(); diff --git a/src/encryptid/vault.ts b/src/encryptid/vault.ts new file mode 100644 index 0000000..db75384 --- /dev/null +++ b/src/encryptid/vault.ts @@ -0,0 +1,288 @@ +/** + * 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; + 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 { + 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 { + 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 { + 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 { + if (!this.vault) { + await this.load(); + } + return this.vault!; + } + + /** + * Merge partial updates into the vault and auto-save. + */ + async update(partial: Partial>): Promise { + 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 { + 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; + } +}