A passphrase-locked, end-to-end encrypted item store driven by useEncryptedVault — AES-GCM ciphertext at rest in IndexedDB, the key derived from the passphrase in memory only — with encrypted notes and imported text documents rendered as lock-aware vault-item-cards (decrypt-on-view + delete), a lock-status badge, a manual Lock that clears the in-memory key, a locked state that survives reload, and a tamper-evident hash-chained audit log (createAuditLog) with in-UI chain verification and JSONL export. Web Crypto + IndexedDB only — no models download. The IndexedDB vault name is kept at 'privacy-vault' for storage-key stability across the category rename. Split out of the tabbed privacy-vault Vault tab (split-image-privacy).
src/app/blocks/privacy/encrypted-vault/encrypted-vault.tsx
'use client';
/**
* @file encrypted-vault.tsx
* @description Encrypted Vault block (`/blocks/privacy/encrypted-vault`) — passphrase-locked AES-GCM item store with a tamper-evident hash-chained audit log.
*/
import { useEffect, useRef, useState } from 'react';
import { FileUp, Lock, Plus, ShieldCheck, Download, ListChecks } from 'lucide-react';
import {
createAuditLog,
exportAuditLog,
type AuditLog,
} from '@localmode/core';
import { useEncryptedVault, useAuditLog } from '@localmode/react';
import { PassphraseGate } from '@/components/passphrase-gate';
import { VaultItemCard } from '@/components/vault-item-card';
import { LockStatusBadge } from '@/components/lock-status-badge';
import { cn } from '@/lib/utils';
import type { StrengthColor } from '@/components/password-strength-bar';
const MIN_PASSPHRASE_LENGTH = 8;
const VAULT_NAME = 'privacy-vault';
interface PassphraseStrength {
value: number;
label?: string;
color: StrengthColor;
}
function estimateStrength(passphrase: string): PassphraseStrength {
const classes =
(/[a-z]/.test(passphrase) ? 1 : 0) +
(/[A-Z]/.test(passphrase) ? 1 : 0) +
(/[0-9]/.test(passphrase) ? 1 : 0) +
(/[^a-zA-Z0-9]/.test(passphrase) ? 1 : 0);
const lengthScore = Math.min(passphrase.length, 16) / 16;
const value = Math.round(lengthScore * 60 + (classes / 4) * 40);
return {
value,
label:
passphrase.length === 0 ? undefined : value < 40 ? 'Weak' : value < 70 ? 'Good' : 'Strong',
color: value < 40 ? 'error' : value < 70 ? 'warning' : 'success',
};
}
function formatTimestamp(ms: number): string {
return new Date(ms).toLocaleString();
}
interface VaultData {
title: string;
content: string;
kind: 'note' | 'document';
}
export function EncryptedVaultBlock() {
const vault = useEncryptedVault<VaultData>({ name: VAULT_NAME });
const { status, items, error, isBusy, unlock, lock, createItem, deleteItem } = vault;
const [auditLog, setAuditLog] = useState<AuditLog | null>(null);
const audit = useAuditLog(auditLog);
useEffect(() => {
let live = true;
let created: AuditLog | null = null;
createAuditLog({ name: VAULT_NAME })
.then((log) => {
if (live) {
created = log;
setAuditLog(log);
} else {
void log.close();
}
})
.catch(() => {});
return () => {
live = false;
void created?.close();
};
}, []);
const logChainRef = useRef<Promise<unknown>>(Promise.resolve());
const logEvent = (kind: string, payload: unknown): Promise<unknown> => {
logChainRef.current = logChainRef.current
.then(() => (auditLog ? audit.append(kind, payload) : undefined))
.catch(() => {});
return logChainRef.current;
};
const [strength, setStrength] = useState<PassphraseStrength>({ value: 0, color: 'error' });
const [gateError, setGateError] = useState<string | undefined>(undefined);
const [revealedId, setRevealedId] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const mode: 'create' | 'unlock' = status === 'uninitialized' ? 'create' : 'unlock';
const lockStatus = status === 'unlocked' ? 'unlocked' : status === 'locked' ? 'locked' : 'no-vault';
const handleSubmitPassphrase = async (passphrase: string) => {
setGateError(undefined);
const wasUninitialized = status === 'uninitialized';
const ok = await unlock(passphrase);
if (ok) {
if (wasUninitialized) logEvent('vault.created', { name: VAULT_NAME });
logEvent('vault.unlocked', { name: VAULT_NAME });
} else {
setGateError('Incorrect passphrase. Please try again.');
logEvent('vault.unlock_failed', { name: VAULT_NAME });
}
};
const handleLock = () => {
lock();
setRevealedId(null);
logEvent('vault.locked', { name: VAULT_NAME });
};
const handleReveal = (id: string, itemTitle: string) => {
setRevealedId(id);
logEvent('item.viewed', { itemId: id, title: itemTitle });
};
const handleAddNote = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !content.trim()) return;
const item = await createItem({ title: title.trim(), content: content.trim(), kind: 'note' });
if (item) {
logEvent('item.added', { itemId: item.id, title: item.data.title });
setTitle('');
setContent('');
}
};
const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const text = await file.text();
const item = await createItem({ title: file.name, content: text, kind: 'document' });
if (item) logEvent('item.added', { itemId: item.id, title: item.data.title });
if (fileRef.current) fileRef.current.value = '';
};
const handleDelete = async (id: string, itemTitle: string) => {
const ok = await deleteItem(id);
if (ok) {
if (revealedId === id) setRevealedId(null);
logEvent('item.deleted', { itemId: id, title: itemTitle });
}
setPendingDelete(null);
};
const verifyChainResult = async () => {
await audit.verify();
};
const exportAudit = async () => {
if (!auditLog) return;
const chunks: string[] = [];
for await (const line of exportAuditLog(auditLog)) chunks.push(line);
const blob = new Blob(chunks, { type: 'application/x-ndjson' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'privacy-vault-audit.jsonl';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
const wrongPassphrase =
error?.name === 'VaultPassphraseError' ? 'Incorrect passphrase. Please try again.' : gateError;
return (
<div className="flex flex-col gap-4 p-4">
<p className="text-xs text-muted-foreground">
A passphrase-locked, end-to-end encrypted item store with a hash-chained audit log (Web Crypto only).
</p>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">Encrypted vault</span>
<LockStatusBadge status={lockStatus} />
</div>
{status !== 'unlocked' ? (
<PassphraseGate
className="mx-auto"
mode={mode}
isBusy={isBusy}
minLength={MIN_PASSPHRASE_LENGTH}
strength={strength}
error={wrongPassphrase}
onPassphraseChange={(v) => setStrength(estimateStrength(v))}
onSubmit={handleSubmitPassphrase}
title={mode === 'create' ? 'Create your vault' : 'Unlock your vault'}
description={
mode === 'create'
? 'Choose a passphrase. It derives the encryption key (in memory only) - it is never stored.'
: 'Enter your passphrase to decrypt your items.'
}
/>
) : (
<>
{}
<form onSubmit={handleAddNote} className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
<label htmlFor="vault-note-title" className="sr-only">
Note title
</label>
<input
id="vault-note-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<label htmlFor="vault-note-content" className="sr-only">
Secret content
</label>
<textarea
id="vault-note-content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
placeholder="Secret content: encrypted with AES-GCM before it is stored"
className="w-full resize-y rounded-md border border-input bg-background p-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={!title.trim() || !content.trim() || isBusy}
className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus className="size-4" aria-hidden="true" /> Add note
</button>
<button
type="button"
onClick={() => fileRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<FileUp className="size-4" aria-hidden="true" /> Import .txt
</button>
<label htmlFor="vault-import-file" className="sr-only">
Import a text document
</label>
<input
ref={fileRef}
id="vault-import-file"
type="file"
accept=".txt,text/plain"
onChange={handleImport}
className="hidden"
/>
<button
type="button"
onClick={handleLock}
className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Lock className="size-4" aria-hidden="true" /> Lock
</button>
</div>
</form>
{}
<div className="flex flex-col gap-3">
{items.length === 0 ? (
<p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground">
No items yet. Add an encrypted note or import a text document.
</p>
) : (
items.map((item) => (
<div key={item.id} role="group" aria-label={item.data.title} data-item-id={item.id}>
<VaultItemCard
title={item.data.title}
kind={item.data.kind}
createdAt={formatTimestamp(item.createdAt)}
locked={false}
revealed={revealedId === item.id}
content={revealedId === item.id ? item.data.content : undefined}
onReveal={() => handleReveal(item.id, item.data.title)}
onHide={() => setRevealedId(null)}
onDelete={() => setPendingDelete({ id: item.id, title: item.data.title })}
/>
{pendingDelete?.id === item.id && (
<div
role="alertdialog"
aria-label={`Delete ${item.data.title}`}
className="mt-2 flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3"
>
<p className="text-sm text-foreground">
Permanently delete <span className="font-medium">{item.data.title}</span>? The
encrypted contents are destroyed and cannot be recovered.
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleDelete(item.id, item.data.title)}
className="inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Delete permanently
</button>
<button
type="button"
autoFocus
onClick={() => setPendingDelete(null)}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Cancel
</button>
</div>
</div>
)}
</div>
))
)}
</div>
</>
)}
{}
<div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-1.5 whitespace-nowrap text-sm font-medium">
<ListChecks className="size-4 text-muted-foreground" aria-hidden="true" /> Audit log
<span className="text-muted-foreground">({audit.entries.length})</span>
</h2>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={verifyChainResult}
disabled={audit.entries.length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ShieldCheck className="size-3.5" aria-hidden="true" /> Verify chain
</button>
<button
type="button"
onClick={exportAudit}
disabled={audit.entries.length === 0}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Download className="size-3.5" aria-hidden="true" /> Export JSONL
</button>
</div>
</div>
{audit.lastVerification && (
<p
role="status"
data-ok={audit.lastVerification.ok}
className={cn(
'text-xs font-medium',
audit.lastVerification.ok
? 'text-emerald-600 dark:text-emerald-400'
: 'text-red-600 dark:text-red-400',
)}
>
{audit.lastVerification.ok
? `Chain valid: ${audit.lastVerification.entriesChecked} entries verified.`
: `Chain broken at entry ${audit.lastVerification.brokenAt} (${audit.lastVerification.reason}).`}
</p>
)}
<ol aria-label="Audit log entries" className="flex flex-col gap-1 text-xs">
{audit.entries.length === 0 ? (
<li className="text-muted-foreground">No audit entries yet.</li>
) : (
audit.entries.map((entry) => (
<li
key={entry.id}
data-audit-kind={entry.kind}
className="flex items-center gap-2 font-mono"
>
<span className="text-muted-foreground">{formatTimestamp(entry.timestamp)}</span>
<span className="font-medium">{entry.kind}</span>
</li>
))
)}
</ol>
</div>
</div>
);
}