co-maintainer 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,9 +1,12 @@
1
1
  import type { UserConfig } from "../../config.ts";
2
- import type { AuthMethods } from "../../server/auth.ts";
2
+ import type { AuthMethods, PasswordStore } from "../../server/auth.ts";
3
3
  import { type Platform } from "../../util/runtime.ts";
4
4
  /** `undefined` on Linux, otherwise one line naming the platform (a pure
5
5
  * function so it is testable without actually being off Linux). */
6
6
  export declare function platformWarning(os: Platform): string | undefined;
7
+ /** `--password=` replaces the stored password. With no flag the stored one is
8
+ * kept, and a first start generates one, returned so the caller can print it. */
9
+ export declare function ensureDashboardPassword(args: string[], hasStoredPassword: boolean, store: PasswordStore): Promise<string | undefined>;
7
10
  /** `--disable-auth=password` / `--enable-auth=github` always win over the
8
11
  * config defaults `co-maintainer set` persisted; dies if the result would
9
12
  * leave no sign-in method active. Pure so it is testable without a server. */
@@ -1,12 +1,14 @@
1
1
  import { readConfig } from "../../config.js";
2
2
  import { appDbPath, closeAppDb, openAppDb } from "../../store/app_db.js";
3
3
  import { createApp } from "../../server/app.js";
4
+ import { configPasswordStore } from "../../server/auth.js";
4
5
  import { recoverOrphans, startWorkerLoop, stopWorkerLoop, } from "../../services/jobs.js";
5
6
  import { registerSetupJobHandler } from "../../services/setup.js";
6
7
  import { registerReviewJobHandler } from "../../services/review.js";
7
8
  import { recoverReplyRequests, registerReplyJobHandler, } from "../../services/replies.js";
8
9
  import { registerRemoteReviewHandler } from "../../services/remote_review.js";
9
10
  import { startRemoteWatchdog } from "../../remote/server/sessions.js";
11
+ import { passwordProblem } from "../../util/password.js";
10
12
  import { serveHttp } from "../../server/http.js";
11
13
  import { currentPlatform, getEnv } from "../../util/runtime.js";
12
14
  /** `undefined` on Linux, otherwise one line naming the platform (a pure
@@ -24,6 +26,25 @@ function generatePassword() {
24
26
  crypto.getRandomValues(bytes);
25
27
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
26
28
  }
29
+ /** `--password=` replaces the stored password. With no flag the stored one is
30
+ * kept, and a first start generates one, returned so the caller can print it. */
31
+ export async function ensureDashboardPassword(args, hasStoredPassword, store) {
32
+ const flag = args
33
+ .find((arg) => arg.startsWith("--password="))
34
+ ?.slice("--password=".length);
35
+ if (flag !== undefined) {
36
+ const problem = passwordProblem(flag);
37
+ if (problem)
38
+ die(`--password: ${problem}`);
39
+ await store.set(flag);
40
+ return undefined;
41
+ }
42
+ if (hasStoredPassword)
43
+ return undefined;
44
+ const generated = generatePassword();
45
+ await store.set(generated);
46
+ return generated;
47
+ }
27
48
  /** `--disable-auth=password` / `--enable-auth=github` always win over the
28
49
  * config defaults `co-maintainer set` persisted; dies if the result would
29
50
  * leave no sign-in method active. Pure so it is testable without a server. */
@@ -117,6 +138,13 @@ export async function runServe(args) {
117
138
  allowedUser: config.githubOAuthAllowedUser,
118
139
  };
119
140
  }
141
+ const passwordStore = configPasswordStore();
142
+ if (auth.password) {
143
+ const generated = await ensureDashboardPassword(args, Boolean(config.dashboardPasswordHash), passwordStore);
144
+ console.log(generated
145
+ ? `[serve] dashboard password: ${generated}`
146
+ : "[serve] dashboard password is stored in config.json, change it in Settings");
147
+ }
120
148
  const warning = platformWarning(currentPlatform());
121
149
  if (warning)
122
150
  console.log(`[serve] warning: ${warning}`);
@@ -136,13 +164,6 @@ export async function runServe(args) {
136
164
  console.log(`[serve] requeued ${recoveredReplies} unfinished conversation repl${recoveredReplies === 1 ? "y" : "ies"}`);
137
165
  }
138
166
  startWorkerLoop();
139
- const password = auth.password
140
- ? (args
141
- .find((arg) => arg.startsWith("--password="))
142
- ?.slice("--password=".length) ?? generatePassword())
143
- : "";
144
- if (auth.password)
145
- console.log(`[serve] dashboard password: ${password}`);
146
167
  if (auth.github)
147
168
  console.log(`[serve] GitHub sign-in enabled for ${githubOAuth.allowedUser}`);
148
169
  const inject500 = args.includes("--inject-500") || getEnv("CM_INJECT_500") === "1";
@@ -150,7 +171,8 @@ export async function runServe(args) {
150
171
  console.log("[serve] --inject-500 is on. Mutating /api requests return 500.");
151
172
  }
152
173
  const app = createApp({
153
- password,
174
+ password: "",
175
+ passwordStore,
154
176
  webhookUrl,
155
177
  inject500,
156
178
  auth,
@@ -1,4 +1,5 @@
1
1
  import { writeUserConfig } from "../../config.js";
2
+ import { hashPassword, passwordProblem } from "../../util/password.js";
2
3
  import { readTextFile } from "../../util/runtime.js";
3
4
  function die(message) {
4
5
  throw new Error(message);
@@ -15,6 +16,7 @@ const secretFields = new Set([
15
16
  "githubWebhookSecret",
16
17
  "githubOAuthClientSecret",
17
18
  "remoteToken",
19
+ "dashboardPasswordHash",
18
20
  ]);
19
21
  /** `co-maintainer set --token=... --ai=... --low-model=... --high-model=...
20
22
  * --auth=... --github-app-id=... --github-app-private-key=... (or
@@ -28,7 +30,7 @@ export async function runSet(args) {
28
30
  console.log(" --github-app-id=... --github-app-private-key=... | --github-app-private-key-file=path");
29
31
  console.log(" --github-webhook-secret=...");
30
32
  console.log(" --github-oauth-client-id=... --github-oauth-client-secret=... --github-oauth-allowed-user=...");
31
- console.log(" --disable-auth=password --enable-auth=github");
33
+ console.log(" --password=... --disable-auth=password --enable-auth=github");
32
34
  console.log("Writes to the user config file; unset an entry with --unset=name (e.g. --unset=token).");
33
35
  return;
34
36
  }
@@ -50,6 +52,7 @@ export async function runSet(args) {
50
52
  "enable-auth",
51
53
  "remote-host",
52
54
  "remote-token",
55
+ "password",
53
56
  "unset",
54
57
  ];
55
58
  for (const arg of args) {
@@ -87,6 +90,7 @@ export async function runSet(args) {
87
90
  "enable-auth": "githubAuthEnabled",
88
91
  "remote-host": "remoteHost",
89
92
  "remote-token": "remoteToken",
93
+ password: "dashboardPasswordHash",
90
94
  };
91
95
  const unset = new Set(args
92
96
  .filter((arg) => arg.startsWith("--unset="))
@@ -157,11 +161,18 @@ export async function runSet(args) {
157
161
  const remoteToken = text(args, "remote-token");
158
162
  if (remoteToken)
159
163
  patch.remoteToken = remoteToken;
164
+ const password = text(args, "password");
165
+ if (password) {
166
+ const problem = passwordProblem(password);
167
+ if (problem)
168
+ die(`--password: ${problem}`);
169
+ patch.dashboardPasswordHash = await hashPassword(password);
170
+ }
160
171
  if (Object.keys(patch).length === 0) {
161
172
  die("Nothing to set; pass --token=, --ai=, --low-model=, --high-model=, --auth=, --github-pat=, " +
162
173
  "--github-app-id=, --github-app-private-key(-file)=, --github-webhook-secret=, " +
163
174
  "--github-oauth-client-id=, --github-oauth-client-secret=, --github-oauth-allowed-user=, " +
164
- "--disable-auth=password, --enable-auth=github, or --unset=name");
175
+ "--password=, --disable-auth=password, --enable-auth=github, or --unset=name");
165
176
  }
166
177
  await writeUserConfig(patch);
167
178
  const summary = Object.entries(patch).map(([key, value]) => value === undefined
@@ -45,6 +45,8 @@ export type UserConfig = {
45
45
  * `--enable-auth` flag on the `serve` command overrides these per run. */
46
46
  passwordAuthDisabled?: boolean;
47
47
  githubAuthEnabled?: boolean;
48
+ /** scrypt hash of the dashboard password, never the password itself. */
49
+ dashboardPasswordHash?: string;
48
50
  defaults?: {
49
51
  maxCommits?: number;
50
52
  maxPrMonths?: number;
@@ -1 +1,2 @@
1
- export declare function handleSettingsRoute(request: Request, url: URL, webhookUrl: string): Promise<Response>;
1
+ import type { PasswordStore } from "../auth.ts";
2
+ export declare function handleSettingsRoute(request: Request, url: URL, webhookUrl: string, passwords: PasswordStore, ip: string): Promise<Response>;
@@ -1,6 +1,9 @@
1
1
  import { errorResponse } from "../errors.js";
2
2
  import { readConfig, writeUserConfig } from "../../config.js";
3
3
  import { testAppAccess, testGithubAccess } from "../../services/credentials.js";
4
+ import { checkPassword, readSessionToken, revokeOtherSessions, } from "../auth.js";
5
+ import { passwordProblem } from "../../util/password.js";
6
+ import { readJsonObject } from "./json_body.js";
4
7
  function validateWebhookUrl(value) {
5
8
  if (typeof value !== "string" || !value.trim()) {
6
9
  return "webhook URL is required";
@@ -16,7 +19,28 @@ function validateWebhookUrl(value) {
16
19
  }
17
20
  return undefined;
18
21
  }
19
- export async function handleSettingsRoute(request, url, webhookUrl) {
22
+ async function changePassword(request, passwords, ip) {
23
+ const body = await readJsonObject(request);
24
+ if (body instanceof Response)
25
+ return body;
26
+ const next = typeof body.newPassword === "string" ? body.newPassword : "";
27
+ const problem = passwordProblem(next);
28
+ if (problem)
29
+ return errorResponse(422, "weak_password", problem);
30
+ const current = String(body.currentPassword ?? "");
31
+ if (!(await checkPassword(current, passwords, ip))) {
32
+ return errorResponse(403, "wrong_password", "the current password is wrong");
33
+ }
34
+ await passwords.set(next);
35
+ const token = readSessionToken(request);
36
+ if (token)
37
+ await revokeOtherSessions(token);
38
+ return Response.json({ ok: true });
39
+ }
40
+ export async function handleSettingsRoute(request, url, webhookUrl, passwords, ip) {
41
+ if (url.pathname === "/api/settings/password" && request.method === "POST") {
42
+ return await changePassword(request, passwords, ip);
43
+ }
20
44
  if (url.pathname === "/api/settings" && request.method === "GET") {
21
45
  const config = readConfig();
22
46
  return Response.json({
@@ -1,8 +1,9 @@
1
- import type { AuthMethods } from "./auth.ts";
2
- /** `password` is the dashboard password `serve` generates or takes via
3
- * `--password`. */
1
+ import type { AuthMethods, PasswordStore } from "./auth.ts";
2
+ /** `password` seeds an in-memory store. `serve` passes `passwordStore` so the
3
+ * password lives in config.json instead. */
4
4
  export type AppDeps = {
5
5
  password: string;
6
+ passwordStore?: PasswordStore;
6
7
  webhookUrl?: string;
7
8
  secureCookie?: boolean;
8
9
  inject500?: boolean;
@@ -3,6 +3,7 @@
3
3
  import { VERSION } from "../version.js";
4
4
  import { errorResponse } from "./errors.js";
5
5
  import { hasCsrfHeader, login, logout, readSessionToken, sessionCookieHeader, verifySession, } from "./auth.js";
6
+ import { memoryPasswordStore } from "./auth.js";
6
7
  import { readConfig } from "../config.js";
7
8
  import { listJobs } from "../store/jobs.js";
8
9
  import { handleJobsRoute } from "./api/jobs.js";
@@ -33,7 +34,7 @@ async function requireSession(request) {
33
34
  return undefined;
34
35
  return await verifySession(token);
35
36
  }
36
- async function handleLogin(request, deps, ip) {
37
+ async function handleLogin(request, deps, passwords, ip) {
37
38
  if (deps.auth?.password === false) {
38
39
  return errorResponse(403, "password_disabled", "password sign-in is disabled");
39
40
  }
@@ -44,7 +45,7 @@ async function handleLogin(request, deps, ip) {
44
45
  catch {
45
46
  return errorResponse(400, "bad_request", "expected a JSON body");
46
47
  }
47
- const result = await login(String(body.password ?? ""), deps.password, ip);
48
+ const result = await login(String(body.password ?? ""), passwords, ip);
48
49
  if (!result)
49
50
  return errorResponse(401, "unauthorized", "wrong password");
50
51
  return Response.json(result, {
@@ -93,6 +94,7 @@ function liveGithubConfig() {
93
94
  };
94
95
  }
95
96
  export function createApp(deps) {
97
+ const passwords = deps.passwordStore ?? memoryPasswordStore(deps.password);
96
98
  return {
97
99
  async fetch(request, remoteAddr = "unknown") {
98
100
  const url = new URL(request.url);
@@ -104,7 +106,7 @@ export function createApp(deps) {
104
106
  if (url.pathname === "/github/webhook" && request.method === "POST") {
105
107
  return await handleWebhookRequest(request, webhookSecret);
106
108
  }
107
- const page = await handlePageRequest(request, { ...deps, githubApp }, remoteAddr);
109
+ const page = await handlePageRequest(request, { ...deps, githubApp, passwordStore: passwords }, remoteAddr);
108
110
  if (page)
109
111
  return page;
110
112
  if (url.pathname.startsWith("/api/remote/")) {
@@ -115,7 +117,7 @@ export function createApp(deps) {
115
117
  return errorResponse(403, "csrf", `every mutating request needs the X-Requested-With header`);
116
118
  }
117
119
  if (url.pathname === "/api/login" && request.method === "POST") {
118
- return await handleLogin(request, deps, remoteAddr);
120
+ return await handleLogin(request, deps, passwords, remoteAddr);
119
121
  }
120
122
  const session = await requireSession(request);
121
123
  if (!session) {
@@ -143,7 +145,7 @@ export function createApp(deps) {
143
145
  return await handleInstallationsRoute(request, githubApp);
144
146
  }
145
147
  if (url.pathname.startsWith("/api/settings")) {
146
- return await handleSettingsRoute(request, url, deps.webhookUrl ?? "");
148
+ return await handleSettingsRoute(request, url, deps.webhookUrl ?? "", passwords, remoteAddr);
147
149
  }
148
150
  if (url.pathname.startsWith("/api/activity")) {
149
151
  return handleActivityRoute(request, url);
@@ -19,9 +19,21 @@ export type LoginResult = {
19
19
  username: string;
20
20
  expiresAt: string;
21
21
  };
22
+ export type PasswordStore = {
23
+ verify(password: string): Promise<boolean>;
24
+ set(password: string): Promise<void>;
25
+ };
26
+ /** Holds the password in memory only: what `createApp` uses when `serve` did
27
+ * not hand it a config-backed store. */
28
+ export declare function memoryPasswordStore(initial: string): PasswordStore;
29
+ /** Reads the hash on every check, so a change made from the dashboard or
30
+ * `co-maintainer set` applies without restarting `serve`. */
31
+ export declare function configPasswordStore(): PasswordStore;
22
32
  /** A locked-out attempt is indistinguishable from a wrong password to the
23
- * caller — both return `undefined` — so a probe gains nothing either way. */
24
- export declare function login(password: string, expected: string, ip: string): Promise<LoginResult | undefined>;
33
+ * caller, so a probe gains nothing either way. */
34
+ export declare function checkPassword(password: string, store: PasswordStore, ip: string): Promise<boolean>;
35
+ export declare function login(password: string, store: PasswordStore, ip: string): Promise<LoginResult | undefined>;
36
+ export declare function revokeOtherSessions(keepToken: string): Promise<void>;
25
37
  /** Issues a session for the one dashboard identity, bypassing the password
26
38
  * check — used after GitHub OAuth has already verified the caller. */
27
39
  export declare function createSession(): Promise<LoginResult>;
@@ -1,6 +1,8 @@
1
1
  /** Only a token's hash ever reaches `store/sessions.ts`; the raw token is
2
2
  * handed back once, at login, and never stored. */
3
- import { deleteSession, getSession, insertSession } from "../store/sessions.js";
3
+ import { readConfig, writeUserConfig } from "../config.js";
4
+ import { hashPassword, verifyPasswordHash } from "../util/password.js";
5
+ import { deleteOtherSessions, deleteSession, getSession, insertSession, } from "../store/sessions.js";
4
6
  const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
5
7
  const LOCKOUT_THRESHOLD = 5;
6
8
  const LOCKOUT_WINDOW_MS = 10 * 60 * 1000;
@@ -69,16 +71,50 @@ function recordSuccess(ip) {
69
71
  export function hasCsrfHeader(request) {
70
72
  return request.headers.get(CSRF_HEADER) === CSRF_VALUE;
71
73
  }
74
+ /** Holds the password in memory only: what `createApp` uses when `serve` did
75
+ * not hand it a config-backed store. */
76
+ export function memoryPasswordStore(initial) {
77
+ let current = initial;
78
+ return {
79
+ verify: async (password) => current !== "" && (await constantTimeEqual(password, current)),
80
+ set: async (password) => {
81
+ current = password;
82
+ },
83
+ };
84
+ }
85
+ /** Reads the hash on every check, so a change made from the dashboard or
86
+ * `co-maintainer set` applies without restarting `serve`. */
87
+ export function configPasswordStore() {
88
+ return {
89
+ verify: async (password) => {
90
+ const stored = readConfig().dashboardPasswordHash;
91
+ return stored ? await verifyPasswordHash(password, stored) : false;
92
+ },
93
+ set: async (password) => {
94
+ await writeUserConfig({
95
+ dashboardPasswordHash: await hashPassword(password),
96
+ });
97
+ },
98
+ };
99
+ }
72
100
  /** A locked-out attempt is indistinguishable from a wrong password to the
73
- * caller — both return `undefined` — so a probe gains nothing either way. */
74
- export async function login(password, expected, ip) {
75
- if (isLockedOut(ip) || !(await constantTimeEqual(password, expected))) {
101
+ * caller, so a probe gains nothing either way. */
102
+ export async function checkPassword(password, store, ip) {
103
+ if (isLockedOut(ip) || !(await store.verify(password))) {
76
104
  recordFailure(ip);
77
- return undefined;
105
+ return false;
78
106
  }
79
107
  recordSuccess(ip);
108
+ return true;
109
+ }
110
+ export async function login(password, store, ip) {
111
+ if (!(await checkPassword(password, store, ip)))
112
+ return undefined;
80
113
  return await createSession();
81
114
  }
115
+ export async function revokeOtherSessions(keepToken) {
116
+ deleteOtherSessions(await sha256Hex(keepToken));
117
+ }
82
118
  /** Issues a session for the one dashboard identity, bypassing the password
83
119
  * check — used after GitHub OAuth has already verified the caller. */
84
120
  export async function createSession() {
@@ -1,6 +1,6 @@
1
- import type { AuthMethods } from "../auth.ts";
1
+ import type { AuthMethods, PasswordStore } from "../auth.ts";
2
2
  export type PageDeps = {
3
- password: string;
3
+ passwordStore: PasswordStore;
4
4
  webhookUrl?: string;
5
5
  secureCookie?: boolean;
6
6
  githubApp?: {
@@ -266,7 +266,7 @@ async function handleLoginForm(request, deps, ip, auth) {
266
266
  showGithub: auth.github,
267
267
  });
268
268
  }
269
- const result = await login(password, deps.password, ip);
269
+ const result = await login(password, deps.passwordStore, ip);
270
270
  if (!result) {
271
271
  return renderLogin({
272
272
  next,
@@ -144,7 +144,7 @@ Leave blank to keep the current key"></textarea></div>
144
144
  <div class="bd">
145
145
  <p class="muted" style="margin:0 0 16px">A <code>--disable-auth</code>/<code>--enable-auth</code> flag on <code>serve</code> overrides these for that run.</p>
146
146
  <div class="field wide"><label><input type="checkbox" id="password-auth"${config.passwordAuthDisabled ? "" : " checked"}> Password sign-in</label>
147
- <div class="hint">Set with <code>--password</code> when you start serve, or printed to the console if you omit the flag.</div></div>
147
+ <div class="hint">The first start prints a generated password to the console. Change it in the Password card below.</div></div>
148
148
  <div class="field wide"><label><input type="checkbox" id="github-auth"${config.githubAuthEnabled ? " checked" : ""}> GitHub sign-in</label></div>
149
149
  <div class="two" style="max-width:none">
150
150
  <div class="field"><label>OAuth client ID</label>
@@ -160,6 +160,23 @@ Leave blank to keep the current key"></textarea></div>
160
160
  </div>
161
161
  <div class="ft"><button class="primary" id="save-access">Save</button></div>
162
162
  </div>
163
+ <div class="card" id="password" data-async>
164
+ ${skSlot()}
165
+ <div class="hd"><h2>Password</h2></div>
166
+ <div class="bd">
167
+ <p class="muted" style="margin:0 0 16px">Changing it signs out every other browser session.</p>
168
+ <div class="field wide"><label>Current password</label>
169
+ <input id="pw-current" type="password" autocomplete="current-password"></div>
170
+ <div class="two" style="max-width:none">
171
+ <div class="field"><label>New password</label>
172
+ <input id="pw-new" type="password" autocomplete="new-password">
173
+ <div class="hint">8 to 200 characters.</div></div>
174
+ <div class="field"><label>Repeat new password</label>
175
+ <input id="pw-repeat" type="password" autocomplete="new-password"></div>
176
+ </div>
177
+ </div>
178
+ <div class="ft"><button class="primary" id="save-password">Change password</button></div>
179
+ </div>
163
180
  <div class="card" id="about">
164
181
  <div class="hd"><h2>About</h2></div>
165
182
  <div class="bd">
@@ -215,6 +232,25 @@ document.getElementById("save-access").addEventListener("click", function() {
215
232
  githubOAuthAllowedUser: document.getElementById("oauth-allowed-user").value
216
233
  });
217
234
  });
235
+ document.getElementById("save-password").addEventListener("click", function() {
236
+ var btn = this;
237
+ var card = btn.closest("[data-async]");
238
+ var next = document.getElementById("pw-new").value;
239
+ if (next !== document.getElementById("pw-repeat").value) {
240
+ fail(card, "The two new passwords do not match.", function () {});
241
+ return;
242
+ }
243
+ run(btn, card, async function() {
244
+ await api("POST", "/api/settings/password", {
245
+ currentPassword: document.getElementById("pw-current").value,
246
+ newPassword: next
247
+ });
248
+ ["pw-current", "pw-new", "pw-repeat"].forEach(function(id) {
249
+ document.getElementById(id).value = "";
250
+ });
251
+ toast("Password changed");
252
+ });
253
+ });
218
254
  function positiveOrNull(raw, label) {
219
255
  if (raw === "") return { ok: true, value: null };
220
256
  var n = Number(raw);
@@ -6,3 +6,4 @@ export declare function insertSession(tokenHash: string, username: string, expir
6
6
  export declare function getSession(tokenHash: string): SessionRow | undefined;
7
7
  export declare function deleteSession(tokenHash: string): void;
8
8
  export declare function deleteExpiredSessions(): void;
9
+ export declare function deleteOtherSessions(keepTokenHash: string): void;
@@ -32,3 +32,8 @@ export function deleteExpiredSessions() {
32
32
  .prepare(`DELETE FROM sessions WHERE expires_at <= ?`)
33
33
  .run(nowIso());
34
34
  }
35
+ export function deleteOtherSessions(keepTokenHash) {
36
+ getAppDb()
37
+ .prepare(`DELETE FROM sessions WHERE token_hash != ?`)
38
+ .run(keepTokenHash);
39
+ }
@@ -0,0 +1,3 @@
1
+ export declare function passwordProblem(password: string): string | undefined;
2
+ export declare function hashPassword(password: string): Promise<string>;
3
+ export declare function verifyPasswordHash(password: string, stored: string): Promise<boolean>;
@@ -0,0 +1,29 @@
1
+ import { randomBytes, scrypt, timingSafeEqual } from "node:crypto";
2
+ const KEY_LENGTH = 32;
3
+ const MIN_LENGTH = 8;
4
+ const MAX_LENGTH = 200;
5
+ function derive(password, salt, length) {
6
+ return new Promise((resolve, reject) => {
7
+ scrypt(password, salt, length, (error, key) => error ? reject(error) : resolve(key));
8
+ });
9
+ }
10
+ export function passwordProblem(password) {
11
+ if (password.length < MIN_LENGTH || password.length > MAX_LENGTH) {
12
+ return `the password must be ${MIN_LENGTH} to ${MAX_LENGTH} characters`;
13
+ }
14
+ }
15
+ export async function hashPassword(password) {
16
+ const salt = randomBytes(16);
17
+ const key = await derive(password, salt, KEY_LENGTH);
18
+ return `scrypt$${salt.toString("hex")}$${key.toString("hex")}`;
19
+ }
20
+ export async function verifyPasswordHash(password, stored) {
21
+ const [scheme, saltHex, keyHex] = stored.split("$");
22
+ if (scheme !== "scrypt" || !saltHex || !keyHex)
23
+ return false;
24
+ const expected = Buffer.from(keyHex, "hex");
25
+ if (expected.length === 0)
26
+ return false;
27
+ const key = await derive(password, Buffer.from(saltHex, "hex"), expected.length);
28
+ return timingSafeEqual(key, expected);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "co-maintainer",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Analyzes a GitHub repository and writes repository-specific contribution guidance.",
5
5
  "license": "MIT",
6
6
  "repository": {