From ed048c5bb326d5b79a6c53ea3f198c81a3d61ced Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:25:52 +0200 Subject: [PATCH] feat(auth): generated one-time passwords for new accounts --- src/lib/auth/initial-password.test.ts | 34 +++++++++++++++++++++++++ src/lib/auth/initial-password.ts | 36 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/lib/auth/initial-password.test.ts create mode 100644 src/lib/auth/initial-password.ts diff --git a/src/lib/auth/initial-password.test.ts b/src/lib/auth/initial-password.test.ts new file mode 100644 index 0000000..8a89e5b --- /dev/null +++ b/src/lib/auth/initial-password.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { ALPHABET, generateInitialPassword } from "@/lib/auth/initial-password"; + +describe("generateInitialPassword", () => { + it("produces three groups of four, hyphen separated", () => { + expect(generateInitialPassword()).toMatch(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/); + }); + + it("clears PocketBase's eight-character minimum", () => { + expect(generateInitialPassword().length).toBeGreaterThanOrEqual(8); + }); + + it("omits characters that are misread when typed from a screen", () => { + // The owner reads this aloud or copies it by hand into an email, so 0/O + // and 1/l/I must not both be possible. + for (const banned of ["0", "o", "1", "l", "i"]) { + expect(ALPHABET).not.toContain(banned); + } + }); + + it("draws only from the declared alphabet", () => { + for (let i = 0; i < 50; i++) { + for (const char of generateInitialPassword().replace(/-/g, "")) { + expect(ALPHABET).toContain(char); + } + } + }); + + it("does not repeat across calls", () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generateInitialPassword()); + expect(seen.size).toBe(200); + }); +}); \ No newline at end of file diff --git a/src/lib/auth/initial-password.ts b/src/lib/auth/initial-password.ts new file mode 100644 index 0000000..2c48395 --- /dev/null +++ b/src/lib/auth/initial-password.ts @@ -0,0 +1,36 @@ +/** + * A one-time password for a newly issued client-manager account. + * + * Generated rather than chosen, because the alternative is the owner inventing + * one password and reusing it for every client they onboard. + * + * Uses the Web Crypto API, not Math.random. Math.random is seeded from + * predictable state and is not a credential source; the two are one character + * apart to write and worlds apart in consequence. + */ + +/** + * Deliberately excludes 0/o, 1/l/i and u/v — the pairs that get misread when + * the owner copies this into an email or reads it over the phone. + */ +export const ALPHABET = "23456789abcdefghjkmnpqrstwxyz"; + +const GROUPS = 3; +const GROUP_LENGTH = 4; + +export function generateInitialPassword(): string { + const bytes = new Uint32Array(GROUPS * GROUP_LENGTH); + crypto.getRandomValues(bytes); + + const chars = Array.from( + bytes, + (value) => ALPHABET[value % ALPHABET.length] + ); + + const groups: string[] = []; + for (let i = 0; i < GROUPS; i++) { + groups.push(chars.slice(i * GROUP_LENGTH, (i + 1) * GROUP_LENGTH).join("")); + } + + return groups.join("-"); +} \ No newline at end of file