From 2aa6f87b25b7d03b0c7ae4efa718d746ab5ab482 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 3 Nov 2026 16:15:08 +0100 Subject: [PATCH 1/2] feat(admin)!: no default admin; password hashing comes from @dune/core BREAKING: ensureDefaultAdmin() and the plaintext initial-password.txt are removed. The first admin is an explicit act: `dune users:create --role admin --password-prompt`, or DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by @dune/core). A site with no enabled admin logs a warning saying how to create one. Sites that already have an admin are unaffected. Password hashing now comes from @dune/core/auth/passwords; the plugin's own copy is removed. hashPassword, verifyPassword and DUMMY_HASH are still re-exported from the admin entry point. The hash format is unchanged. Requires @dune/core 0.35.3 (duneorg/dune#84, #86). Refs duneorg/dune#69. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 +++ deno.json | 2 +- mod.ts | 22 +++-- scripts/generate-core-imports.ts | 12 ++- src/admin/auth/local-provider.ts | 2 +- src/admin/auth/passwords.ts | 123 ------------------------- src/admin/auth/users.ts | 71 ++------------ src/admin/mod.ts | 2 +- src/admin/routes/login.tsx | 2 +- src/admin/timing-safe.ts | 2 +- tests/admin/auth_test.ts | 33 +------ tests/admin/codeql_regressions_test.ts | 22 ----- 12 files changed, 52 insertions(+), 256 deletions(-) delete mode 100644 src/admin/auth/passwords.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b7524..84a9c3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Changed + +- **BREAKING: no default admin is created any more.** `ensureDefaultAdmin()` + and the plaintext `initial-password.txt` are gone (that file was committed to a + repo once). The first admin is an explicit act: `dune users:create + --role admin --password-prompt`, or `DUNE_ADMIN_EMAIL` + `DUNE_ADMIN_PASSWORD` + at boot (handled by `@dune/core`). A site with no enabled admin logs a warning + saying how to create one. Existing sites that already have an admin are + unaffected. `UserManager.ensureDefaultAdmin` and `generatePassword` are removed. +- **Password hashing comes from `@dune/core/auth/passwords`.** The plugin's own + copy (`src/admin/auth/passwords.ts`) is removed; `hashPassword`, + `verifyPassword` and `DUMMY_HASH` are still re-exported from the admin entry + point. The hash format is unchanged. **Requires a `@dune/core` that includes + `@dune/core/auth/passwords` and the env bootstrap (duneorg/dune#84, #86).** + ## [3.2.0] — 2026-11-03 ### Changed diff --git a/deno.json b/deno.json index 1957388..b3686a0 100644 --- a/deno.json +++ b/deno.json @@ -18,7 +18,7 @@ "./admin/auth/provider": "./src/admin/auth/provider.ts" }, "imports": { - "@dune/core": "jsr:@dune/core@^0.35.2", + "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", "preact/hooks": "npm:preact@^10/hooks", diff --git a/mod.ts b/mod.ts index 406e5fc..a0d9f90 100644 --- a/mod.ts +++ b/mod.ts @@ -197,7 +197,7 @@ export function createAdminPlugin( logger.warn("admin.users.legacy-location", { legacyDir: legacyUsersDir, newDir: `${dataDir}/users`, - message: "Move user files or a new default admin will be created", + message: "Move the user files to the new directory, or those accounts will not be found", }); } } catch { /* ignore */ } @@ -382,16 +382,22 @@ export function createAdminPlugin( // AdminContext type — plugin-admin owns the actual shape. bootstrap.adminContext = adminContext as unknown as Record; - // Ensure a default admin user exists on first run. - const result = await users.ensureDefaultAdmin(); - if (result.created) { - console.log(`\n 🔑 Default admin created — username: admin`); - console.log(` Password written to: ${result.passwordFile}`); - console.log(` Read it, then delete the file and change your password.\n`); + // No default admin is ever created. The first admin is an explicit act: + // `dune users:create --role admin --password-prompt`, or + // DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by core before + // this runs). Say so when there is none, instead of leaving a login page + // nobody can pass. + const existingUsers = await users.list(); + if (!existingUsers.some((u) => u.enabled !== false && u.roles.includes("admin"))) { + console.warn( + `\n ⚠️ No admin account yet — the admin panel has nobody who can sign in.\n` + + ` Create one: dune users:create --role admin --password-prompt\n` + + ` (or set DUNE_ADMIN_EMAIL and DUNE_ADMIN_PASSWORD for the first boot)\n`, + ); } // ── authz tuple bootstrap ───────────────────────────────────────────────── - // Runs after ensureDefaultAdmin() so a freshly-created default admin's + // Runs after core's env bootstrap so a freshly-created first admin's // tuple gets registered too, instead of being locked out on first run. if (bootstrap.authz && bootstrap.authzAdapter) { try { diff --git a/scripts/generate-core-imports.ts b/scripts/generate-core-imports.ts index 6f68b06..95d6f9a 100644 --- a/scripts/generate-core-imports.ts +++ b/scripts/generate-core-imports.ts @@ -43,10 +43,12 @@ * auto-tracks patch releases within that minor — confirmed via `deno cache`: * "0.28" resolved to the latest 0.28.x, not just 0.28.0. * - * Floored at 0.35.2: the public form routes and the submission store moved into - * core there (`BootstrapResult.submissions`, `registerFormRoutes()` and the - * `onSystemAction` hook from `@dune/core/forms`), and this package no longer - * registers them itself. A bare "0.35" would floor JSR's publish-time check at + * Floored at 0.35.3: password hashing (`@dune/core/auth/passwords`) and the + * `DUNE_ADMIN_*` first-admin bootstrap moved into core there, and this package + * no longer carries its own copy or a default admin. (0.35.2 before that: the + * public form routes and the submission store moved into core, with + * `BootstrapResult.submissions`, `registerFormRoutes()` and the `onSystemAction` + * hook from `@dune/core/forms`.) A bare "0.35" would floor JSR's publish-time check at * 0.35.0 (reproduced 2026-08-31 with "0.34": * "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema', * resolved to 0.34.0, has no export") and let a site pair this package with a @@ -54,7 +56,7 @@ * minor. This constant and deno.json are only kept in step by * `check:core-imports`, so run it before merging any pin change. */ -const CORE_RANGE = "^0.35.2"; +const CORE_RANGE = "^0.35.3"; const DENO_JSON = "deno.json"; const SCAN_ROOTS = ["mod.ts", "src", "tests"]; diff --git a/src/admin/auth/local-provider.ts b/src/admin/auth/local-provider.ts index a257397..8e9d4f0 100644 --- a/src/admin/auth/local-provider.ts +++ b/src/admin/auth/local-provider.ts @@ -7,7 +7,7 @@ import type { AuthProvider, AuthCredentials, AuthProviderUser } from "./provider.ts"; import type { UserManager } from "./users.ts"; -import { verifyPassword, DUMMY_HASH } from "./passwords.ts"; +import { verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; export class LocalAuthProvider implements AuthProvider { readonly type = "local" as const; diff --git a/src/admin/auth/passwords.ts b/src/admin/auth/passwords.ts deleted file mode 100644 index 3ab85fa..0000000 --- a/src/admin/auth/passwords.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Password hashing using PBKDF2 with SHA-256. - * - * Format: `pbkdf2:iterations:salt:hash` where salt and hash are hex-encoded. - * - * Modern best practice is Argon2id with `m=64MiB, t=3, p=1`. Migrating to - * Argon2id is a multi-step change (new dependency, schema versioning, opaque - * blob format); it's tracked separately. As an intermediate hardening this - * commit raises the PBKDF2 iteration count to OWASP-2024's minimum of - * 600 000 for SHA-256 and adds a `needsRehash()` helper so a future - * Argon2id (or higher-iteration) migration can transparently rehash on - * successful login. - */ - -import { encodeHex } from "@std/encoding/hex"; -import { timingSafeEqual } from "../timing-safe.ts"; - -/** - * Pre-computed PBKDF2 hash used for constant-time dummy comparisons. - * - * When a login attempt uses an unknown username, we still run `verifyPassword()` - * against this hash so the response time is indistinguishable from a valid-user - * attempt (preventing username enumeration via timing side-channels). - * The hash will never validate — it is a fixed string in the correct format. - * - * Iteration count must equal ITERATIONS so dummy compares pay the same CPU - * cost as real ones; otherwise the time delta becomes a username oracle. - */ -const ITERATIONS = 600_000; // OWASP 2024 minimum for PBKDF2-SHA256 -const MIN_ACCEPTABLE_ITERATIONS = 100_000; // grandfathered legacy hashes -/** - * Pre-computed PBKDF2 hash used in constant-time dummy comparisons when a - * username lookup fails, preventing timing-based username enumeration. - * @internal - */ -export const DUMMY_HASH = - `pbkdf2:${ITERATIONS}:00000000000000000000000000000000:0000000000000000000000000000000000000000000000000000000000000000`; - -const KEY_LENGTH = 32; -const SALT_LENGTH = 16; -const ALGORITHM = "PBKDF2"; -const HASH_ALGO = "SHA-256"; - -/** - * Hash a plaintext password. - * Returns a string in the format `pbkdf2:600000:salt:hash`. - */ -export async function hashPassword(password: string): Promise { - const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); - const key = await deriveKey(password, salt, ITERATIONS); - return `pbkdf2:${ITERATIONS}:${encodeHex(salt)}:${encodeHex(key)}`; -} - -/** - * Returns true if the stored hash is below the current cost parameters and - * should be re-hashed on the next successful login. Callers can wrap their - * verify-then-rehash flow with this check. - */ -export function needsRehash(stored: string): boolean { - const parts = stored.split(":"); - if (parts.length !== 4 || parts[0] !== "pbkdf2") return true; - const iter = parseInt(parts[1], 10); - if (!Number.isFinite(iter)) return true; - return iter < ITERATIONS; -} - -/** - * Verify a password against a stored hash. - */ -export async function verifyPassword( - password: string, - stored: string, -): Promise { - const parts = stored.split(":"); - if (parts.length !== 4 || parts[0] !== "pbkdf2") return false; - - const iterations = parseInt(parts[1], 10); - // Refuse cost parameters below an absolute floor — protects against a - // tampered user file that downgrades iterations to make brute force - // trivial on a leaked hash. - if (!Number.isFinite(iterations) || iterations < MIN_ACCEPTABLE_ITERATIONS) { - return false; - } - const salt = hexToBytes(parts[2]); - const expectedBytes = hexToBytes(parts[3]); - - const derivedBytes = await deriveKey(password, salt, iterations); - - // Constant-time byte comparison — avoids the timing side-channel in the - // previous hex-string comparison which also had an early return on length. - return timingSafeEqual(expectedBytes, derivedBytes); -} - -async function deriveKey( - password: string, - salt: Uint8Array, - iterations: number, -): Promise { - const passwordKey = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(password), - { name: ALGORITHM }, - false, - ["deriveBits"], - ); - - const bits = await crypto.subtle.deriveBits( - { name: ALGORITHM, salt: salt as BufferSource, iterations, hash: HASH_ALGO }, - passwordKey, - KEY_LENGTH * 8, - ); - - return new Uint8Array(bits); -} - -function hexToBytes(hex: string): Uint8Array { - const bytes = new Uint8Array(hex.length / 2); - for (let i = 0; i < bytes.length; i++) { - bytes[i] = parseInt(hex.substr(i * 2, 2), 16); - } - return bytes; -} - diff --git a/src/admin/auth/users.ts b/src/admin/auth/users.ts index 4d98f5b..e1f4e73 100644 --- a/src/admin/auth/users.ts +++ b/src/admin/auth/users.ts @@ -8,14 +8,18 @@ * (Phase 0) admin accounts never had before this. Adds admin-specific * conveniences the generic UserStore has no reason to know about: a single * `role: Role` create/update parameter (translated to/from `roles: string[]` - * via role-utils.ts), password hashing, and `ensureDefaultAdmin()`. + * via role-utils.ts) and password hashing (via @dune/core/auth/passwords). + * + * There is deliberately no default admin: the first admin is created + * explicitly with `dune users:create --role admin --password-prompt` or from + * DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (both in @dune/core). */ import type { StorageAdapter } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; import type { UserStore } from "@dune/core/auth/user-store"; import type { User, Role } from "../types.ts"; -import { hashPassword } from "./passwords.ts"; +import { hashPassword } from "@dune/core/auth/passwords"; import { withRole } from "./role-utils.ts"; /** Options for {@link createUserManager}. */ @@ -53,13 +57,6 @@ export interface UserManager { changePassword(id: string, newPassword: string): Promise; /** Delete a user */ delete(id: string): Promise; - /** - * Ensure a default admin user exists (for first-time setup). - * If created, the password is written to a file on disk (not returned in - * the response) to avoid it appearing in logs or process output. - * `passwordFile` is the path to that file when `created` is true. - */ - ensureDefaultAdmin(): Promise<{ created: boolean; passwordFile?: string }>; } /** @@ -102,41 +99,6 @@ export function createUserManager(config: UserManagerConfig): UserManager { return updated !== null; } - async function ensureDefaultAdmin(): Promise<{ created: boolean; passwordFile?: string }> { - const allUsers = await store.list(); - const admins = allUsers.filter((u) => u.enabled && u.roles.includes("admin")); - - if (admins.length > 0) { - if (admins.length > 1) { - console.warn(`[dune/users] ⚠️ Found ${admins.length} enabled admin users (${admins.map((u) => u.username).join(", ")})`); - } - return { created: false }; - } - - if (allUsers.length > 0) { - console.warn(`[dune/users] ⚠️ Found ${allUsers.length} user(s) but none are enabled admins — creating default admin`); - } - - // Generate a random password and write it to a file — never log it. - const password = generatePassword(); - - await create({ - username: "admin", - email: "admin@localhost", - password, - role: "admin", - name: "Admin", - }); - - // Write the password to a file next to the users directory. - // The caller can print the file path; the password never appears in logs. - const passwordFile = `${usersDir}/../initial-password.txt`; - const content = `Dune CMS — initial admin password\n\nUsername: admin\nPassword: ${password}\n\nDelete this file after logging in and changing your password.\n`; - await storage.write(passwordFile, new TextEncoder().encode(content)); - - return { created: true, passwordFile }; - } - return { create, getById: store.getById, @@ -145,26 +107,5 @@ export function createUserManager(config: UserManagerConfig): UserManager { update, changePassword, delete: store.delete, - ensureDefaultAdmin, }; } - -/** - * Generate a random password from an unambiguous alphabet. Uses rejection - * sampling so every character is equally likely — `byte % chars.length` would - * favor the first (256 % chars.length) characters. - */ -export function generatePassword(length = 16): string { - const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; - const limit = 256 - (256 % chars.length); - let out = ""; - while (out.length < length) { - const bytes = crypto.getRandomValues(new Uint8Array(length * 2)); - for (const b of bytes) { - if (b >= limit) continue; - out += chars[b % chars.length]; - if (out.length === length) break; - } - } - return out; -} diff --git a/src/admin/mod.ts b/src/admin/mod.ts index d09f41a..4ababa4 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -14,7 +14,7 @@ export type { UserManager, UserManagerConfig, CreateUserInput } from "./auth/use export { createAuthMiddleware } from "./auth/middleware.ts"; export type { AuthMiddleware, AuthMiddlewareConfig } from "./auth/middleware.ts"; -export { hashPassword, verifyPassword, DUMMY_HASH } from "./auth/passwords.ts"; +export { hashPassword, verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; export type { AuthProvider, diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index 498c161..9b7b09f 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -7,7 +7,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../types.ts"; -import { verifyPassword, DUMMY_HASH, needsRehash } from "../auth/passwords.ts"; +import { verifyPassword, DUMMY_HASH, needsRehash } from "@dune/core/auth/passwords"; import { findOrProvisionUser } from "../auth/provisioner.ts"; import { RateLimiter, clientIp } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; diff --git a/src/admin/timing-safe.ts b/src/admin/timing-safe.ts index 71b286d..508e1eb 100644 --- a/src/admin/timing-safe.ts +++ b/src/admin/timing-safe.ts @@ -1,7 +1,7 @@ /** * Constant-time byte-array comparison. * - * Single implementation shared by password verification (auth/passwords.ts), + * Single implementation shared by password verification (@dune/core/auth/passwords), * CSRF token matching (auth/csrf.ts), and incoming-webhook token checks * (public-api.ts) — three copies of security-critical code were a drift risk * (Q-2, Aug 2026 quality audit). diff --git a/tests/admin/auth_test.ts b/tests/admin/auth_test.ts index a58820f..9be4e71 100644 --- a/tests/admin/auth_test.ts +++ b/tests/admin/auth_test.ts @@ -3,7 +3,7 @@ */ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { hashPassword, verifyPassword } from "../../src/admin/auth/passwords.ts"; +import { hashPassword, verifyPassword } from "@dune/core/auth/passwords"; import { createSessionManager } from "../../src/admin/auth/sessions.ts"; import { createUserManager } from "../../src/admin/auth/users.ts"; import { createAuthMiddleware } from "../../src/admin/auth/middleware.ts"; @@ -272,36 +272,13 @@ Deno.test("UserManager: delete removes user", async () => { assertEquals(await mgr.getById(user.id), null); }); -Deno.test("UserManager: ensureDefaultAdmin creates admin on first run", async () => { +Deno.test("UserManager: has no default-admin bootstrap (no admin is created implicitly)", async () => { const storage = createMemoryStorage(); const mgr = createUserManager({ storage, usersDir: ".users" }); - const result = await mgr.ensureDefaultAdmin(); - assertEquals(result.created, true); - // ensureDefaultAdmin now returns a passwordFile path (not the raw password) - assertEquals(typeof result.passwordFile, "string"); - - // Admin user should exist - const admin = await mgr.getByUsername("admin"); - assertEquals(admin !== null, true); - assertEquals(admin!.roles, ["admin"]); -}); - -Deno.test("UserManager: ensureDefaultAdmin skips if admin exists", async () => { - const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); - - await mgr.create({ - username: "boss", - email: "", - password: "pass", - role: "admin", - name: "Boss", - }); - - const result = await mgr.ensureDefaultAdmin(); - assertEquals(result.created, false); - assertEquals(result.passwordFile, undefined); + assertEquals("ensureDefaultAdmin" in mgr, false); + assertEquals(await mgr.list(), []); + assertEquals(await storage.exists(".users/../initial-password.txt"), false); }); // === Auth middleware === diff --git a/tests/admin/codeql_regressions_test.ts b/tests/admin/codeql_regressions_test.ts index 59544cb..a5f7aca 100644 --- a/tests/admin/codeql_regressions_test.ts +++ b/tests/admin/codeql_regressions_test.ts @@ -1,11 +1,9 @@ /** Regression tests for the 2026-10 CodeQL triage fixes. */ import { - assert, assertEquals, assertThrows, } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { generatePassword } from "../../src/admin/auth/users.ts"; import { setDeepKey } from "../../src/admin/routes/api/dev/apply.ts"; import { ValidationError } from "../../src/admin/http.ts"; @@ -28,23 +26,3 @@ Deno.test("setDeepKey: still sets ordinary nested keys", () => { setDeepKey(obj, "system.site.title", "Hi"); assertEquals(obj, { system: { site: { title: "Hi" } } }); }); - -Deno.test("generatePassword: right length, alphabet only, no modulo bias", () => { - const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789"; - const counts = new Map(); - const n = 20000; - for (let i = 0; i < n; i++) { - const p = generatePassword(); - assertEquals(p.length, 16); - for (const c of p) { - assert(alphabet.includes(c)); - counts.set(c, (counts.get(c) ?? 0) + 1); - } - } - // With modulo bias the first 32 chars appear 1.25x as often as the rest - // (ratio of means ~1.25). Unbiased sampling keeps the ratio near 1. - const mean = (cs: string) => - [...cs].reduce((a, c) => a + (counts.get(c) ?? 0), 0) / cs.length; - const ratio = mean(alphabet.slice(0, 32)) / mean(alphabet.slice(32)); - assert(ratio > 0.97 && ratio < 1.03, `biased distribution, ratio=${ratio}`); -}); From 19b7d0fca7d2db86749df36d785390f40ad82758 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 3 Nov 2026 16:41:00 +0100 Subject: [PATCH 2/2] docs(changelog): name the minimum core version Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a9c3e..46ba037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,9 @@ follows [Semantic Versioning](https://semver.org). - **Password hashing comes from `@dune/core/auth/passwords`.** The plugin's own copy (`src/admin/auth/passwords.ts`) is removed; `hashPassword`, `verifyPassword` and `DUMMY_HASH` are still re-exported from the admin entry - point. The hash format is unchanged. **Requires a `@dune/core` that includes - `@dune/core/auth/passwords` and the env bootstrap (duneorg/dune#84, #86).** + point. The hash format is unchanged. **Requires `@dune/core` 0.35.3 or newer** + (it adds `@dune/core/auth/passwords` and the env bootstrap, duneorg/dune#84 + and #86). ## [3.2.0] — 2026-11-03