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) => {
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;
+ }
+}