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
↓ kepr-556a0d53.tar.gzatlas at this save
files changed9 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 −0package.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": {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);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">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 ];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);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+}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 });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 }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+});