connect.ts

7.5 KB · raw

1import { createHash, randomBytes } from "node:crypto";2import type { Context, Hono } from "hono";3import { html } from "../pages/html.ts";4import { type PageContext, page } from "../pages/layout.ts";5import { clientIp } from "./client.ts";6import type { Config } from "./config.ts";7import { adminPostRefusal, adminRefusal, formToken, signedIn } from "./login.ts";89// No I, O, 0 or 1: a code is read off a screen and typed into another machine.10const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";11const CODE_SECONDS = 600;12/** How many redeemed codes the status page lists, and so how many kepr.db keeps. */13export const MACHINES_LISTED = 20;1415/** Eight characters from an alphabet with no look-alikes, shown as `ABCD-EFGH`. */16export function oneTimeCode(): string {17	// 32 characters into a byte is even, so every character is equally likely.18	return Array.from({ length: 8 }, () => ALPHABET[randomBytes(1)[0] % ALPHABET.length]).join("");19}2021/** A code as typed, dash and case aside, or null when it can't be one. */22export function typedCode(typed: string): string | null {23	const code = typed.trim().replaceAll("-", "").toUpperCase();24	return code.length === 8 && [...code].every((char) => ALPHABET.includes(char)) ? code : null;25}26const ATTEMPTS_PER_ADDRESS = 10;27const ATTEMPT_WINDOW_MS = 3600000;28// One body for every failure — unknown, expired, used, malformed and rate-limited alike — so a29// caller learns nothing but "no code".30const NO_CODE = '{"error":"no_code","message":"no code"}';31// Failed codes, oldest first, in memory: a restart forgets them, but an attacker can't cause one.32let attempts: { at: number; address: string }[] = [];33// Addresses are keyed hashes even in memory, as issue filings store them (§23).34const attemptKey = randomBytes(32);35const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");3637/** The S3 write keys a signed-in machine is handed, or null when Kepr hands out none. */38export function connectKeys(39	config: Config,40): { accessKeyId: string; secretAccessKey: string } | null {41	return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null;42}4344/** A machine name as it may be stored and shown: ≤64 characters, none of them a control code. */45function machineName(value: unknown): string | null {46	if (typeof value !== "string") return null;47	const characters = [...value];48	if (characters.length === 0 || characters.length > 64) return null;49	// `\p{C}` covers C0, DEL, C1 and the format codes, a bidi override among them that would reorder50	// the machines list. Letters outside ASCII stay: a machine is named in its owner's language.51	if (/[\p{C}\p{Zl}\p{Zp}]/u.test(value)) return null;52	return value;53}5455/** The one answer every failed redemption gets, whoever asked and however they failed. */56export function noCode(c: Context): Response {57	return c.body(NO_CODE, 404, {58		"Content-Type": "application/json; charset=UTF-8",59		"Cache-Control": "no-store",60	});61}6263/** §24's connect page and the one-time code it hands a machine, plus the API that redeems it. */64export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {65	app.get("/connect", (c) => {66		// A disabled route is an unknown path, byte for byte, so nothing is set before this line.67		if (!connectKeys(config)) return c.notFound();68		c.header("Cache-Control", "no-store");69		const machine = machineName(c.req.query("machine"));70		if (!signedIn(ctx, c)) {71			const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect";72			return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302);73		}74		if (!machine)75			return c.text("a machine name of 64 printable characters or fewer is required", 400);76		const body = html`<h1 class="file-page-title">Connect <b>${machine}</b> to your bucket?</h1>77<p class="muted">That machine is handed the bucket key, once.</p>78<form class="issues-form" method="post" action="/connect">79<input type="hidden" name="machine" value="${machine}">80<input type="hidden" name="form" value="${formToken(c)}">81<button class="issues-submit" type="submit">connect</button>82</form>`;83		return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));84	});8586	app.post("/connect", async (c) => {87		if (!connectKeys(config)) return c.notFound();88		c.header("Cache-Control", "no-store");89		// As for every admin POST: a live session, the right origin and the same-site form token.90		if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");91		const refusal = await adminPostRefusal(ctx, c);92		if (refusal) return adminRefusal(c, ctx, 403, refusal);93		const machine = machineName((await c.req.parseBody()).machine);94		if (!machine)95			return c.text("a machine name of 64 printable characters or fewer is required", 400);96		const now = Math.floor(Date.now() / 1000);97		ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);98		const code = oneTimeCode();99		ctx.kepr100			.query(101				"INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",102			)103			.run(sha256(code), machine, now, now + CODE_SECONDS);104		const body = html`<h1 class="file-page-title">Connect <b>${machine}</b></h1>105<p>Type this code on that machine, within ten minutes. It is shown once.</p>106<p class="mono code">${code.slice(0, 4)}-${code.slice(4)}</p>`;107		return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));108	});109110	app.post("/api/connect", async (c) => {111		if (!connectKeys(config)) return c.notFound();112		c.header("Cache-Control", "no-store");113		const now = Date.now();114		// By value, not by position: a clock that stepped backwards outside this process (a test's115		// fake timer, an NTP correction) would leave the oldest entry in the middle of the list.116		attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS);117		const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");118		const used = attempts.filter((attempt) => attempt.address === address).length;119		// A refused attempt is not recorded: that caps the list at ten entries per address, and keeps120		// hammering from moving the block out past the hour.121		if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c);122		attempts.push({ at: now, address });123		let code = "";124		try {125			const fields = await c.req.parseBody();126			code = typeof fields.code === "string" ? fields.code : "";127		} catch {128			return noCode(c);129		}130		const submitted = typedCode(code);131		if (!submitted) return noCode(c);132		const at = Math.floor(now / 1000);133		// Marked used in the same statement that proves it live, so one code is never redeemed twice.134		const redeemed = ctx.kepr135			.query(136				`UPDATE connect_codes SET used_at = ?137				 WHERE code_hash = ?138				 AND used_at IS NULL139				 AND expires_at > ?`,140			)141			.run(at, sha256(submitted), at);142		if (redeemed.changes !== 1) return noCode(c);143		ctx.kepr144			.query(145				`DELETE FROM connect_codes146				 WHERE used_at IS NOT NULL147				 AND code_hash NOT IN (148					SELECT code_hash FROM connect_codes149					WHERE used_at IS NOT NULL150					ORDER BY used_at DESC, rowid DESC151					LIMIT ?152				 )`,153			)154			.run(MACHINES_LISTED);155		// Read again after the awaits above: the console may have changed the keys meanwhile.156		const bucket = config.bucket.kind === "s3" ? config.bucket : null;157		if (!bucket?.write) return noCode(c);158		const write = bucket.write;159		return c.json({160			endpoint: bucket.endpoint,161			bucket: bucket.name,162			region: bucket.region,163			access_key_id: write.accessKeyId,164			secret_access_key: write.secretAccessKey,165		});166	});167}