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
↓ kepr-b16612d4.tar.gzatlas at this save
files changed11 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 −0kepr.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: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": {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 ),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 };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 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+}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 = {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>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",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`;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("<img src=x onerror=alert(1)>");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+});