/** * Client-side CyberLux vault accounts (no remote auth API). * Passphrases are hashed with SHA-256 + a static pepper before storage. */ const ACCOUNTS_KEY = "cyberlux-accounts-v1"; const SESSION_KEY = "cyberlux-session-v1"; const PEPPER = "cyberlux-account-pepper-v1"; export type StoredAccount = { passwordHashHex: string; displayName: string; createdAt: number; }; export type SessionPayload = { username: string; }; export type PublicCredentials = { username: string; displayName: string; createdAt: number; isAdmin?: boolean; }; function accountKey(username: string): string { return username.trim().toLowerCase(); } export async function hashPassword(password: string): Promise { const data = new TextEncoder().encode(`${PEPPER}:${password}`); const buf = await crypto.subtle.digest("SHA-256", data); return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); } function loadAccountMap(): Record { if (typeof window === "undefined") return {}; try { const raw = localStorage.getItem(ACCOUNTS_KEY); if (!raw) return {}; const o = JSON.parse(raw) as Record; return o && typeof o === "object" ? o : {}; } catch { return {}; } } function saveAccountMap(m: Record): void { if (typeof window === "undefined") return; localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(m)); } export function validateUsername(username: string): string | null { const u = username.trim(); if (u.length < 3 || u.length > 24) return "Handle must be 3–24 characters."; if (!/^[a-zA-Z0-9_]+$/.test(u)) return "Use letters, numbers, and underscores only."; return null; } export function validatePassword(password: string): string | null { if (password.length < 8) return "Passphrase must be at least 8 characters."; return null; } export async function registerAccount( username: string, password: string, displayName?: string, ): Promise<{ ok: true } | { ok: false; error: string }> { const uErr = validateUsername(username); if (uErr) return { ok: false, error: uErr }; const pErr = validatePassword(password); if (pErr) return { ok: false, error: pErr }; const key = accountKey(username); const map = loadAccountMap(); if (map[key]) return { ok: false, error: "That handle is already taken." }; const dn = (displayName?.trim() || username.trim()).slice(0, 48); const passwordHashHex = await hashPassword(password); map[key] = { passwordHashHex, displayName: dn, createdAt: Date.now(), }; saveAccountMap(map); return { ok: true }; } export async function verifyCredentials( username: string, password: string, ): Promise<{ ok: true; profile: PublicCredentials } | { ok: false; error: string }> { const key = accountKey(username); const map = loadAccountMap(); const row = map[key]; if (!row) return { ok: false, error: "Unknown handle or wrong passphrase." }; const hash = await hashPassword(password); if (hash !== row.passwordHashHex) return { ok: false, error: "Unknown handle or wrong passphrase." }; return { ok: true, profile: { username: key, displayName: row.displayName, createdAt: row.createdAt, isAdmin: key === "drjones" }, }; } export function setSession(username: string): void { if (typeof window === "undefined") return; const payload: SessionPayload = { username: accountKey(username) }; localStorage.setItem(SESSION_KEY, JSON.stringify(payload)); } export function clearSession(): void { if (typeof window === "undefined") return; localStorage.removeItem(SESSION_KEY); } export function readSession(): SessionPayload | null { if (typeof window === "undefined") return null; try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; const o = JSON.parse(raw) as SessionPayload; if (!o?.username || typeof o.username !== "string") return null; return { username: accountKey(o.username) }; } catch { return null; } } export function getPublicProfile(username: string): PublicCredentials | null { const key = accountKey(username); const map = loadAccountMap(); const row = map[key]; if (!row) return null; return { username: key, displayName: row.displayName, createdAt: row.createdAt, isAdmin: key === "drjones" }; } export function updateDisplayName(username: string, displayName: string): boolean { const key = accountKey(username); const map = loadAccountMap(); const row = map[key]; if (!row) return false; const dn = displayName.trim().slice(0, 48); if (!dn) return false; map[key] = { ...row, displayName: dn }; saveAccountMap(map); return true; } /** Portable row for copying identity to another onion hostname (each host has its own localStorage). */ export type PortableAccountBundleV1 = { v: 1; username: string; displayName: string; createdAt: number; passwordHashHex: string; }; const HASH_HEX_RE = /^[a-f0-9]{64}$/; function isPortableBundleV1(x: unknown): x is PortableAccountBundleV1 { if (!x || typeof x !== "object") return false; const o = x as Record; if (o.v !== 1) return false; if (typeof o.username !== "string" || typeof o.displayName !== "string") return false; if (typeof o.createdAt !== "number" || !Number.isFinite(o.createdAt)) return false; if (typeof o.passwordHashHex !== "string" || !HASH_HEX_RE.test(o.passwordHashHex)) return false; if (validateUsername(o.username)) return false; return true; } /** Returns JSON text for the signed-in user, or null if none. Treat like a key backup — anyone with this blob can load this identity in-browser on another host. */ export function exportPortableAccountJson(): string | null { const s = readSession(); if (!s) return null; const map = loadAccountMap(); const row = map[s.username]; if (!row) return null; const bundle: PortableAccountBundleV1 = { v: 1, username: s.username, displayName: row.displayName, createdAt: row.createdAt, passwordHashHex: row.passwordHashHex, }; return JSON.stringify(bundle); } /** * Merges a bundle from another hostname into this browser. Signs in as that user on success. * If the handle already exists here with a different passphrase hash, returns an error. */ export function importPortableAccountJson(json: string): { ok: true } | { ok: false; error: string } { let parsed: unknown; try { parsed = JSON.parse(json) as unknown; } catch { return { ok: false, error: "Not valid JSON." }; } if (!isPortableBundleV1(parsed)) return { ok: false, error: "Invalid or unsupported identity bundle." }; const key = accountKey(parsed.username); const map = loadAccountMap(); const existing = map[key]; if (existing && existing.passwordHashHex !== parsed.passwordHashHex) { return { ok: false, error: "This browser already has a different passphrase for that handle." }; } const dn = parsed.displayName.trim().slice(0, 48) || key; map[key] = { passwordHashHex: parsed.passwordHashHex, displayName: dn, createdAt: parsed.createdAt, }; saveAccountMap(map); setSession(key); return { ok: true }; }