/** * The app's own accounts: an email and a password, for people who have no * forge account. Whoever signs in this way is a `user` — exactly what the * forge would have signed for a person allowed to see the app — with an id * of `acct:`, which no forge id can be, so the contract treats both kinds * of person the same and their vaults never meet. Admin is only ever the * forge's word. * * Passwords are hashed with argon2id (`Bun.password`). A session is a random * token in an HttpOnly cookie; only its SHA-256 is stored, so a copy of the * database opens nobody's vault. Signing in replaces nothing — each device * has its own session — and signing out ends that one. * * If `NOTES_SIGNUP_CODE` is set (Settings → App → Environment), creating an * account needs it: the switch for closing sign-up without a deploy. */ import type { Database } from "bun:sqlite"; import { createHash, randomBytes } from "node:crypto"; import { record } from "./audit.ts"; import type { Result } from "./actions.ts"; import type { Who } from "./who.ts"; export const SESSION_DAYS = 30; export const PASSWORD_MIN = 8; export const PASSWORD_MAX = 200; const NAME_MAX = 60; export interface Account { readonly id: number; readonly email: string; readonly name: string; readonly created_at: string; } const ok = (value: T): Result => ({ ok: true, value }); const fail = (error: "invalid" | "forbidden" | "signin_required", message: string): Result => ({ ok: false, error, message, }); /** The person an account signs in as. */ export const whoOf = (a: Pick): Who & { role: "user" } => ({ role: "user", id: `acct:${a.id}`, name: a.name, }); /** Whether `who` signed in with the app's own account (and not the forge). */ export const isAccount = (who: Who): who is Who & { role: "user" } => who.role !== "anon" && who.id.startsWith("acct:"); const digest = (token: string): string => createHash("sha256").update(token).digest("hex"); const cleanEmail = (raw: string): string => raw.trim().toLowerCase(); const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,63}$/; // Spent on a sign-in for an email nobody has, so it takes as long as a real one. let decoy: string | undefined; const decoyHash = async (): Promise => (decoy ??= await Bun.password.hash("not a password at all")); function checkPassword(password: string): Result { if (password.length < PASSWORD_MIN) return fail("invalid", `A password is at least ${PASSWORD_MIN} characters.`); if (password.length > PASSWORD_MAX) return fail("invalid", `A password is at most ${PASSWORD_MAX} characters.`); return ok(password); } /** A fresh session for an account: the token for the cookie, and only its hash kept. */ function startSession(db: Database, accountId: number): string { const token = randomBytes(32).toString("base64url"); db.query( `INSERT INTO session (token_hash, account_id, expires_at) VALUES (?1, ?2, strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '+${SESSION_DAYS} days'))`, ).run(digest(token), accountId); // Expired sessions go as new ones come. db.query("DELETE FROM session WHERE expires_at < strftime('%Y-%m-%dT%H:%M:%fZ', 'now')").run(); return token; } export async function signUp( db: Database, input: { email: string; password: string; name?: string; code?: string }, signupCode = process.env.NOTES_SIGNUP_CODE ?? "", ): Promise> { const anon: Who = { role: "anon" }; if (signupCode !== "" && (input.code ?? "").trim() !== signupCode) { record(db, { who: anon, resource: "account", verb: "create", outcome: "forbidden" }); return fail("forbidden", "That sign-up code isn't right."); } const email = cleanEmail(input.email); if (!EMAIL.test(email)) return fail("invalid", "That doesn't look like an email address."); const password = checkPassword(input.password); if (!password.ok) return password; const name = (input.name ?? "").replace(/\s+/g, " ").trim().slice(0, NAME_MAX) || email.split("@")[0]!; const taken = db.query<{ id: number }, [string]>("SELECT id FROM account WHERE email = ?1").get(email); if (taken !== null) return fail("invalid", "There's already an account with that email. Log in instead."); const hash = await Bun.password.hash(password.value); const account = db .query( "INSERT INTO account (email, name, password_hash) VALUES (?1, ?2, ?3) RETURNING id, email, name, created_at", ) .get(email, name, hash)!; record(db, { who: whoOf(account), resource: "account", verb: "create", target: account.id, outcome: "ok" }); return ok({ account, token: startSession(db, account.id) }); } export async function logIn( db: Database, input: { email: string; password: string }, ): Promise> { const email = cleanEmail(input.email); const row = db .query("SELECT * FROM account WHERE email = ?1") .get(email); const good = await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row?.password_hash ?? (await decoyHash())); if (row === null || !good) { record(db, { who: { role: "anon" }, resource: "session", verb: "create", outcome: "forbidden" }); return fail("forbidden", "That email and password don't match an account."); } const { password_hash: _, ...account } = row; record(db, { who: whoOf(account), resource: "session", verb: "create", target: account.id, outcome: "ok" }); return ok({ account, token: startSession(db, account.id) }); } /** The person a session cookie belongs to, or anonymous. */ export function sessionWho(db: Database, token: string | undefined): Who { if (token === undefined || token.length < 20 || token.length > 100) return { role: "anon" }; const row = db .query<{ id: number; name: string }, [string]>( `SELECT a.id, a.name FROM session s JOIN account a ON a.id = s.account_id WHERE s.token_hash = ?1 AND s.expires_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now')`, ) .get(digest(token)); return row === null ? { role: "anon" } : whoOf(row); } export function logOut(db: Database, token: string | undefined): void { if (token !== undefined) db.query("DELETE FROM session WHERE token_hash = ?1").run(digest(token)); } const accountIdOf = (who: Who): number | undefined => isAccount(who) ? Number(who.id.slice("acct:".length)) : undefined; export function getAccount(db: Database, who: Who): Account | undefined { const id = accountIdOf(who); if (id === undefined) return undefined; return db.query("SELECT id, email, name, created_at FROM account WHERE id = ?1").get(id) ?? undefined; } /** A new password, given the current one. Every other session ends; this device gets a fresh one. */ export async function changePassword( db: Database, who: Who, input: { current: string; next: string }, ): Promise> { const id = accountIdOf(who); if (id === undefined) return fail("signin_required", "Log in with an email account to change its password."); const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id); if (row === null || !(await Bun.password.verify(input.current.slice(0, PASSWORD_MAX), row.password_hash))) { record(db, { who, resource: "account", verb: "update", target: id, outcome: "forbidden" }); return fail("forbidden", "Your current password isn't right."); } const next = checkPassword(input.next); if (!next.ok) return next; db.query("UPDATE account SET password_hash = ?1 WHERE id = ?2").run(await Bun.password.hash(next.value), id); db.query("DELETE FROM session WHERE account_id = ?1").run(id); record(db, { who, resource: "account", verb: "update", target: id, outcome: "ok" }); return ok({ token: startSession(db, id) }); } /** The account, its sessions and every note in its vault — gone, given the password. */ export async function deleteAccount( db: Database, who: Who, input: { password: string }, ): Promise> { const id = accountIdOf(who); if (id === undefined) return fail("signin_required", "Log in with an email account to delete it."); const row = db.query<{ password_hash: string }, [number]>("SELECT password_hash FROM account WHERE id = ?1").get(id); if (row === null || !(await Bun.password.verify(input.password.slice(0, PASSWORD_MAX), row.password_hash))) { record(db, { who, resource: "account", verb: "delete", target: id, outcome: "forbidden" }); return fail("forbidden", "That password isn't right."); } const notes = db.transaction(() => { const gone = db.query("DELETE FROM note WHERE author_id = ?1").run(`acct:${id}`).changes; db.query("DELETE FROM session WHERE account_id = ?1").run(id); db.query("DELETE FROM space_member WHERE who_id = ?1").run(`acct:${id}`); db.query("DELETE FROM account WHERE id = ?1").run(id); return gone; })(); record(db, { who, resource: "account", verb: "delete", target: id, outcome: "ok" }); return ok({ notes }); }