import { createHash, randomBytes } from "node:crypto"; import type { Context, Hono } from "hono"; import { html } from "../pages/html.ts"; import { type PageContext, page } from "../pages/layout.ts"; import { clientIp } from "./client.ts"; import type { Config } from "./config.ts"; import { adminPostRefusal, adminRefusal, formToken, signedIn } from "./login.ts"; // No I, O, 0 or 1: a code is read off a screen and typed into another machine. const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const CODE_SECONDS = 600; /** How many redeemed codes the status page lists, and so how many kepr.db keeps. */ export const MACHINES_LISTED = 20; /** Eight characters from an alphabet with no look-alikes, shown as `ABCD-EFGH`. */ export function oneTimeCode(): string { // 32 characters into a byte is even, so every character is equally likely. return Array.from({ length: 8 }, () => ALPHABET[randomBytes(1)[0] % ALPHABET.length]).join(""); } /** A code as typed, dash and case aside, or null when it can't be one. */ export function typedCode(typed: string): string | null { const code = typed.trim().replaceAll("-", "").toUpperCase(); return code.length === 8 && [...code].every((char) => ALPHABET.includes(char)) ? code : null; } const ATTEMPTS_PER_ADDRESS = 10; const ATTEMPT_WINDOW_MS = 3600000; // One body for every failure — unknown, expired, used, malformed and rate-limited alike — so a // caller learns nothing but "no code". const NO_CODE = '{"error":"no_code","message":"no code"}'; // Failed codes, oldest first, in memory: a restart forgets them, but an attacker can't cause one. let attempts: { at: number; address: string }[] = []; // Addresses are keyed hashes even in memory, as issue filings store them (§23). const attemptKey = randomBytes(32); const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); /** The S3 write keys a signed-in machine is handed, or null when Kepr hands out none. */ export function connectKeys( config: Config, ): { accessKeyId: string; secretAccessKey: string } | null { return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null; } /** A machine name as it may be stored and shown: ≤64 characters, none of them a control code. */ function machineName(value: unknown): string | null { if (typeof value !== "string") return null; const characters = [...value]; if (characters.length === 0 || characters.length > 64) return null; // `\p{C}` covers C0, DEL, C1 and the format codes, a bidi override among them that would reorder // the machines list. Letters outside ASCII stay: a machine is named in its owner's language. if (/[\p{C}\p{Zl}\p{Zp}]/u.test(value)) return null; return value; } /** The one answer every failed redemption gets, whoever asked and however they failed. */ export function noCode(c: Context): Response { return c.body(NO_CODE, 404, { "Content-Type": "application/json; charset=UTF-8", "Cache-Control": "no-store", }); } /** §24's connect page and the one-time code it hands a machine, plus the API that redeems it. */ export function registerConnect(app: Hono, config: Config, ctx: PageContext): void { app.get("/connect", (c) => { // A disabled route is an unknown path, byte for byte, so nothing is set before this line. if (!connectKeys(config)) return c.notFound(); c.header("Cache-Control", "no-store"); const machine = machineName(c.req.query("machine")); if (!signedIn(ctx, c)) { const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect"; return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302); } if (!machine) return c.text("a machine name of 64 printable characters or fewer is required", 400); const body = html`
That machine is handed the bucket key, once.
`; return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body })); }); app.post("/connect", async (c) => { if (!connectKeys(config)) return c.notFound(); c.header("Cache-Control", "no-store"); // As for every admin POST: a live session, the right origin and the same-site form token. if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first."); const refusal = await adminPostRefusal(ctx, c); if (refusal) return adminRefusal(c, ctx, 403, refusal); const machine = machineName((await c.req.parseBody()).machine); if (!machine) return c.text("a machine name of 64 printable characters or fewer is required", 400); const now = Math.floor(Date.now() / 1000); ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now); const code = oneTimeCode(); ctx.kepr .query( "INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)", ) .run(sha256(code), machine, now, now + CODE_SECONDS); const body = html`Type this code on that machine, within ten minutes. It is shown once.
${code.slice(0, 4)}-${code.slice(4)}
`; return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body })); }); app.post("/api/connect", async (c) => { if (!connectKeys(config)) return c.notFound(); c.header("Cache-Control", "no-store"); const now = Date.now(); // By value, not by position: a clock that stepped backwards outside this process (a test's // fake timer, an NTP correction) would leave the oldest entry in the middle of the list. attempts = attempts.filter((attempt) => attempt.at > now - ATTEMPT_WINDOW_MS); const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex"); const used = attempts.filter((attempt) => attempt.address === address).length; // A refused attempt is not recorded: that caps the list at ten entries per address, and keeps // hammering from moving the block out past the hour. if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c); attempts.push({ at: now, address }); let code = ""; try { const fields = await c.req.parseBody(); code = typeof fields.code === "string" ? fields.code : ""; } catch { return noCode(c); } const submitted = typedCode(code); if (!submitted) return noCode(c); const at = Math.floor(now / 1000); // Marked used in the same statement that proves it live, so one code is never redeemed twice. const redeemed = ctx.kepr .query( `UPDATE connect_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL AND expires_at > ?`, ) .run(at, sha256(submitted), at); if (redeemed.changes !== 1) return noCode(c); ctx.kepr .query( `DELETE FROM connect_codes WHERE used_at IS NOT NULL AND code_hash NOT IN ( SELECT code_hash FROM connect_codes WHERE used_at IS NOT NULL ORDER BY used_at DESC, rowid DESC LIMIT ? )`, ) .run(MACHINES_LISTED); // Read again after the awaits above: the console may have changed the keys meanwhile. const bucket = config.bucket.kind === "s3" ? config.bucket : null; if (!bucket?.write) return noCode(c); const write = bucket.write; return c.json({ endpoint: bucket.endpoint, bucket: bucket.name, region: bucket.region, access_key_id: write.accessKeyId, secret_access_key: write.secretAccessKey, }); }); }