Files
chat/src/totp.ts
T
2026-09-21 15:57:10 +02:00

140 lines
4.5 KiB
TypeScript

import { createHash, createHmac, randomBytes } from "node:crypto";
import { getDb } from "./db.ts";
/**
* TOTP two-factor (RFC 6238, SHA-1, 30s step, 6 digits, ±1 window).
* The per-user secret lives in `users.totp_secret` (empty = disabled);
* login challenges live in `totp_challenges`. Tables/columns are created
* idempotently here so db.ts stays untouched.
*/
const STEP_SEC = 30;
const DIGITS = 6;
const WINDOW = 1;
const CHALLENGE_TTL_MS = 5 * 60_000;
export function initTotpTables(): void {
const d = getDb();
try {
d.query("ALTER TABLE users ADD COLUMN totp_secret TEXT NOT NULL DEFAULT ''").run();
} catch {
// column already exists — fine
}
d.query(`CREATE TABLE IF NOT EXISTS totp_challenges (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`).run();
}
const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
export function newTotpSecret(bytes = 20): string {
const buf = randomBytes(bytes);
let out = "";
let bits = 0;
let value = 0;
for (const b of buf) {
value = (value << 8) | b;
bits += 8;
while (bits >= 5) {
out += B32[(value >>> (bits - 5)) & 31];
bits -= 5;
}
}
if (bits > 0) out += B32[(value << (5 - bits)) & 31];
return out;
}
export function totpAuthUrl(secret: string, username: string, issuer = "KisAssistant"): string {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(username)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
}
function base32Decode(s: string): Buffer {
const clean = s.trim().replace(/=+$/, "").toUpperCase();
let bits = 0;
let value = 0;
const out: number[] = [];
for (const ch of clean) {
const idx = B32.indexOf(ch);
if (idx < 0) throw new Error("invalid base32");
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
out.push((value >>> (bits - 8)) & 255);
bits -= 8;
}
}
return Buffer.from(out);
}
function hotp(key: Buffer, counter: bigint): string {
const msg = Buffer.alloc(8);
msg.writeBigUInt64BE(counter);
const h = createHmac("sha1", key).update(msg).digest();
const offset = h[h.length - 1]! & 0x0f;
const code =
((h[offset]! & 0x7f) << 24) | ((h[offset + 1]! << 16) | ((h[offset + 2]! << 8) | h[offset + 3]!));
return String(code % 10 ** DIGITS).padStart(DIGITS, "0");
}
/** Timing-safe comparison against the current ±1 window. */
export function verifyTotp(secret: string, code: string): boolean {
const c = code.trim();
if (!/^\d{6}$/.test(c)) return false;
let key: Buffer;
try {
key = base32Decode(secret);
} catch {
return false;
}
if (key.length < 10) return false;
const now = Math.floor(Date.now() / 1000 / STEP_SEC);
let ok = false;
for (let w = -WINDOW; w <= WINDOW; w++) {
const expected = hotp(key, BigInt(now + w));
const a = Buffer.from(expected);
const b = Buffer.from(c);
if (a.length === b.length && createHash("sha256").update(a).digest().equals(createHash("sha256").update(b).digest())) {
ok = true;
}
}
// Constant-time-ish: always hash-compare all window slots (loop above).
return ok;
}
export function getTotpSecret(userId: string): string {
const row = getDb().query("SELECT totp_secret AS s FROM users WHERE id = ?").get(userId) as { s: string } | null;
return row?.s ?? "";
}
export function setTotpSecret(userId: string, secret: string): void {
getDb().query("UPDATE users SET totp_secret = ? WHERE id = ?").run(secret, userId);
}
/** Short-lived login challenge after a correct key; returns the raw token. */
export function createTotpChallenge(userId: string): string {
const token = "tc_" + randomBytes(24).toString("base64url");
const hash = createHash("sha256").update(token).digest("hex");
const now = Date.now();
getDb().query("INSERT INTO totp_challenges (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run(
hash, userId, now, now + CHALLENGE_TTL_MS,
);
// Opportunistic cleanup.
getDb().query("DELETE FROM totp_challenges WHERE expires_at < ?").run(now);
return token;
}
export function consumeTotpChallenge(token: string): string | null {
const hash = createHash("sha256").update(token).digest("hex");
const row = getDb().query("SELECT user_id, expires_at FROM totp_challenges WHERE token_hash = ?").get(hash) as {
user_id: string;
expires_at: number;
} | null;
if (!row) return null;
getDb().query("DELETE FROM totp_challenges WHERE token_hash = ?").run(hash);
if (row.expires_at < Date.now()) return null;
return row.user_id;
}