feat: add encrypted server-side account vault for EncryptID

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-03 11:13:41 -08:00
parent 91cafc92ce
commit e2e12afc96
4 changed files with 482 additions and 4 deletions

View File

@ -67,6 +67,17 @@ export {
type RecoveryRequest,
} from './recovery';
// ============================================================================
// ENCRYPTED VAULT
// ============================================================================
export {
VaultManager,
getVaultManager,
resetVaultManager,
type AccountVault,
} from './vault';
// ============================================================================
// UI COMPONENTS
// ============================================================================

View File

@ -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) => {
<div class="check-item"><div class="check-icon" id="check-email">&#9675;</div> <span id="check-email-text">Recovery email</span></div>
<div class="check-item"><div class="check-icon" id="check-device">&#9675;</div> <span id="check-device-text">Second device</span></div>
<div class="check-item"><div class="check-icon" id="check-guardians">&#9675;</div> <span id="check-guardians-text">Guardians (0/3)</span></div>
<div class="check-item"><div class="check-icon" id="check-vault">&#9675;</div> <span id="check-vault-text">Encrypted vault backup</span></div>
</div>
<div class="recovery-section">
@ -2941,6 +2942,17 @@ app.get('/', (c) => {
</div>
</div>
<div class="vault-section">
<h4>Encrypted Account Vault</h4>
<p style="font-size:0.8rem;color:#64748b;margin-bottom:0.75rem">Your account data is encrypted with your passkey and stored securely. Only you can unlock it.</p>
<div id="vault-status" style="font-size:0.8rem;padding:0.5rem 0.75rem;border-radius:0.5rem;background:rgba(255,255,255,0.04);color:#94a3b8">Status: Not synced</div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem">
<button class="btn-secondary" onclick="saveVault()">Save to Encrypted Server</button>
<button class="btn-secondary" onclick="restoreVault()">Restore from Server</button>
</div>
<div id="vault-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
</div>
<div class="profile-actions">
<a href="/demo.html" class="btn-secondary">SDK Demo</a>
<button class="btn-secondary btn-danger" onclick="handleLogout()">Sign Out</button>
@ -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 = '&#10003;';
text.textContent = 'Encrypted vault backup';
} else {
icon.className = 'check-icon todo';
icon.innerHTML = '&#9675;';
text.textContent = 'Encrypted vault backup';
}
}
// ── Checklist update ──
function updateChecklist(guardians) {

View File

@ -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();

288
src/encryptid/vault.ts Normal file
View File

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