Add persistent onion key backup and restore, improve startup resilience, and flesh out the major site verticals with richer navigation, search coverage, and operator documentation. Made-with: Cursor
220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
/**
|
||
* 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;
|
||
};
|
||
|
||
function accountKey(username: string): string {
|
||
return username.trim().toLowerCase();
|
||
}
|
||
|
||
export async function hashPassword(password: string): Promise<string> {
|
||
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<string, StoredAccount> {
|
||
if (typeof window === "undefined") return {};
|
||
try {
|
||
const raw = localStorage.getItem(ACCOUNTS_KEY);
|
||
if (!raw) return {};
|
||
const o = JSON.parse(raw) as Record<string, StoredAccount>;
|
||
return o && typeof o === "object" ? o : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function saveAccountMap(m: Record<string, StoredAccount>): 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 },
|
||
};
|
||
}
|
||
|
||
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 };
|
||
}
|
||
|
||
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<string, unknown>;
|
||
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 };
|
||
}
|