Harden onion boot flow and deepen site surfaces
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
This commit is contained in:
219
lib/cyberluxAccount.ts
Normal file
219
lib/cyberluxAccount.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user