b16612d47778

hand out bucket access to a signed-in machine through a one-time code (v3.0.62)

time
2026-09-27 15:06 UTC · 2 weeks ago
machine
Junes-MacBook-Pro.local
agent
deepseek/deepseek-flash
session
kepr connect and public steal
parent
4928df826985
11 files kepr.example.toml+4 −0package.json+1 −1src/admin/console.ts+22 −1src/server/app.ts+9 −2src/server/config.ts+12 −0src/server/connect.ts+141 −0src/server/db.ts+7 −0src/server/login.ts+66 −15src/server/reserved.ts+1 −0tests/config.test.ts+16 −0tests/connect.test.ts+302 −0
kepr.example.toml+4 −0
@@ -12,6 +12,10 @@1212 # A read-only key: Kepr never writes to the bucket.1313 access_key_id = "SCW..."1414 secret_access_key = "..."15+# Optional write key, S3-only: Kepr hands this to a machine you sign in from; it is never shown or backed up.16+#[bucket.write]17+#access_key_id = "SCW..."18+#secret_access_key = "..."1519 1620 # For development and tests, read a Koh 3 folder remote instead of S3.1721 # Replace the five S3 keys above with:

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.0.61",3+	"version": "3.0.62",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

src/admin/console.ts+22 −1
@@ -6,7 +6,7 @@66 import { backups, newestBackupTarball } from "../server/backup.ts";77 import { formToken } from "../server/login.ts";88 9-export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string };9+export type AdminContext = PageContext & { sync: SyncTarget; dataDir: string; canConnect: boolean };1010 1111 const SECTIONS = [1212 	["/admin", "status"],@@ -66,6 +66,26 @@6666 	return total;6767 }6868 69+/** The last 20 machines that redeemed a code, and how to take their access away again. */70+function connectedMachines(ctx: AdminContext): Html | "" {71+	const rows = ctx.kepr72+		.query<{ machine: string; used_at: number }, []>(73+			`SELECT machine, used_at FROM connect_codes74+			 WHERE used_at IS NOT NULL75+			 ORDER BY used_at DESC76+			 LIMIT 20`,77+		)78+		.all();79+	if (rows.length === 0) return "";80+	const led = rows.map((row) => {81+		const at = new Date(row.used_at * 1000).toISOString();82+		return html`<li>${row.machine} <span class="muted">${at}</span></li>`;83+	});84+	return html`<h2>Machines connected</h2>85+<ul>${led}</ul>86+<p class="muted">To revoke access, rotate the write key in Scaleway and update kepr.toml.</p>`;87+}88+6989 /** §24's Status section at `/admin`: the bucket, each repo's format, backups, the running version. */7090 export function registerStatus(app: Hono, ctx: AdminContext): void {7191 	app.get("/admin", async (c) => {@@ -104,6 +124,7 @@104124 				ctx,105125 				"status",106126 				html`<section class="repo-section markdown">${bucket}127+${ctx.canConnect ? connectedMachines(ctx) : ""}107128 ${backup}108129 <p>Running kepr ${version}: ${lines} lines of TypeScript.</p></section>`,109130 			),

view file

src/server/app.ts+9 −2
@@ -24,6 +24,7 @@2424 import { registerReleaseRoutes } from "../releases/routes.ts";2525 import { registerTarballRoutes } from "../tarball/archive.ts";2626 import type { Config } from "./config.ts";27+import { connectKeys, noCode, registerConnect } from "./connect.ts";2728 import { registerLogin } from "./login.ts";2829 2930 const MAX_BODY_BYTES = 64 * 1024;@@ -62,7 +63,12 @@6263 		}),6364 	);6465 	app.use(65-		bodyLimit({ maxSize: MAX_BODY_BYTES, onError: (c) => c.text("Request body too large", 413) }),66+		bodyLimit({67+			maxSize: MAX_BODY_BYTES,68+			onError: (c) =>69+				// An oversized redemption is one more failed redemption, not a different answer.70+				c.req.path === "/api/connect" ? noCode(c) : c.text("Request body too large", 413),71+		}),6672 	);6773 	// `/static/assets/` is the operator's uploads in `data/assets/` (§19), such as the signature.6874 	const assetsDir = join(config.dataDir, "assets");@@ -78,10 +84,11 @@7884 	});7985 	const ctx = { domain: config.domain, kepr, mirrorDir: join(config.dataDir, "mirror") };8086 	const bucket = openBucket(config.bucket);81-	// Before every other route, so the admin guard runs ahead of each `/admin` handler.8287 	registerLogin(app, ctx, config.adminPasswordHash);88+	registerConnect(app, config, ctx);8389 	const admin = {8490 		...ctx,91+		canConnect: connectKeys(config) !== null,8592 		dataDir: config.dataDir,8693 		sync: { bucket, kepr, mirrorDir: ctx.mirrorDir, tarballDir: join(config.dataDir, "tarballs") },8794 	};

view file

src/server/config.ts+12 −0
@@ -6,6 +6,7 @@66 			region: string;77 			accessKeyId: string;88 			secretAccessKey: string;9+			write?: { accessKeyId: string; secretAccessKey: string };910 	  }1011 	// A directory laid out exactly like the bucket (Koh 3's folder remote), for development and tests.1112 	| { kind: "folder"; path: string };@@ -58,6 +59,8 @@5859 5960 function parseBucket(bucket: Record<string, unknown>): BucketSource {6061 	if (bucket.folder !== undefined) {62+		if (bucket.write !== undefined)63+			throw new ConfigError("[bucket] write cannot be combined with folder");6164 		const s3Keys = ["endpoint", "name", "region", "access_key_id", "secret_access_key"];6265 		const mixed = s3Keys.filter((key) => bucket[key] !== undefined);6366 		if (mixed.length > 0) {@@ -67,6 +70,7 @@6770 	}6871 	const endpoint = requireString(bucket, "endpoint", "[bucket] ");6972 	if (!URL.canParse(endpoint)) throw new ConfigError("[bucket] endpoint must be a URL");73+	const write = bucket.write === undefined ? undefined : asTable(bucket.write, "[bucket.write]");7074 	return {7175 		kind: "s3",7276 		endpoint,@@ -74,6 +78,14 @@7478 		region: requireString(bucket, "region", "[bucket] "),7579 		accessKeyId: requireString(bucket, "access_key_id", "[bucket] "),7680 		secretAccessKey: requireString(bucket, "secret_access_key", "[bucket] "),81+		...(write82+			? {83+					write: {84+						accessKeyId: requireString(write, "access_key_id", "[bucket.write] "),85+						secretAccessKey: requireString(write, "secret_access_key", "[bucket.write] "),86+					},87+				}88+			: {}),7789 	};7890 }7991 

view file

src/server/connect.ts+141 −0
@@ -0,0 +1,141 @@1+import { createHash, randomBytes } from "node:crypto";2+import type { Context, Hono } from "hono";3+import { html } from "../pages/html.ts";4+import { type PageContext, page } from "../pages/layout.ts";5+import { clientIp } from "./client.ts";6+import type { Config } from "./config.ts";7+import { adminPostRefusal, adminRefusal, formToken, signedIn } from "./login.ts";8+9+// No I, O, 0 or 1: a code is read off a screen and typed into another machine.10+const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";11+const CODE_SECONDS = 600;12+const ATTEMPTS_PER_ADDRESS = 10;13+const ATTEMPT_WINDOW_MS = 3600000;14+// One body for every failure — unknown, expired, used, malformed and rate-limited alike — so a15+// caller learns nothing but "no code".16+const NO_CODE = '{"error":"no_code","message":"no code"}';17+// Failed codes, oldest first, in memory: a restart forgets them, but an attacker can't cause one.18+const attempts: { at: number; address: string }[] = [];19+// Addresses are keyed hashes even in memory, as issue filings store them (§23).20+const attemptKey = randomBytes(32);21+const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");22+23+/** The S3 write keys a signed-in machine is handed, or null when Kepr hands out none. */24+export function connectKeys(25+	config: Config,26+): { accessKeyId: string; secretAccessKey: string } | null {27+	return config.bucket.kind === "s3" && config.bucket.write ? config.bucket.write : null;28+}29+30+/** A machine name as it may be stored and shown: ≤64 characters, none of them a control code. */31+function machineName(value: unknown): string | null {32+	if (typeof value !== "string") return null;33+	const characters = [...value];34+	if (characters.length === 0 || characters.length > 64) return null;35+	// C0, DEL and C1 are not printable. Letters outside ASCII are: a machine can be named in any36+	// alphabet, and Koh sends the name as it stands.37+	if (characters.some((char) => char < " " || (char >= "\u007f" && char <= "\u009f"))) return null;38+	return value;39+}40+41+/** The one answer every failed redemption gets, whoever asked and however they failed. */42+export function noCode(c: Context): Response {43+	return c.body(NO_CODE, 404, {44+		"Content-Type": "application/json; charset=UTF-8",45+		"Cache-Control": "no-store",46+	});47+}48+49+/** §24's connect page and the one-time code it hands a machine, plus the API that redeems it. */50+export function registerConnect(app: Hono, config: Config, ctx: PageContext): void {51+	const bucket = config.bucket.kind === "s3" ? config.bucket : null;52+	const write = bucket?.write ?? null;53+54+	app.get("/connect", (c) => {55+		// A disabled route is an unknown path, byte for byte, so nothing is set before this line.56+		if (!write) return c.notFound();57+		c.header("Cache-Control", "no-store");58+		const machine = machineName(c.req.query("machine"));59+		if (!signedIn(ctx, c)) {60+			const back = machine ? `/connect?machine=${encodeURIComponent(machine)}` : "/connect";61+			return c.redirect(`/sign-in?next=${encodeURIComponent(back)}`, 302);62+		}63+		if (!machine)64+			return c.text("a machine name of 64 printable characters or fewer is required", 400);65+		const body = html`<h1 class="file-page-title">Connect <b>${machine}</b> to your bucket?</h1>66+<p class="muted">That machine is handed the bucket key, once.</p>67+<form class="issues-form" method="post" action="/connect">68+<input type="hidden" name="machine" value="${machine}">69+<input type="hidden" name="form" value="${formToken(c)}">70+<button class="issues-submit" type="submit">connect</button>71+</form>`;72+		return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));73+	});74+75+	app.post("/connect", async (c) => {76+		if (!write) return c.notFound();77+		c.header("Cache-Control", "no-store");78+		// As for every admin POST: a live session, the right origin and the same-site form token.79+		if (!signedIn(ctx, c)) return adminRefusal(c, ctx, 401, "Sign in first.");80+		const refusal = await adminPostRefusal(ctx, c);81+		if (refusal) return adminRefusal(c, ctx, 403, refusal);82+		const machine = machineName((await c.req.parseBody()).machine);83+		if (!machine)84+			return c.text("a machine name of 64 printable characters or fewer is required", 400);85+		const now = Math.floor(Date.now() / 1000);86+		ctx.kepr.query("DELETE FROM connect_codes WHERE used_at IS NULL AND expires_at <= ?").run(now);87+		// 32 characters into a byte is even, so every character is equally likely.88+		const code = Array.from(89+			{ length: 8 },90+			() => ALPHABET[randomBytes(1)[0] % ALPHABET.length],91+		).join("");92+		ctx.kepr93+			.query(94+				"INSERT INTO connect_codes (code_hash, machine, created_at, expires_at) VALUES (?, ?, ?, ?)",95+			)96+			.run(sha256(code), machine, now, now + CODE_SECONDS);97+		const body = html`<h1 class="file-page-title">Connect <b>${machine}</b></h1>98+<p>Type this code on that machine, within ten minutes. It is shown once.</p>99+<p class="mono code">${code.slice(0, 4)}-${code.slice(4)}</p>`;100+		return c.html(page(ctx, { title: "connect", description: "", path: c.req.path, body }));101+	});102+103+	app.post("/api/connect", async (c) => {104+		if (!write || !bucket) return c.notFound();105+		c.header("Cache-Control", "no-store");106+		const now = Date.now();107+		while (attempts[0] && attempts[0].at <= now - ATTEMPT_WINDOW_MS) attempts.shift();108+		const address = createHash("sha256").update(attemptKey).update(clientIp(c)).digest("hex");109+		const used = attempts.filter((attempt) => attempt.address === address).length;110+		attempts.push({ at: now, address });111+		if (used >= ATTEMPTS_PER_ADDRESS) return noCode(c);112+		let code = "";113+		try {114+			const fields = await c.req.parseBody();115+			code = typeof fields.code === "string" ? fields.code : "";116+		} catch {117+			return noCode(c);118+		}119+		const submitted = code.trim().replaceAll("-", "").toUpperCase();120+		if (submitted.length !== 8 || [...submitted].some((char) => !ALPHABET.includes(char)))121+			return noCode(c);122+		const at = Math.floor(now / 1000);123+		// Marked used in the same statement that proves it live, so one code is never redeemed twice.124+		const redeemed = ctx.kepr125+			.query(126+				`UPDATE connect_codes SET used_at = ?127+				 WHERE code_hash = ?128+				 AND used_at IS NULL129+				 AND expires_at > ?`,130+			)131+			.run(at, sha256(submitted), at);132+		if (redeemed.changes !== 1) return noCode(c);133+		return c.json({134+			endpoint: bucket.endpoint,135+			bucket: bucket.name,136+			region: bucket.region,137+			access_key_id: write.accessKeyId,138+			secret_access_key: write.secretAccessKey,139+		});140+	});141+}

view file

src/server/db.ts+7 −0
@@ -76,6 +76,13 @@7676 	token_hash TEXT PRIMARY KEY,7777 	expires_at INTEGER NOT NULL7878 );79+CREATE TABLE IF NOT EXISTS connect_codes (80+	code_hash   TEXT PRIMARY KEY,81+	machine     TEXT NOT NULL,82+	created_at  INTEGER NOT NULL,83+	expires_at  INTEGER NOT NULL,84+	used_at     INTEGER85+);7986 `;8087 8188 export type RepoSettings = {

view file

src/server/login.ts+66 −15
@@ -38,6 +38,18 @@3838 	return row ? token : null;3939 }4040 41+export function signedIn(ctx: PageContext, c: Context): boolean {42+	return sessionToken(ctx, c) !== null;43+}44+45+export async function adminPostRefusal(ctx: PageContext, c: Context): Promise<string> {46+	if (c.req.header("origin") !== `https://${ctx.domain}`) return "Refused: wrong origin.";47+	const form = (await c.req.parseBody()).form;48+	return typeof form !== "string" || !sameText(form, formToken(c))49+		? "Refused: this form expired. Reload and try again."50+		: "";51+}52+4153 /**4254  * The same-site token every admin form carries (§25): derived from the session's secret token, so4355  * a page on another site can neither read nor guess it, and it dies with the session.@@ -47,32 +59,43 @@4759 }4860 4961 /**62+ * The refusal page behind every admin entry point, `/connect` included: one answer for a missing63+ * session, a foreign origin, an expired form token or too many attempts, wherever it arrives.64+ */65+export function adminRefusal(66+	c: Context,67+	ctx: PageContext,68+	status: 401 | 403 | 429,69+	text: string,70+): Response {71+	c.header("Cache-Control", "no-store");72+	const body = html`<h1 class="file-page-title">${text}</h1>`;73+	return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);74+}75+76+/**5077  * §24's login at the unlinked `/sign-in`, sessions, sign-out, and the guard in front of every5178  * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.5279  */5380 export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {5481 	const origin = `https://${ctx.domain}`;55-	const refuse = (c: Context, status: 401 | 403 | 429, text: string) => {56-		c.header("Cache-Control", "no-store");57-		const body = html`<h1 class="file-page-title">${text}</h1>`;58-		return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);59-	};82+	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>83+		adminRefusal(c, ctx, status, text);6084 6185 	app.use("/admin/*", async (c, next) => {6286 		const token = sessionToken(ctx, c);6387 		if (!token) return refuse(c, 401, "Sign in first.");6488 		if (c.req.method !== "GET") {65-			if (c.req.header("origin") !== origin) return refuse(c, 403, "Refused: wrong origin.");66-			const form = (await c.req.parseBody()).form;67-			if (typeof form !== "string" || !sameText(form, formToken(c))) {68-				return refuse(c, 403, "Refused: this form expired. Reload and try again.");69-			}89+			const error = await adminPostRefusal(ctx, c);90+			if (error) return refuse(c, 403, error);7091 		}7192 		await next();7293 		c.header("Cache-Control", "no-store");7394 	});7495 75-	app.get("/sign-in", (c) => signInPage(c, ctx, "", 200));96+	app.get("/sign-in", (c) =>97+		signInPage(c, ctx, "", 200, validNext(c.req.query("next") ?? "") ?? "/admin"),98+	);7699 77100 	app.post("/sign-in", async (c) => {78101 		const fields = await c.req.parseBody();@@ -87,12 +110,24 @@87110 		const mine = failures.filter((failure) => failure.address === address).length;88111 		if (mine >= FAILURES_PER_ADDRESS) {89112 			c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));90-			return signInPage(c, ctx, "Too many attempts. Try again later.", 429);113+			return signInPage(114+				c,115+				ctx,116+				"Too many attempts. Try again later.",117+				429,118+				validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",119+			);91120 		}92121 		const password = typeof fields.password === "string" ? fields.password : "";93122 		if (!(await Bun.password.verify(password, passwordHash))) {94123 			failures.push({ at: now, address });95-			return signInPage(c, ctx, "That password is not right.", 401);124+			return signInPage(125+				c,126+				ctx,127+				"That password is not right.",128+				401,129+				validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",130+			);96131 		}97132 		const token = randomBytes(32).toString("hex");98133 		const at = nowSeconds();@@ -102,7 +137,10 @@102137 			.run(sha256(token), at + SESSION_SECONDS);103138 		setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });104139 		deleteCookie(c, SIGN_IN_COOKIE, COOKIE);105-		return c.redirect("/admin", 303);140+		return c.redirect(141+			validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",142+			303,143+		);106144 	});107145 108146 	app.post("/admin/sign-out", (c) => {@@ -114,8 +152,20 @@114152 	});115153 }116154 155+function validNext(value: string): string | null {156+	if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return null;157+	if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127))158+		return null;159+	return value;160+}117161 // A double-submit token repeats a SameSite=Strict cookie that a cross-site post never has.118-function signInPage(c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429) {162+function signInPage(163+	c: Context,164+	ctx: PageContext,165+	error: string,166+	status: 200 | 401 | 429,167+	next = "/admin",168+) {119169 	let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";120170 	if (!HEX64.test(cookie)) {121171 		cookie = randomBytes(32).toString("hex");@@ -126,6 +176,7 @@126176 ${error && html`<p class="issues-error mono" role="alert">${error}</p>`}127177 <form class="issues-form" method="post" action="/sign-in">128178 <input type="hidden" name="form" value="${cookie}">179+<input type="hidden" name="next" value="${next}">129180 <label class="issues-label" for="password">password</label>130181 <input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>131182 <button class="issues-submit" type="submit">sign in</button>

view file

src/server/reserved.ts+1 −0
@@ -8,6 +8,7 @@88 	"api",99 	"browse",1010 	"cli-auth",11+	"connect",1112 	"docs",1213 	"drafts",1314 	"favicon.svg",

view file

tests/config.test.ts+16 −0
@@ -30,6 +30,22 @@3030 		},3131 	});3232 });33+test("parses optional S3 write keys", () => {34+	const config = parseConfig(35+		`${s3}[bucket.write]\naccess_key_id = "SCWWRITE"\nsecret_access_key = "wsecret"\n`,36+	);37+	expect(config.bucket).toMatchObject({38+		write: { accessKeyId: "SCWWRITE", secretAccessKey: "wsecret" },39+	});40+	expect(() =>41+		parseConfig(`${s3}[bucket.write]\naccess_key_id = ""\nsecret_access_key = "x"\n`),42+	).toThrow("access_key_id is required");43+	expect(() =>44+		parseConfig(45+			`domain = "d"\ndata_dir = "data"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/b"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"\n`,46+		),47+	).toThrow("cannot be combined");48+});3349 3450 test("parses a folder source", () => {3551 	const text = `domain = "d"\ndata_dir = "data"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/b"\n`;

view file

tests/connect.test.ts+302 −0
@@ -0,0 +1,302 @@1+// Covers connect setup, validation, one-time redemption, rate limits, admin history, and secrecy.2+3+import { Database } from "bun:sqlite";4+import { afterAll, describe, expect, setSystemTime, test } from "bun:test";5+import { mkdtemp } from "node:fs/promises";6+import { tmpdir } from "node:os";7+import { join } from "node:path";8+import { createApp } from "../src/server/app.ts";9+import { makeBackup } from "../src/server/backup.ts";10+import { type Config, parseConfig } from "../src/server/config.ts";11+import { openKeprDb } from "../src/server/db.ts";12+import { Browser, PASSWORD } from "./browser.ts";13+14+const ORIGIN = "https://kepr.example";15+const WRITE = { accessKeyId: "WRITE-ID", secretAccessKey: "WRITE-SECRET" };16+// One argon2 hash for the whole file: every instance shares it, so the suite stays quick.17+const HASH = await Bun.password.hash(PASSWORD, "argon2id");18+let clock = Date.parse("2026-09-27T12:00:00Z");19+setSystemTime(clock);20+afterAll(() => setSystemTime());21+22+async function build(write = true, folder = false) {23+	const dataDir = await mkdtemp(join(tmpdir(), "kepr-connect-"));24+	const kepr = openKeprDb(dataDir);25+	const bucket: Config["bucket"] = folder26+		? { kind: "folder", path: dataDir }27+		: {28+				kind: "s3",29+				endpoint: "https://s3.fr-par.scw.cloud",30+				name: "my-bucket",31+				region: "fr-par",32+				accessKeyId: "read-id",33+				secretAccessKey: "read-secret",34+				...(write ? { write: WRITE } : {}),35+			};36+	const config: Config = {37+		domain: "kepr.example",38+		dataDir,39+		adminPasswordHash: HASH,40+		bucket,41+	};42+	return { app: createApp(config, kepr), kepr, config };43+}44+45+async function connected(write = true) {46+	const built = await build(write);47+	const browser = new Browser(built.app, `198.51.100.${Math.floor(Math.random() * 200) + 1}`);48+	await browser.signIn();49+	return { ...built, browser };50+}51+52+async function issue(browser: Browser, machine = "laptop") {53+	await browser.get(`/connect?machine=${encodeURIComponent(machine)}`);54+	const page = await browser.post("/connect", { machine });55+	const body = await page.text();56+	const match = /class="mono code">([^<]+)/.exec(body);57+	const code = match?.[1]?.trim() ?? "";58+	return { page, body, code };59+}60+61+async function redeem(app: ReturnType<typeof createApp>, code: string, address = "203.0.113.77") {62+	const browser = new Browser(app, address);63+	return browser.post("/api/connect", { code });64+}65+66+describe.serial("connect configuration and page", () => {67+	test("parseConfig rejects write keys beside a folder bucket and empty write values", () => {68+		const hash = "$argon2id$v=19$m=65536,t=2,p=1$abc$abc";69+		expect(() =>70+			parseConfig(71+				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nfolder = "/tmp/x"\n[bucket.write]\naccess_key_id = "x"\nsecret_access_key = "y"`,72+			),73+		).toThrow();74+		expect(() =>75+			parseConfig(76+				`domain = "kepr.example"\nadmin_password_hash = "${hash}"\n[bucket]\nendpoint = "https://x"\nname = "n"\nregion = "r"\naccess_key_id = "a"\nsecret_access_key = "s"\n[bucket.write]\naccess_key_id = ""\nsecret_access_key = "y"`,77+			),78+		).toThrow();79+	});80+81+	test("folder buckets and S3 buckets without write keys hide all connect routes", async () => {82+		for (const built of [await build(false), await build(false, true)]) {83+			const browser = new Browser(built.app);84+			expect((await browser.get("/connect?machine=x")).status).toBe(404);85+			expect((await browser.post("/connect", { machine: "x" })).status).toBe(404);86+			expect((await browser.post("/api/connect", { code: "ABCDEFGH" })).status).toBe(404);87+		}88+	});89+90+	test("a disabled connect route is an unknown path, byte for byte", async () => {91+		const { app } = await build(false);92+		const browser = new Browser(app);93+		for (const [method, path] of [94+			["GET", "/connect?machine=x"],95+			["POST", "/connect"],96+			["POST", "/api/connect"],97+		] as const) {98+			const asked = method === "GET" ? browser.get(path) : browser.post(path, {});99+			const unknown =100+				method === "GET" ? browser.get("/nothing/here") : browser.post("/nothing/here", {});101+			const [found, absent] = await Promise.all([asked, unknown]);102+			expect(`${found.status} ${await found.text()}`).toBe(103+				`${absent.status} ${await absent.text()}`,104+			);105+			expect(found.headers.get("cache-control")).toBe(absent.headers.get("cache-control"));106+		}107+	});108+109+	test("a signed-out request redirects before the machine name is judged", async () => {110+		const { app } = await build();111+		const browser = new Browser(app);112+		const response = await browser.get(`/connect?machine=${encodeURIComponent("x".repeat(65))}`);113+		expect(response.status).toBe(302);114+		expect(response.headers.get("location")).toBe("/sign-in?next=%2Fconnect");115+	});116+117+	test("connect redirects signed out, preserves next, and rejects unsafe next values", async () => {118+		const built = await build();119+		const browser = new Browser(built.app);120+		const response = await browser.get("/connect?machine=desk%20%26%20rack");121+		expect(response.status).toBe(302);122+		for (const next of ["https://evil.example", "//evil.example", "/bad\n"]) {123+			const sign = await browser.get(`/sign-in?next=${encodeURIComponent(next)}`);124+			const html = await sign.text();125+			expect(html).toContain('name="next" value="/admin"');126+		}127+		const sign = await browser.get("/sign-in?next=%2Fconnect%3Fmachine%3Ddesk");128+		const form = /name="form" value="([^"]+)"/.exec(await sign.text())?.[1] ?? "";129+		const wrong = await browser.request("/sign-in", {130+			method: "POST",131+			headers: { origin: ORIGIN },132+			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: "wrong" }),133+		});134+		expect(wrong.status).toBe(401);135+		expect(await wrong.text()).toContain('name="next" value="/connect?machine=desk"');136+		const good = await browser.request("/sign-in", {137+			method: "POST",138+			headers: { origin: ORIGIN },139+			body: new URLSearchParams({ form, next: "/connect?machine=desk", password: PASSWORD }),140+		});141+		expect(good.status).toBe(303);142+		expect(good.headers.get("location")).toBe("/connect?machine=desk");143+	});144+145+	test("connect validates and escapes machine names, and never caches or exposes secrets", async () => {146+		const { browser } = await connected();147+		for (const machine of ["", "x".repeat(65), "bad\nname", "bad\u0085name"])148+			expect((await browser.get(`/connect?machine=${encodeURIComponent(machine)}`)).status).toBe(149+				400,150+			);151+		const result = await issue(browser, "<img src=x onerror=alert(1)>");152+		expect(result.page.status).toBe(200);153+		expect(result.body).toContain("&lt;img src=x onerror=alert(1)&gt;");154+		expect(result.body).not.toContain("WRITE-SECRET");155+		expect(result.page.headers.get("cache-control")).toBe("no-store");156+	});157+});158+159+describe.serial("connect redemption", () => {160+	test("stores only a hash, redeems once with exact credentials, normalizes code, and gives identical failures", async () => {161+		const { app, browser, kepr, config } = await connected();162+		const { code, body } = await issue(browser, "office");163+		expect(code).toMatch(/^[A-Z2-9]{4}-[A-Z2-9]{4}$/);164+		expect(body).not.toContain("WRITE-SECRET");165+		const row = kepr.query("SELECT code_hash, machine FROM connect_codes").get() as {166+			code_hash: string;167+			machine: string;168+		};169+		expect(row.code_hash).toMatch(/^[0-9a-f]{64}$/);170+		expect(row.code_hash).not.toContain(code.replace("-", ""));171+		expect(row.machine).toBe("office");172+		const success = await redeem(app, code.toLowerCase(), "203.0.113.88");173+		expect(success.status).toBe(200);174+		expect(await success.json()).toEqual({175+			endpoint: config.bucket.kind === "s3" ? config.bucket.endpoint : "",176+			bucket: "my-bucket",177+			region: "fr-par",178+			access_key_id: "WRITE-ID",179+			secret_access_key: "WRITE-SECRET",180+		});181+		expect(success.headers.get("cache-control")).toBe("no-store");182+		const used = await redeem(app, code, "203.0.113.89");183+		const unknown = await redeem(app, "ZZZZ-ZZZZ", "203.0.113.90");184+		expect(used.status).toBe(404);185+		expect(await used.text()).toBe(await unknown.text());186+		expect(used.headers.get("cache-control")).toBe("no-store");187+	});188+189+	test("connect POST requires session, origin and fresh form token", async () => {190+		const built = await build();191+		const browser = new Browser(built.app);192+		expect((await browser.post("/connect", { machine: "x" })).status).toBe(401);193+		await browser.signIn();194+		expect((await browser.post("/connect", { machine: "x" }, "https://evil.example")).status).toBe(195+			403,196+		);197+		expect((await browser.post("/connect", { machine: "x", form: "stale" })).status).toBe(403);198+	});199+200+	test("expired, malformed and missing codes all return the same no-code response", async () => {201+		const { app, browser } = await connected();202+		const { code } = await issue(browser);203+		clock += 601_000;204+		setSystemTime(clock);205+		const expired = await redeem(app, code, "198.51.100.201");206+		const malformed = await redeem(app, "bad", "198.51.100.202");207+		const missing = await new Browser(app, "198.51.100.203").request("/api/connect", {208+			method: "POST",209+			body: "not-a-form",210+		});211+		expect(expired.status).toBe(404);212+		const expected = await expired.text();213+		expect(expected).toBe('{"error":"no_code","message":"no code"}');214+		expect(await malformed.text()).toBe(expected);215+		expect(await missing.text()).toBe(expected);216+	});217+218+	test("rate limits ten attempts per peer while another peer succeeds", async () => {219+		const { app, browser } = await connected();220+		const { code } = await issue(browser);221+		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.40");222+		const limited = await redeem(app, code, "192.0.2.40");223+		expect(limited.status).toBe(404);224+		const other = await redeem(app, code, "192.0.2.41");225+		expect(other.status).toBe(200);226+		// The window is an hour: once it has passed the same peer is served again.227+		clock += 3_601_000;228+		setSystemTime(clock);229+		const { code: fresh } = await issue(browser, "later");230+		expect((await redeem(app, fresh, "192.0.2.40")).status).toBe(200);231+	});232+233+	test("a forged X-Forwarded-For does not buy another bucketful", async () => {234+		const { app, browser } = await connected();235+		const { code } = await issue(browser);236+		for (let i = 0; i < 10; i++) await redeem(app, "bad", "192.0.2.50");237+		const forged = await new Browser(app, "192.0.2.50").request("/api/connect", {238+			method: "POST",239+			headers: {240+				"x-forwarded-for": "198.51.100.99",241+				"content-type": "application/x-www-form-urlencoded",242+			},243+			body: new URLSearchParams({ code }),244+		});245+		expect(forged.status).toBe(404);246+	});247+248+	test("two redemptions at once: exactly one wins", async () => {249+		const { app, browser } = await connected();250+		const { code } = await issue(browser, "race");251+		const [first, second] = await Promise.all([252+			redeem(app, code, "203.0.113.101"),253+			redeem(app, code, "203.0.113.102"),254+		]);255+		expect([first.status, second.status].sort()).toEqual([200, 404]);256+	});257+258+	test("an oversized redemption is refused with the same no-code body", async () => {259+		const { app } = await connected();260+		const response = await new Browser(app, "192.0.2.60").post("/api/connect", {261+			code: "x".repeat(70_000),262+		});263+		expect(response.status).toBe(404);264+		expect(await response.text()).toBe('{"error":"no_code","message":"no code"}');265+		expect(response.headers.get("cache-control")).toBe("no-store");266+	});267+268+	test("a backup carries the hashed code and never the write key", async () => {269+		const { browser, kepr, config } = await connected();270+		const { code } = await issue(browser, "laptop");271+		const name = makeBackup(kepr, config.dataDir);272+		const file = join(config.dataDir, "backups", name, "kepr.db");273+		const copy = new Database(file, { readonly: true });274+		const rows = copy.query("SELECT machine, code_hash FROM connect_codes").all();275+		copy.close();276+		expect(rows.length).toBe(1);277+		expect(JSON.stringify(rows)).not.toContain(code.replace("-", ""));278+		// The write key is read from kepr.toml alone, so no copy of kepr.db can hold it.279+		const bytes = await Bun.file(file).bytes();280+		expect(new TextDecoder().decode(bytes)).not.toContain("WRITE-SECRET");281+	});282+});283+284+describe.serial("connected machines in admin", () => {285+	test("lists redeemed machines newest first, at most twenty, with revocation guidance", async () => {286+		const { app, browser } = await connected();287+		for (let i = 0; i < 22; i++) {288+			const { code } = await issue(browser, `machine-${i}`);289+			const response = await redeem(app, code, `10.0.0.${i + 1}`);290+			expect(response.status).toBe(200);291+			clock += 1000;292+			setSystemTime(clock);293+		}294+		const admin = await browser.get("/admin");295+		const html = await admin.text();296+		expect(html).toContain("Machines connected");297+		expect(html).toContain("rotate the write key in Scaleway");298+		expect(html).not.toContain("machine-0 ");299+		expect(html).toContain("machine-21");300+		expect(html.indexOf("machine-21")).toBeLessThan(html.indexOf("machine-20"));301+	});302+});

view file