import { and, desc, eq, isNull } from 'drizzle-orm'; import argon2 from 'argon2'; import { randomBytes } from 'node:crypto'; import { db } from 'officerdb'; import { davAppPasswords } from './schema'; export type DavAppPassword = typeof davAppPasswords.$inferSelect; /** What the UI is allowed to see: everything except the hash. */ export type DavAppPasswordView = Omit; const view = (row: DavAppPassword): DavAppPasswordView => { const { passwordHash: _hash, ...rest } = row; return rest; }; // Base32-ish over an unambiguous alphabet: no 0/O/1/I/l, because this gets read off a screen and typed // into a phone by hand. Grouped into blocks of four for the same reason. const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; function generateSecret(): string { const bytes = randomBytes(20); // 20 bytes over a 31-char alphabet ≈ 99 bits. Well past anything Basic auth over TLS needs, and it // still fits in five readable blocks. const chars = Array.from(bytes, (b) => ALPHABET[b % ALPHABET.length]).join(''); return (chars.match(/.{1,4}/g) ?? [chars]).join('-'); } export async function listDavAppPasswords(userId: number): Promise { const rows = await db .select() .from(davAppPasswords) .where(eq(davAppPasswords.userId, userId)) .orderBy(desc(davAppPasswords.createdAt)); return rows.map(view); } /** * Mint a credential. The plaintext is returned HERE AND NOWHERE ELSE — it is not stored, so this return * value is the only chance the owner gets to see it. */ export async function createDavAppPassword( userId: number, label: string, ): Promise<{ entry: DavAppPasswordView; password: string }> { const password = generateSecret(); const [row] = await db .insert(davAppPasswords) .values({ userId, label, passwordHash: await argon2.hash(password), hint: password.slice(0, 8), }) .returning(); if (!row) throw new Error('failed to create dav app password'); return { entry: view(row), password }; } export async function revokeDavAppPassword(userId: number, id: number): Promise { const [row] = await db .update(davAppPasswords) .set({ revokedAt: new Date() }) .where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt))) .returning(); return !!row; } export async function deleteDavAppPassword(userId: number, id: number): Promise { const [row] = await db .delete(davAppPasswords) .where(and(eq(davAppPasswords.id, id), eq(davAppPasswords.userId, userId))) .returning(); return !!row; } /** * Verify a Basic-auth credential, returning the owning user id. * * Every live credential is tried, because the username a DAV client sends is the account email, not a * row id — there is nothing in the request that says WHICH device is calling. That means the cost is * one argon2 verify per stored credential, which is why revoked rows are filtered in SQL and why the * UI should encourage deleting devices that are gone rather than accumulating them. * * `lastUsedAt` is written on success, at most once a minute: a syncing phone hits this constantly and * the column exists to answer "is this device still around", not to be an access log. */ export async function verifyDavAppPassword(userId: number, password: string): Promise { const rows = await db .select() .from(davAppPasswords) .where(and(eq(davAppPasswords.userId, userId), isNull(davAppPasswords.revokedAt))); for (const row of rows) { let ok = false; try { ok = await argon2.verify(row.passwordHash, password); } catch { ok = false; // a corrupt hash must not take the whole login path down } if (!ok) continue; const now = Date.now(); if (!row.lastUsedAt || now - row.lastUsedAt.getTime() > 60_000) { await db .update(davAppPasswords) .set({ lastUsedAt: new Date(now) }) .where(eq(davAppPasswords.id, row.id)); } return row.userId; } return null; }