This commit is contained in:
k
2026-09-21 15:57:10 +02:00
parent 2dfcb944d1
commit 133a08b7ed
8 changed files with 2167 additions and 5 deletions
+78
View File
@@ -43,6 +43,16 @@ import {
updateUser,
} from "./db.ts";
import type { ChatMessage } from "./drivers/types.ts";
import {
consumeTotpChallenge,
createTotpChallenge,
getTotpSecret,
initTotpTables,
newTotpSecret,
setTotpSecret,
totpAuthUrl,
verifyTotp,
} from "./totp.ts";
import {
addAccessMessage,
createAccessRequest,
@@ -83,6 +93,7 @@ const registry = new DriverRegistry();
const ollamaDriver = registry.get("ollama") as OllamaDriver;
await tools.init();
initAccessTables();
initTotpTables();
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
@@ -297,10 +308,35 @@ const server = Bun.serve({
return json({ error: "invalid credentials" }, 401);
}
clearAttempts(ip);
// Second factor: correct key but TOTP enabled -> short-lived challenge,
// the session is only issued after POST /api/auth/totp verifies a code.
if (getTotpSecret(user.id)) {
const totpToken = createTotpChallenge(user.id);
return json({ totpRequired: true, totpToken, username: user.username });
}
const { token, expiresAt } = issueToken(user);
return json({ token, expiresAt, user });
}
// --- auth: TOTP second step (rate limited, challenge is single-use) ---
if (path === "/api/auth/totp" && req.method === "POST") {
const ip = clientIp(req, server);
if (isRateLimited(ip)) return json({ error: "too many attempts, try again later" }, 429);
const { ok, body } = await readJson(req);
const userId =
ok && typeof body.totpToken === "string" ? consumeTotpChallenge(body.totpToken) : null;
const code = ok && typeof body.code === "string" ? body.code : "";
const target = userId ? getUserById(userId) : null;
if (!target || !verifyTotp(getTotpSecret(target.id), code)) {
recordAttempt(ip);
await Bun.sleep(400);
return json({ error: "invalid code" }, 401);
}
clearAttempts(ip);
const { token, expiresAt } = issueToken(toPublicUser(target));
return json({ token, expiresAt, user: toPublicUser(target) });
}
if (path === "/api/auth/verify" && req.method === "GET") {
const user = requireAuth(req);
return user ? json({ ok: true, user }) : json({ error: "unauthorized" }, 401);
@@ -506,6 +542,48 @@ const server = Bun.serve({
return json({ user: toPublicUser(updated) });
}
// --- self-service security: rotate access key (old sessions revoked) ---
if (path === "/api/me/key/rotate" && req.method === "POST") {
const key = newAccessKey();
updateUser(user.id, { keyHash: hashSecret(key) });
deleteUserSessions(user.id);
return json({ key });
}
// --- self-service: delete your own account (chats, shares, sessions gone) ---
if (path === "/api/me" && req.method === "DELETE") {
if (user.username === "admin") return json({ error: "the admin account cannot be deleted" }, 403);
deleteUser(user.id);
getDb().query("DELETE FROM totp_challenges WHERE user_id = ?").run(user.id);
return json({ ok: true });
}
// --- self-service security: TOTP two-factor ---
if (path === "/api/me/totp" && req.method === "GET") {
return json({ enabled: getTotpSecret(user.id).length > 0 });
}
if (path === "/api/me/totp/setup" && req.method === "POST") {
if (getTotpSecret(user.id)) return json({ error: "TOTP already enabled — disable it first" }, 409);
const secret = newTotpSecret();
return json({ secret, otpauthUrl: totpAuthUrl(secret, user.username) });
}
if (path === "/api/me/totp/verify" && req.method === "POST") {
const { ok, body } = await readJson(req);
const secret = ok && typeof body.secret === "string" ? body.secret : "";
const code = ok && typeof body.code === "string" ? body.code : "";
if (!/^[A-Z2-7]{16,64}$/.test(secret.trim().toUpperCase())) return json({ error: "invalid secret" }, 400);
if (!verifyTotp(secret, code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, secret.trim().toUpperCase());
return json({ enabled: true });
}
if (path === "/api/me/totp" && req.method === "DELETE") {
const { ok, body } = await readJson(req);
const code = ok && typeof body.code === "string" ? body.code : "";
if (!verifyTotp(getTotpSecret(user.id), code)) return json({ error: "invalid code" }, 401);
setTotpSecret(user.id, "");
return json({ enabled: false });
}
// --- admin: user management (No-KYC accounts, keys issued by admin) ---
if (path === "/api/admin/users" && req.method === "GET") {
if (!admin) return json({ error: "forbidden" }, 403);
+139
View File
@@ -0,0 +1,139 @@
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;
}