556a0d535096

add two-step sign-in and give code strings green and constants rose (v3.2.0)

time
2026-10-08 19:50 UTC · 4 days ago
machine
Junes-MacBook-Pro.local
agent
deepseek-flash
session
two-step sign-in
parent
69d1e4392039
9 files package.json+1 −1public/css/kepr.css+9 −5src/admin/account.ts+55 −1src/server/config.ts+10 −0src/server/login.ts+33 −7src/server/totp.ts+106 −0tests/account.test.ts+80 −2tests/browser.ts+2 −2tests/totp.test.ts+60 −0
package.json+1 −1
@@ -1,6 +1,6 @@11 {22 	"name": "kepr",3-	"version": "3.1.33",3+	"version": "3.2.0",44 	"private": true,55 	"type": "module",66 	"scripts": {

view file

public/css/kepr.css+9 −5
@@ -1,4 +1,4 @@1-/* Amber carries the brand; neighbouring gold and warm red keep the palette cohesive. */1+/* Amber carries the brand; warm red, rose and green stay for code and outcomes. */22 33 :root {44 	--bg: #0d0805;@@ -16,6 +16,7 @@1616 	--accent-tint-04: rgba(255, 173, 51, 0.04);1717 	--accent-tint-12: rgba(255, 173, 51, 0.12);1818 	--sage: #9ad65f;19+	--rose: #ff8ab4;1920 	--syntax-red: #ff8f86;2021 	--sans: "Quicksand", system-ui, sans-serif;2122 	--mono: "Martian Mono", ui-monospace, monospace;@@ -1104,18 +1105,21 @@11041105 	text-decoration: none;11051106 }11061107 1107-/* A gold-to-amber-to-red range keeps syntax vivid without colouring every identifier. */1108+/* One hue per role, so a file of strung-together amber reads as structure: amber keywords,1109+green strings, rose constants, warm red functions, ivory parameters. */11081110 .tk-comment {11091111 	color: var(--text-muted);11101112 	font-style: italic;11111113 }1112-.tk-keyword,1113-.tk-constant {1114+.tk-keyword {11141115 	color: var(--accent);11151116 }1117+.tk-constant {1118+	color: var(--rose);1119+}11161120 .tk-string,11171121 .tk-string-expression {1118-	color: var(--accent);1122+	color: var(--sage);11191123 }11201124 .tk-function {11211125 	color: var(--syntax-red);

view file

src/admin/account.ts+55 −1
@@ -2,7 +2,9 @@22 import { html } from "../pages/html.ts";33 import { plural } from "../pages/layout.ts";44 import { isDomain, saveConfig } from "../server/config.ts";5+import { keepState } from "../server/db.ts";56 import { endOtherSessions, liveSessions } from "../server/login.ts";7+import { isSecret, newSecret, otpauthUri, stepFor } from "../server/totp.ts";68 import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";79 810 export const MIN_PASSWORD = 12;@@ -34,6 +36,26 @@3436 			await save({ adminPasswordHash: await Bun.password.hash(password, "argon2id") });3537 			// Whoever knew the old password is signed out with it.3638 			endOtherSessions(ctx, c);39+		} else if (fields.action === "totp-start") {40+			return c.html(accountPage(c, ctx, "", newSecret()));41+		} else if (fields.action === "totp-confirm") {42+			const secret = fields.secret ?? "";43+			const step = isSecret(secret)44+				? stepFor(secret, (fields.code ?? "").trim(), Math.floor(Date.now() / 1000))45+				: null;46+			if (step === null) {47+				return c.html(accountPage(c, ctx, "That code did not match. Set it up again.", ""), 400);48+			}49+			// The code that set it up is spent, so the same one cannot also sign in (§24).50+			keepState(ctx.kepr, "totp_step", String(step));51+			await save({ totpSecret: secret });52+		} else if (fields.action === "totp-off") {53+			const current = fields.current_password ?? "";54+			if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {55+				return refuse("The current password is not right.");56+			}57+			keepState(ctx.kepr, "totp_step", null);58+			await save({ totpSecret: undefined });3759 		} else if (fields.action === "sessions") {3860 			endOtherSessions(ctx, c);3961 		} else if (fields.action === "domain") {@@ -51,11 +73,41 @@5173 	});5274 }5375 54-function accountPage(c: Context, ctx: AdminContext, error: string): string {76+function accountPage(c: Context, ctx: AdminContext, error: string, offered = ""): string {5577 	const password = (name: string, label: string, autocomplete: string) =>5678 		html`<label class="issues-label" for="account-${name}">${label}</label>5779 <input class="issues-input" id="account-${name}" name="${name}" type="password"5880 autocomplete="${autocomplete}" required>`;81+	// Setting up keeps the offered secret in the form: the code that confirms it proves whoever82+	// typed it holds it, so a tampered field can only fail.83+	const twoStep =84+		offered !== ""85+			? html`<p>Add this key to an authenticator app by hand, or paste the address under it, then86+type the code the app shows. The key is shown this once; from then on sign-in asks for the password87+and a code.</p>88+<p class="issue-meta mono">key: ${offered}</p>89+<p class="issue-meta mono">${otpauthUri(offered, ctx.domain)}</p>90+<form class="issues-form" method="post" action="/admin/account">91+${formField(c)}<input type="hidden" name="action" value="totp-confirm">92+<input type="hidden" name="secret" value="${offered}">93+<label class="issues-label" for="account-code">code from the app</label>94+<input class="issues-input mono" id="account-code" name="code" inputmode="numeric"95+autocomplete="one-time-code" required autofocus>96+<button class="issues-submit" type="submit">turn two-step sign-in on</button>97+</form>`98+			: ctx.config.totpSecret === undefined99+				? html`<p>Off. Sign-in asks for the password alone.</p>100+<form method="post" action="/admin/account">101+${formField(c)}<input type="hidden" name="action" value="totp-start">102+<button class="issues-submit" type="submit">set up two-step sign-in</button>103+</form>`104+				: html`<p>On. Sign-in asks for the password and the code from your authenticator app.</p>105+<form class="issues-form" method="post" action="/admin/account">106+${formField(c)}<input type="hidden" name="action" value="totp-off">107+${password("current_password", "password, to turn it off", "current-password")}108+<p class="issue-meta mono">The password is also the way back in when the phone is lost.</p>109+<button class="issues-submit" type="submit">turn it off</button>110+</form>`;59111 	const body = html`${notice(error)}60112 <section class="repo-section"><h2 class="section-label mono">password</h2>61113 <form class="issues-form" method="post" action="/admin/account">@@ -66,6 +118,8 @@66118 <p class="issue-meta mono">Changing it signs out every other session.</p>67119 <button class="issues-submit" type="submit">change password</button>68120 </form></section>121+<section class="repo-section"><h2 class="section-label mono">two-step sign-in</h2>122+${twoStep}</section>69123 <section class="repo-section"><h2 class="section-label mono">sessions</h2>70124 <p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>71125 <form method="post" action="/admin/account">

view file

src/server/config.ts+10 −0
@@ -1,4 +1,5 @@11 import { chmod, rename, writeFile } from "node:fs/promises";2+import { isSecret } from "./totp.ts";23 34 export type BucketSource =45 	| {@@ -42,6 +43,8 @@4243 	domain: string;4344 	dataDir: string;4445 	adminPasswordHash: string;46+	/** The two-step sign-in secret (§24), base32; absent leaves sign-in to the password alone. */47+	totpSecret?: string;4548 	bucket: BucketSource;4649 	smtp?: SmtpSettings;4750 	backup?: BackupBucket;@@ -77,10 +80,16 @@7780 	// Pages, feeds and install.sh put it in URLs and a shell script as is.7881 	if (!HOSTNAME.test(domain))7982 		throw new ConfigError("domain must be a bare hostname, with an optional :port");83+	// A secret that cannot be read is a lockout, so it is refused at startup, not at sign-in.84+	const totpSecret = typeof top.totp_secret === "string" ? top.totp_secret.trim() : "";85+	if (totpSecret !== "" && !isSecret(totpSecret)) {86+		throw new ConfigError("totp_secret must be a base32 secret of at least 16 bytes");87+	}8088 	return {8189 		domain,8290 		dataDir: requireString(top, "data_dir"),8391 		adminPasswordHash,92+		...(totpSecret === "" ? {} : { totpSecret }),8493 		bucket: parseBucket(bucket),8594 		...(top.smtp === undefined ? {} : { smtp: parseSmtp(asTable(top.smtp, "[smtp]")) }),8695 		...(top.backup === undefined ? {} : { backup: parseBackup(asTable(top.backup, "[backup]")) }),@@ -133,6 +142,7 @@133142 		`domain = ${str(config.domain)}`,134143 		`data_dir = ${str(config.dataDir)}`,135144 		`admin_password_hash = ${str(config.adminPasswordHash)}`,145+		...(config.totpSecret ? [`totp_secret = ${str(config.totpSecret)}`] : []),136146 		"",137147 		"[bucket]",138148 	];

view file

src/server/login.ts+33 −7
@@ -4,6 +4,8 @@44 import { html } from "../pages/html.ts";55 import { type PageContext, page } from "../pages/layout.ts";66 import { clientIp } from "./client.ts";7+import { keepState, setting } from "./db.ts";8+import { DIGITS, stepFor } from "./totp.ts";79 810 const SESSION_SECONDS = 30 * 86400;911 const SESSION_COOKIE = "kepr_session";@@ -122,11 +124,12 @@122124 export function registerLogin(123125 	app: Hono,124126 	ctx: PageContext,125-	// The live config, so a password changed in the console works at once.126-	config: { adminPasswordHash: string },127+	// The live config, so a password or a two-step secret changed in the console works at once.128+	config: { adminPasswordHash: string; totpSecret?: string },127129 ): void {128130 	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>129131 		adminRefusal(c, ctx, status, text);132+	const asksCode = () => (config.totpSecret ?? "") !== "";130133 131134 	app.use("/admin/*", async (c, next) => {132135 		const token = sessionToken(ctx, c);@@ -141,7 +144,9 @@141144 		c.header("Cache-Control", "no-store");142145 	});143146 144-	app.get("/sign-in", (c) => signInPage(c, ctx, "", 200, nextPath(c.req.query("next"))));147+	app.get("/sign-in", (c) =>148+		signInPage(c, ctx, "", 200, nextPath(c.req.query("next")), asksCode()),149+	);145150 146151 	app.post("/sign-in", async (c) => {147152 		const fields = await c.req.parseBody();@@ -157,12 +162,26 @@157162 		const mine = failures.filter((failure) => failure.address === address).length;158163 		if (mine >= FAILURES_PER_ADDRESS) {159164 			c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));160-			return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next);165+			return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next, asksCode());161166 		}162167 		const password = typeof fields.password === "string" ? fields.password : "";163-		if (!(await Bun.password.verify(password, config.adminPasswordHash))) {168+		const passwordOk = await Bun.password.verify(password, config.adminPasswordHash);169+		// With two-step sign-in on, one refusal covers both, so no page says which was wrong (§24).170+		const wrong = asksCode()171+			? "That password and code did not match."172+			: "That password is not right.";173+		const refused = () => {164174 			failures.push({ at: now, address });165-			return signInPage(c, ctx, "That password is not right.", 401, next);175+			return signInPage(c, ctx, wrong, 401, next, asksCode());176+		};177+		if (!passwordOk) return refused();178+		if (asksCode()) {179+			const code = typeof fields.code === "string" ? fields.code.trim() : "";180+			const step = stepFor(config.totpSecret ?? "", code, nowSeconds());181+			const used = Number(setting(ctx.kepr, "totp_step") ?? -1);182+			if (step === null || step <= used) return refused();183+			// The step is remembered, so a code that has signed in once cannot sign in again (§24).184+			keepState(ctx.kepr, "totp_step", String(step));166185 		}167186 		const token = randomBytes(32).toString("hex");168187 		const at = nowSeconds();@@ -205,6 +224,7 @@205224 	error: string,206225 	status: 200 | 401 | 429,207226 	next: string,227+	code: boolean,208228 ) {209229 	let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";210230 	if (!HEX64.test(cookie)) {@@ -218,7 +238,13 @@218238 <input type="hidden" name="form" value="${cookie}">219239 <input type="hidden" name="next" value="${next}">220240 <label class="issues-label" for="password">password</label>221-<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>241+<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password"${code ? "" : html` autofocus`}>242+${243+	code &&244+	html`<label class="issues-label" for="code">code from your authenticator app</label>245+<input class="issues-input mono" id="code" name="code" inputmode="numeric" autocomplete="one-time-code"246+pattern="[0-9]{${DIGITS}}" required autofocus>`247+}222248 <button class="issues-submit" type="submit">sign in</button>223249 </form>`;224250 	return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);

view file

src/server/totp.ts+106 −0
@@ -0,0 +1,106 @@1+import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";2+3+// RFC 6238 with the parameters every authenticator app assumes: SHA-1, a 30-second step, six4+// digits. Kepr 2's operator login carried a base32 secret the same way.5+const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";6+const STEP_SECONDS = 30;7+export const DIGITS = 6;8+// The step either side of now: a clock a little out of step is not a lockout.9+const STEPS_EITHER_SIDE = 1;10+// 160 bits, the length RFC 4226 recommends and what authenticators generate.11+const SECRET_BYTES = 20;12+13+function base32Encode(bytes: Uint8Array): string {14+	let bits = 0;15+	let value = 0;16+	let text = "";17+	for (const byte of bytes) {18+		value = (value << 8) | byte;19+		bits += 8;20+		while (bits >= 5) {21+			text += ALPHABET[(value >>> (bits - 5)) & 31];22+			bits -= 5;23+		}24+	}25+	if (bits > 0) text += ALPHABET[(value << (5 - bits)) & 31];26+	return text;27+}28+29+/** The bytes a base32 secret stands for, or null when it is not base32 at all. */30+function base32Decode(text: string): Uint8Array | null {31+	const trimmed = text.trim().toUpperCase().replace(/=+$/, "");32+	if (trimmed === "" || /[^A-Z2-7]/.test(trimmed)) return null;33+	const bytes: number[] = [];34+	let bits = 0;35+	let value = 0;36+	for (const character of trimmed) {37+		value = (value << 5) | ALPHABET.indexOf(character);38+		bits += 5;39+		if (bits >= 8) {40+			bytes.push((value >>> (bits - 8)) & 0xff);41+			bits -= 8;42+		}43+	}44+	return Uint8Array.from(bytes);45+}46+47+/** A fresh secret, in the base32 an authenticator app is given. */48+export function newSecret(): string {49+	return base32Encode(randomBytes(SECRET_BYTES));50+}51+52+/** True for base32 that decodes to at least 16 bytes: shorter is not worth offering. */53+export function isSecret(value: string): boolean {54+	const bytes = base32Decode(value);55+	return bytes !== null && bytes.length >= 16;56+}57+58+function codeAtStep(key: Uint8Array, step: number): string {59+	const counter = Buffer.alloc(8);60+	counter.writeBigUInt64BE(BigInt(step));61+	const mac = createHmac("sha1", key).update(counter).digest();62+	const offset = mac[mac.length - 1]! & 0x0f;63+	const value = mac.readUInt32BE(offset) & 0x7fffffff;64+	return String(value % 10 ** DIGITS).padStart(DIGITS, "0");65+}66+67+/** The code an authenticator app shows at `now`, or null when the secret cannot be read. */68+export function codeAt(secret: string, now: number): string | null {69+	const key = base32Decode(secret);70+	if (key === null) return null;71+	return codeAtStep(key, Math.floor(now / STEP_SECONDS));72+}73+74+/** Six digits, and nothing else: what an authenticator app shows. */75+const CODE = new RegExp(`^\\d{${DIGITS}}$`);76+77+/**78+ * The step a code belongs to, or null when it is not the code for a step near `now`. Callers79+ * remember the step they accepted, so the same code cannot sign in twice (§24).80+ */81+export function stepFor(secret: string, code: string, now: number): number | null {82+	const key = base32Decode(secret);83+	if (key === null || !CODE.test(code)) return null;84+	const given = Buffer.from(code);85+	const here = Math.floor(now / STEP_SECONDS);86+	for (let step = here - STEPS_EITHER_SIDE; step <= here + STEPS_EITHER_SIDE; step++) {87+		if (step < 0) continue;88+		const expected = Buffer.from(codeAtStep(key, step));89+		if (timingSafeEqual(given, expected)) return step;90+	}91+	return null;92+}93+94+/** What an authenticator app reads, for the console to show once while setting up. */95+export function otpauthUri(secret: string, domain: string): string {96+	const issuer = encodeURIComponent("kepr");97+	const label = `${issuer}:${encodeURIComponent(domain)}`;98+	const query = new URLSearchParams({99+		secret,100+		issuer: "kepr",101+		algorithm: "SHA1",102+		digits: String(DIGITS),103+		period: String(STEP_SECONDS),104+	});105+	return `otpauth://totp/${label}?${query.toString()}`;106+}

view file

tests/account.test.ts+80 −2
@@ -1,10 +1,12 @@11 // Covers the console's Account section (June, 2026-09-30): the admin password, other sessions and2-// the domain, all changed without touching kepr.toml by hand, and applied without a restart.2+// the domain, all changed without touching kepr.toml by hand, and applied without a restart. Also3+// §24's two-step sign-in, which is set up here and then used at /sign-in.34 4-import { beforeAll, describe, expect, test } from "bun:test";5+import { afterAll, beforeAll, describe, expect, setSystemTime, test } from "bun:test";56 import { join } from "node:path";67 import { createApp } from "../src/server/app.ts";78 import { loadConfig, saveConfig } from "../src/server/config.ts";9+import { codeAt } from "../src/server/totp.ts";810 import { Browser, type Instance, instance, PASSWORD } from "./browser.ts";911 import { makeRepos } from "./fixture.ts";1012 @@ -101,5 +103,81 @@101103 		expect((await operator.post("/admin/sync", {})).status).toBe(403);102104 		const page = await (await operator.get("/admin/account")).text();103105 		expect(page).toContain('<link rel="canonical" href="https://code.example/admin/account">');106+	});107+});108+109+describe("two-step sign-in (§24)", () => {110+	let inst: Instance;111+	let operator: Browser;112+	let configPath: string;113+	const at = Date.parse("2026-10-08T12:00:00Z");114+	const seconds = (offset: number) => Math.floor(at / 1000) + offset;115+116+	beforeAll(async () => {117+		setSystemTime(new Date(at));118+		const repos = await makeRepos(["app"]);119+		inst = await instance(repos.bucketDir);120+		configPath = join(inst.dataDir, "kepr.toml");121+		await saveConfig(configPath, inst.config);122+		inst.app = createApp(inst.config, inst.kepr, undefined, configPath);123+		operator = new Browser(inst.app);124+		await operator.signIn();125+	});126+	afterAll(() => setSystemTime());127+128+	test("a key is offered, and only a code from it turns two-step on", async () => {129+		const offered = await (await operator.post("/admin/account", { action: "totp-start" })).text();130+		const secret = /key: ([A-Z2-7]{32})/.exec(offered)?.[1] ?? "";131+		expect(secret).toHaveLength(32);132+		expect(offered).toContain("otpauth://totp/kepr:kepr.example");133+		expect(inst.config.totpSecret).toBeUndefined();134+		const wrong = await operator.post("/admin/account", {135+			action: "totp-confirm",136+			secret,137+			code: "000000",138+		});139+		expect(wrong.status).toBe(400);140+		expect(inst.config.totpSecret).toBeUndefined();141+		const confirmed = await operator.post("/admin/account", {142+			action: "totp-confirm",143+			secret,144+			code: codeAt(secret, seconds(0)) ?? "",145+		});146+		expect(confirmed.status).toBe(303);147+		expect(inst.config.totpSecret).toBe(secret);148+		expect((await loadConfig(configPath)).totpSecret).toBe(secret);149+	});150+151+	test("sign-in then takes the code as well, and a code signs in once", async () => {152+		const secret = inst.config.totpSecret ?? "";153+		const bare = new Browser(inst.app, "198.51.100.90");154+		expect((await bare.signIn(PASSWORD)).status).toBe(401);155+		const wrong = new Browser(inst.app, "198.51.100.91");156+		expect((await wrong.signIn(PASSWORD, "000000")).status).toBe(401);157+		// The code that set it up is spent, so a later step's code is the one to use.158+		const code = codeAt(secret, seconds(30)) ?? "";159+		const fresh = new Browser(inst.app, "198.51.100.92");160+		expect((await fresh.signIn(PASSWORD, code)).status).toBe(303);161+		expect((await fresh.get("/admin")).status).toBe(200);162+		const replay = new Browser(inst.app, "198.51.100.93");163+		expect((await replay.signIn(PASSWORD, code)).status).toBe(401);164+	});165+166+	test("turning it off takes the password, and then the password alone signs in", async () => {167+		const wrong = await operator.post("/admin/account", {168+			action: "totp-off",169+			current_password: "not the password",170+		});171+		expect(wrong.status).toBe(400);172+		expect(inst.config.totpSecret).toBeDefined();173+		const off = await operator.post("/admin/account", {174+			action: "totp-off",175+			current_password: PASSWORD,176+		});177+		expect(off.status).toBe(303);178+		expect(inst.config.totpSecret).toBeUndefined();179+		expect((await loadConfig(configPath)).totpSecret).toBeUndefined();180+		const plain = new Browser(inst.app, "198.51.100.94");181+		expect((await plain.signIn(PASSWORD)).status).toBe(303);104182 	});105183 });

view file

tests/browser.ts+2 −2
@@ -51,9 +51,9 @@5151 		return this.request(path, { method: "POST", headers: origin ? { origin } : {}, body });5252 	}5353 54-	async signIn(password = PASSWORD): Promise<Response> {54+	async signIn(password = PASSWORD, code = ""): Promise<Response> {5555 		await this.get("/sign-in");56-		const response = await this.post("/sign-in", { password });56+		const response = await this.post("/sign-in", { password, ...(code ? { code } : {}) });5757 		if (response.status === 303) await this.get("/admin");5858 		return response;5959 	}

view file

tests/totp.test.ts+60 −0
@@ -0,0 +1,60 @@1+// Covers §24's two-step sign-in: RFC 6238 itself, the step either side of now, and what is refused.2+3+import { describe, expect, test } from "bun:test";4+import { codeAt, DIGITS, isSecret, newSecret, stepFor } from "../src/server/totp.ts";5+6+// RFC 6238's own secret and the 8-digit codes its appendix B publishes. The six-digit ones are the7+// same dynamic truncation with the rest of the number dropped, checked against an hour of Python.8+const SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";9+const VECTORS: [at: number, code: string, step: number][] = [10+	[59, "287082", 1],11+	[1111111109, "081804", 37037036],12+	[1234567890, "005924", 41152263],13+	[2000000000, "279037", 66666666],14+];15+16+describe("TOTP codes (§24)", () => {17+	test("matches the RFC's vectors, at the step they belong to", () => {18+		for (const [at, code, step] of VECTORS) {19+			expect(stepFor(SECRET, code, at)).toBe(step);20+			expect(codeAt(SECRET, at)).toBe(code);21+		}22+	});23+24+	test("accepts a step either side of now, and nothing further", () => {25+		const [at, code] = VECTORS[0] as [number, string, number];26+		expect(stepFor(SECRET, code, at + 30)).toBe(1);27+		expect(stepFor(SECRET, code, at - 30)).toBe(1);28+		expect(stepFor(SECRET, code, at + 60)).toBeNull();29+		expect(stepFor(SECRET, code, at - 61)).toBeNull();30+	});31+32+	test("a code is only right for its own secret and its own shape", () => {33+		const other = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP";34+		expect(stepFor(other, "287082", 59)).toBeNull();35+		expect(stepFor(SECRET, "28708", 59)).toBeNull();36+		expect(stepFor(SECRET, "2870821", 59)).toBeNull();37+		expect(stepFor(SECRET, "abcdef", 59)).toBeNull();38+		expect(stepFor(SECRET, "", 59)).toBeNull();39+		expect(stepFor("not base32!", "287082", 59)).toBeNull();40+	});41+42+	test("a fresh secret is base32 of the length authenticators use", () => {43+		const secret = newSecret();44+		expect(isSecret(secret)).toBe(true);45+		expect(secret).toHaveLength(32);46+		expect(secret).toMatch(/^[A-Z2-7]+$/);47+		expect(secret).not.toBe(newSecret());48+		expect(codeAt(secret, 1111111109)).toHaveLength(DIGITS);49+	});50+51+	test("a secret too short to be worth having is refused, as is anything that is not base32", () => {52+		// 128 bits is the shortest RFC 4226 recommends, so 26 base32 characters.53+		expect(isSecret("ABCD")).toBe(false);54+		expect(isSecret("OOOO")).toBe(false);55+		expect(isSecret("")).toBe(false);56+		expect(isSecret("JBSWY3DPEHPK3PXP")).toBe(false);57+		expect(isSecret("not base32!")).toBe(false);58+		expect(isSecret(SECRET)).toBe(true);59+	});60+});

view file