@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.0",
3
+ "version": "0.9.0-preview.2",
4
4
  "description": "CommHub Server \u2014 AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -48,6 +48,7 @@
48
48
  "dependencies": {
49
49
  "@modelcontextprotocol/sdk": "^1.12.0",
50
50
  "bun-types": "^1.3.13",
51
- "hono": "^4.12.25"
51
+ "hono": "^4.12.25",
52
+ "zod": "^4.4.3"
52
53
  }
53
54
  }
@@ -0,0 +1,378 @@
1
+ // Round-6 A1 — salted KDF password hash + lazy migration.
2
+ //
3
+ // Pins the 5 behaviours called out in the dispatch:
4
+ // 1. old-format hash verifies + auto-rehashes to new on login
5
+ // 2. new-format hash round-trips
6
+ // 3. wrong password rejected (both old and new format paths)
7
+ // 4. after a successful login against a legacy hash, the legacy
8
+ // hash NO LONGER EXISTS in the DB (single source of truth)
9
+ // 5. bootstrap admin (register flow) writes new format from row 1
10
+ //
11
+ // Plus regression pins:
12
+ // - timing-safe verify path doesn't throw on adversarial input
13
+ // - malformed new-format string is rejected (not silently accepted)
14
+ //
15
+ // All COMMHUB_SCRYPT_N=10 to keep the suite fast (~5ms/scrypt vs
16
+ // 50ms at production N=14).
17
+
18
+ import { describe, expect, test, beforeAll, beforeEach } from "bun:test";
19
+ import { db, hashPassword, verifyPassword } from "./db.js";
20
+ import { register, login } from "./auth.js";
21
+
22
+ // Fast scrypt for tests — 2^10 = 1024 iter is ~5ms. Production stays at N=14.
23
+ beforeAll(() => {
24
+ process.env.COMMHUB_SCRYPT_N = "10";
25
+ });
26
+
27
+ beforeEach(() => {
28
+ db.run("DELETE FROM users");
29
+ db.run("DELETE FROM api_tokens");
30
+ db.run("DELETE FROM network_members");
31
+ db.run("DELETE FROM networks");
32
+ db.run("DELETE FROM audit_log");
33
+ });
34
+
35
+ // Mimic the pre-A1 unsalted SHA-256 path. Used to seed legacy rows.
36
+ function legacyHash(plain: string): string {
37
+ return new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
38
+ }
39
+
40
+ function getStoredHash(username: string): string | null {
41
+ const row = db.get<{ password_hash: string }>(
42
+ "SELECT password_hash FROM users WHERE username = ?1",
43
+ username
44
+ );
45
+ return row?.password_hash ?? null;
46
+ }
47
+
48
+ function insertLegacyUser(username: string, plain: string): string {
49
+ const userId = `usr_${username}`;
50
+ db.run(
51
+ `INSERT INTO users (user_id, username, password_hash, role)
52
+ VALUES (?1, ?2, ?3, 'user')`,
53
+ [userId, username, legacyHash(plain)]
54
+ );
55
+ return userId;
56
+ }
57
+
58
+ describe("hashPassword — new format shape", () => {
59
+ test("produces scrypt$N$salt$hash with non-zero salt and hash", () => {
60
+ const stored = hashPassword("PasswordWith8+");
61
+ const parts = stored.split("$");
62
+ expect(parts.length).toBe(4);
63
+ expect(parts[0]).toBe("scrypt");
64
+ const N = parseInt(parts[1], 10);
65
+ expect(N).toBeGreaterThanOrEqual(8);
66
+ expect(N).toBeLessThanOrEqual(20);
67
+ expect(Buffer.from(parts[2], "base64").length).toBeGreaterThan(0);
68
+ expect(Buffer.from(parts[3], "base64").length).toBe(64); // SCRYPT_KEYLEN
69
+ });
70
+
71
+ test("salt + hash differ on every call (no static derivation)", () => {
72
+ const a = hashPassword("same");
73
+ const b = hashPassword("same");
74
+ const c = hashPassword("same");
75
+ expect(a).not.toBe(b);
76
+ expect(b).not.toBe(c);
77
+ expect(a).not.toBe(c);
78
+ });
79
+ });
80
+
81
+ describe("verifyPassword — new format round-trip", () => {
82
+ test("hashPassword + verifyPassword round-trips, needsRehash=false", () => {
83
+ const stored = hashPassword("MyPassw0rd!");
84
+ const v = verifyPassword("MyPassw0rd!", stored);
85
+ expect(v.ok).toBe(true);
86
+ expect(v.needsRehash).toBe(false);
87
+ });
88
+
89
+ test("wrong password rejected, needsRehash=false", () => {
90
+ const stored = hashPassword("MyPassw0rd!");
91
+ const v = verifyPassword("wrong", stored);
92
+ expect(v.ok).toBe(false);
93
+ expect(v.needsRehash).toBe(false);
94
+ });
95
+
96
+ test("malformed new-format strings rejected (not silently passed)", () => {
97
+ // Each of these tries to abuse the parser. None should yield ok=true.
98
+ const bad = [
99
+ "scrypt$$salt$hash", // missing N
100
+ "scrypt$14$$hash", // empty salt
101
+ "scrypt$14$c2FsdA==$", // empty hash
102
+ "scrypt$abc$c2FsdA==$aGFzaA==", // non-numeric N
103
+ "scrypt$14$c2FsdA==$aGFzaA==$extra", // 5 parts
104
+ "scrypt$14$c2FsdA==", // 3 parts
105
+ "argon2$1$c2FsdA==$aGFzaA==", // unknown scheme
106
+ "scrypt$5$c2FsdA==$aGFzaA==", // N below floor (8)
107
+ "scrypt$30$c2FsdA==$aGFzaA==", // N above ceiling (20)
108
+ ];
109
+ for (const s of bad) {
110
+ const v = verifyPassword("anything", s);
111
+ expect(v.ok).toBe(false);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("verifyPassword — legacy format compatibility (A1's load-bearing claim)", () => {
117
+ test("legacy unsalted SHA-256 hash verifies", () => {
118
+ const plain = "OldUserPassw0rd";
119
+ const stored = legacyHash(plain);
120
+ const v = verifyPassword(plain, stored);
121
+ expect(v.ok).toBe(true);
122
+ expect(v.needsRehash).toBe(true); // ← KEY: caller MUST rehash
123
+ });
124
+
125
+ test("legacy wrong-password rejected, needsRehash=false", () => {
126
+ const stored = legacyHash("OldUserPassw0rd");
127
+ const v = verifyPassword("wrong", stored);
128
+ expect(v.ok).toBe(false);
129
+ expect(v.needsRehash).toBe(false);
130
+ });
131
+
132
+ test("legacy hash with wrong length rejected (not crash)", () => {
133
+ const v = verifyPassword("anything", "deadbeef"); // 8 hex chars, not 64
134
+ expect(v.ok).toBe(false);
135
+ expect(v.needsRehash).toBe(false);
136
+ });
137
+ });
138
+
139
+ describe("login() — lazy migration on success", () => {
140
+ test("legacy user logs in successfully AND is auto-upgraded to new format", () => {
141
+ insertLegacyUser("legacy-alice", "AliceP@ss123");
142
+
143
+ // Sanity: the seeded hash IS bare-hex sha256 (no $).
144
+ const before = getStoredHash("legacy-alice")!;
145
+ expect(before).not.toContain("$");
146
+ expect(before).toHaveLength(64);
147
+
148
+ const result = login("legacy-alice", "AliceP@ss123");
149
+ expect(result.ok).toBe(true);
150
+
151
+ // The load-bearing assertion: after login, the legacy hash is GONE.
152
+ const after = getStoredHash("legacy-alice")!;
153
+ expect(after).toContain("$");
154
+ expect(after.startsWith("scrypt$")).toBe(true);
155
+ expect(after).not.toBe(before);
156
+ });
157
+
158
+ test("legacy user wrong-password fails AND legacy hash is preserved", () => {
159
+ insertLegacyUser("legacy-bob", "BobP@ss123");
160
+ const before = getStoredHash("legacy-bob")!;
161
+
162
+ const result = login("legacy-bob", "wrong");
163
+ expect(result.ok).toBe(false);
164
+
165
+ // Failed login MUST NOT rehash (would leak info about format).
166
+ const after = getStoredHash("legacy-bob")!;
167
+ expect(after).toBe(before);
168
+ expect(after).not.toContain("$");
169
+ });
170
+
171
+ test("new-format user logs in without rehash (already current)", () => {
172
+ // First login auto-migrates; second login should not rewrite again.
173
+ insertLegacyUser("legacy-charlie", "CharlieP@ss");
174
+ const r1 = login("legacy-charlie", "CharlieP@ss");
175
+ expect(r1.ok).toBe(true);
176
+ const afterFirst = getStoredHash("legacy-charlie")!;
177
+
178
+ const r2 = login("legacy-charlie", "CharlieP@ss");
179
+ expect(r2.ok).toBe(true);
180
+ const afterSecond = getStoredHash("legacy-charlie")!;
181
+
182
+ // Same hash on consecutive logins — no spurious rewrites.
183
+ expect(afterSecond).toBe(afterFirst);
184
+ });
185
+ });
186
+
187
+ describe("register() — new users always start in new format (bootstrap admin)", () => {
188
+ test("freshly-registered user has scrypt$ format from row 1", () => {
189
+ const r = register("dave", "DaveBootstr@p", "dave@example.com", "Dave");
190
+ expect(r.ok).toBe(true);
191
+
192
+ const stored = getStoredHash("dave")!;
193
+ expect(stored.startsWith("scrypt$")).toBe(true);
194
+ });
195
+
196
+ test("registered user can log in (smoke for the whole pipeline)", () => {
197
+ register("eve", "EveP@ssword99", undefined, "Eve");
198
+ const login_r = login("eve", "EveP@ssword99");
199
+ expect(login_r.ok).toBe(true);
200
+ });
201
+
202
+ test("registered user wrong-password fails (smoke for new-format reject)", () => {
203
+ register("frank", "FrankP@ssword99", undefined, "Frank");
204
+ const login_r = login("frank", "wrong");
205
+ expect(login_r.ok).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("login — username enumeration timing-oracle close (round-6 A1 hardening)", () => {
210
+ // Independent pre-review on #285: post-A1 the user-exists +
211
+ // wrong-password path runs ~50ms scrypt, the user-not-found path
212
+ // returns sub-ms — that's a web-measurable oracle for "is this
213
+ // username registered". Fix: run a throwaway verifyPassword
214
+ // against a module-constant dummy hash on the not-found branch
215
+ // so wall-clock cost is comparable on both paths.
216
+ //
217
+ // The test asserts ORDER-OF-MAGNITUDE timing parity, not exact
218
+ // equality — scrypt timings have natural jitter and we don't
219
+ // want a flaky test that hammers CI on busy hosts. The check
220
+ // is "both paths are slow" (both > some floor), proving the
221
+ // dummy-verify codepath actually runs on the not-found branch.
222
+ test("not-found user takes scrypt-class time (not sub-ms early-return)", () => {
223
+ register("real-user-z", "RealUserZ123");
224
+
225
+ // Warm the JIT + paging on both paths so the first call's
226
+ // amortized cost doesn't skew the comparison.
227
+ login("real-user-z", "wrong");
228
+ login("does-not-exist-warmup", "wrong");
229
+
230
+ // Sample multiple times and take the median so single-call
231
+ // jitter doesn't dominate.
232
+ const sample = (fn: () => void): number => {
233
+ const N = 5;
234
+ const xs: number[] = [];
235
+ for (let i = 0; i < N; i++) {
236
+ const start = performance.now();
237
+ fn();
238
+ xs.push(performance.now() - start);
239
+ }
240
+ xs.sort((a, b) => a - b);
241
+ return xs[Math.floor(N / 2)];
242
+ };
243
+
244
+ const tExists = sample(() => { login("real-user-z", "wrong"); });
245
+ const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });
246
+
247
+ // The fix's load-bearing claim: BOTH paths run scrypt. At test
248
+ // N=10 (~5ms per scrypt), an early-return path would be < 1ms.
249
+ // Loose lower bound of 1ms catches an "early return" regression
250
+ // without being flaky on slow CI runners.
251
+ expect(tExists).toBeGreaterThan(1);
252
+ expect(tMissing).toBeGreaterThan(1);
253
+
254
+ // Ratio: both paths should be the same order of magnitude.
255
+ // 5x is a generous bound to avoid CI flakiness from
256
+ // co-scheduled scrypt workloads or paging. Pre-fix this would
257
+ // be 50x+ trivially.
258
+ const ratio = Math.max(tExists, tMissing) / Math.max(0.1, Math.min(tExists, tMissing));
259
+ expect(ratio).toBeLessThan(5);
260
+ });
261
+
262
+ test("not-found user still returns the generic error string (no info leak)", () => {
263
+ register("user-exists", "ExistsP@ss1");
264
+
265
+ const a = login("user-exists", "wrong");
266
+ const b = login("user-does-not-exist", "wrong");
267
+
268
+ expect(a.ok).toBe(false);
269
+ expect(b.ok).toBe(false);
270
+ // Both must return EXACTLY the same error string. If they
271
+ // diverge, the username-enumeration oracle reopens at the
272
+ // string-comparison layer regardless of timing.
273
+ expect(a.error).toBe(b.error);
274
+ expect(a.error).toBe("invalid username or password");
275
+ });
276
+ });
277
+
278
+ describe("login — timing parity across all 3 failure paths (round-6 round-2)", () => {
279
+ // Independent reviewer's round-2 catch on #285: the round-1 dummy-
280
+ // scrypt fix only equalized the !user (missing) path. legacy-user-
281
+ // wrong-password still ran SHA-256 only (sub-ms) while new-format-
282
+ // user-wrong-password ran scrypt (~50ms). That ~50x gap let an
283
+ // attacker distinguish "this username exists on a legacy hash" from
284
+ // "this username has been migrated" or "doesn't exist". Fix: burn
285
+ // an equal-time scrypt on the legacy-wrong-password path so all
286
+ // three failure paths cost SHA-256 + 1 scrypt.
287
+ //
288
+ // The test asserts ORDER-OF-MAGNITUDE timing parity (max/min ratio
289
+ // < 5x) — generous to avoid CI flake but pre-fix would be 50x+.
290
+ test("all 3 wrong-password paths take comparable scrypt-class time", () => {
291
+ // Setup: one user with new-format hash, one with legacy SHA-256
292
+ // seeded directly.
293
+ register("new-user", "NewUserP@ss1");
294
+
295
+ const legacyUserId = "usr_legacy_oracle";
296
+ const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:LegacyP@ss1").digest("hex");
297
+ db.run(
298
+ `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
299
+ [legacyUserId, "legacy-user", legacyHashed]
300
+ );
301
+
302
+ // Warm JIT + scrypt code paths
303
+ login("new-user", "wrong");
304
+ login("legacy-user", "wrong");
305
+ login("nobody-here-warmup", "wrong");
306
+
307
+ const sample = (fn: () => void): number => {
308
+ const N = 5;
309
+ const xs: number[] = [];
310
+ for (let i = 0; i < N; i++) {
311
+ const start = performance.now();
312
+ fn();
313
+ xs.push(performance.now() - start);
314
+ }
315
+ xs.sort((a, b) => a - b);
316
+ return xs[Math.floor(N / 2)];
317
+ };
318
+
319
+ const tNewWrong = sample(() => { login("new-user", "wrong"); });
320
+ const tLegacyWrong = sample(() => { login("legacy-user", "wrong"); });
321
+ const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });
322
+
323
+ // All three must run scrypt (> 1ms floor — early returns are sub-ms)
324
+ expect(tNewWrong).toBeGreaterThan(1);
325
+ expect(tLegacyWrong).toBeGreaterThan(1);
326
+ expect(tMissing).toBeGreaterThan(1);
327
+
328
+ // Order-of-magnitude parity. Pre-fix tLegacyWrong vs tNewWrong
329
+ // would be 50x+. Generous 5x cap absorbs CI scheduling jitter.
330
+ const maxT = Math.max(tNewWrong, tLegacyWrong, tMissing);
331
+ const minT = Math.max(0.1, Math.min(tNewWrong, tLegacyWrong, tMissing));
332
+ expect(maxT / minT).toBeLessThan(5);
333
+ });
334
+
335
+ test("legacy-wrong-password returns the same generic error string as the other paths", () => {
336
+ const legacyUserId = "usr_legacy_str";
337
+ const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:Pw1").digest("hex");
338
+ db.run(
339
+ `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
340
+ [legacyUserId, "legacy-str", legacyHashed]
341
+ );
342
+ register("new-str", "NewP@ss1");
343
+
344
+ const a = login("legacy-str", "wrong");
345
+ const b = login("new-str", "wrong");
346
+ const c = login("does-not-exist", "wrong");
347
+
348
+ expect(a.ok).toBe(false);
349
+ expect(b.ok).toBe(false);
350
+ expect(c.ok).toBe(false);
351
+ // All three must say EXACTLY the same thing or the oracle reopens
352
+ // at the response-body layer.
353
+ expect(a.error).toBe("invalid username or password");
354
+ expect(b.error).toBe(a.error);
355
+ expect(c.error).toBe(a.error);
356
+ });
357
+ });
358
+
359
+ describe("ordering invariant — single login transition (round-6 spec)", () => {
360
+ // The dispatch's load-bearing post-condition: "post-upgrade 旧串
361
+ // 不再存在 in DB". One concise pin.
362
+ test("after first login, legacy hash is provably absent from the row", () => {
363
+ const plain = "TransitionPw1";
364
+ insertLegacyUser("legacy-tx", plain);
365
+ const legacy = legacyHash(plain);
366
+
367
+ login("legacy-tx", plain);
368
+
369
+ const after = getStoredHash("legacy-tx")!;
370
+ expect(after).not.toBe(legacy); // not the same string
371
+ expect(after.startsWith("scrypt$")).toBe(true);
372
+ // The legacy string is exactly 64 hex chars. The new string is
373
+ // markedly longer (scrypt$N$salt$hash). Verify length disjoint
374
+ // so any future regression that re-stores a 64-char bare hex
375
+ // would be caught.
376
+ expect(after.length).toBeGreaterThan(64);
377
+ });
378
+ });
@@ -104,18 +104,27 @@ describe("hashToken — deterministic sha256", () => {
104
104
  });
105
105
  });
106
106
 
107
- describe("hashPassword — sha256('anet:' + password)", () => {
108
- it("returns 64-char lowercase hex", () => {
109
- expect(hashPassword("StrongPassw0rd")).toMatch(HEX_RE(64));
107
+ describe("hashPassword — salted scrypt (round-6 A1)", () => {
108
+ it("returns scrypt$N$salt$hash self-describing format", () => {
109
+ const stored = hashPassword("StrongPassw0rd");
110
+ expect(stored).toMatch(/^scrypt\$\d+\$[A-Za-z0-9+/=]+\$[A-Za-z0-9+/=]+$/);
110
111
  });
111
- it("deterministic", () => {
112
- expect(hashPassword("foo")).toBe(hashPassword("foo"));
112
+
113
+ it("NON-deterministic — same password produces different salts (the whole point)", () => {
114
+ // The pre-A1 hashPassword was deterministic SHA-256 — rainbow-
115
+ // table vulnerable. Salted scrypt MUST produce different output
116
+ // for repeated calls. If this fails, the salt isn't random.
117
+ const a = hashPassword("foo");
118
+ const b = hashPassword("foo");
119
+ expect(a).not.toBe(b);
113
120
  });
114
- it("uses 'anet:' salt → different from hashToken on same input", () => {
115
- // This pins the 'anet:' prefix — if someone drops it, this fails,
116
- // and existing user password hashes will silently stop matching.
121
+
122
+ it("does not collide with hashToken's surface (separate concerns)", () => {
123
+ // Pin the separation. Pre-A1 had hashPassword("foo") === hashToken("anet:foo"),
124
+ // which was an accidental coincidence of unsalted hashes; post-A1 the
125
+ // password hash carries randomness and never equals a token hash.
117
126
  expect(hashPassword("foo")).not.toBe(hashToken("foo"));
118
- expect(hashPassword("foo")).toBe(hashToken("anet:foo"));
127
+ expect(hashPassword("foo")).not.toBe(hashToken("anet:foo"));
119
128
  });
120
129
  });
121
130
 
package/src/auth.ts CHANGED
@@ -1,9 +1,33 @@
1
1
  /**
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
- import { db, generateId, hashPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
4
+ import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
6
 
7
+ // Round-6 A1 hardening — dummy hash for username-enumeration timing
8
+ // close. We compute ONE scrypt hash of a throwaway password and reuse
9
+ // it on every `!user` branch in login. The verify call against this
10
+ // dummy always fails the timingSafeEqual (the candidate password is
11
+ // unrelated), but the wall-clock cost of the scrypt() invocation is
12
+ // identical to the user-exists path — closing the ~50ms username-
13
+ // exists oracle that opened up when we moved off SHA-256 to scrypt.
14
+ //
15
+ // **Lazy** (memoized) rather than module-load constant: COMMHUB_SCRYPT_N
16
+ // may be set after this module loads (tests do this), and the dummy
17
+ // MUST match the current N so its scrypt cost matches the cost of
18
+ // verifying a real stored hash. Computing on first use captures the
19
+ // env-current N. After that it's a single fixed string for the
20
+ // process lifetime — one scrypt per process, not per request.
21
+ let _enumerationDummyHash: string | null = null;
22
+ function getEnumerationDummyHash(): string {
23
+ if (_enumerationDummyHash === null) {
24
+ _enumerationDummyHash = hashPassword(
25
+ "agent-network::enumeration-oracle-close::do-not-match"
26
+ );
27
+ }
28
+ return _enumerationDummyHash;
29
+ }
30
+
7
31
  export interface AuthUser {
8
32
  user_id: string;
9
33
  username: string;
@@ -104,8 +128,38 @@ export function login(username: string, password: string): AuthResult {
104
128
  "SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
105
129
  username);
106
130
 
107
- if (!user) return { ok: false, error: "invalid username or password" };
108
- if (user.password_hash !== hashPassword(password)) return { ok: false, error: "invalid username or password" };
131
+ if (!user) {
132
+ // Round-6 A1 hardening (timing-oracle close): pre-A1 the user-
133
+ // exists vs user-doesn't-exist branches both ran a µs-scale
134
+ // SHA-256, so wall-clock difference was lost in noise. Post-A1
135
+ // the user-exists path runs ~50ms scrypt while user-doesn't
136
+ // returns sub-ms — that ~50ms gap is web-measurable and turns
137
+ // login into a username-enumeration oracle (rate limiter only
138
+ // partially mitigates). Equalize by running scrypt against a
139
+ // module-constant dummy hash on the not-found path. Dummy is
140
+ // pre-generated at module load (not per call) so we don't burn
141
+ // a fresh scryptSync on every miss.
142
+ verifyPassword(password, getEnumerationDummyHash());
143
+ return { ok: false, error: "invalid username or password" };
144
+ }
145
+ // Round-6 A1: verifyPassword accepts both new scrypt format and
146
+ // legacy bare-sha256. On successful legacy verify, lazy-upgrade the
147
+ // stored hash in place (zero downtime, no forced password change).
148
+ const verify = verifyPassword(password, user.password_hash);
149
+ if (!verify.ok) return { ok: false, error: "invalid username or password" };
150
+ if (verify.needsRehash) {
151
+ try {
152
+ const upgraded = hashPassword(password);
153
+ db.run(
154
+ "UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2",
155
+ [upgraded, user.user_id]
156
+ );
157
+ } catch (e: any) {
158
+ // Don't fail the login if the rehash write hits a transient
159
+ // error — the user authenticated, we'll try again next login.
160
+ console.log(`[commhub auth] lazy-rehash failed for user ${user.user_id}: ${e?.message ?? e}`);
161
+ }
162
+ }
109
163
 
110
164
  // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
111
165
  // login (cli, dashboard, second machine) gets its own row so they don't
@@ -287,7 +341,11 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
287
341
  if (passwordError) return { ok: false, error: passwordError };
288
342
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
289
343
  if (!user) return { ok: false, error: "user not found" };
290
- if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
344
+ // Round-6 A1: verifyPassword accepts both formats. No need to
345
+ // lazy-rehash here separately because we're about to overwrite
346
+ // password_hash with the new password's scrypt hash below anyway.
347
+ const verify = verifyPassword(oldPassword, user.password_hash);
348
+ if (!verify.ok) return { ok: false, error: "incorrect current password" };
291
349
  // #261 P0-2 — clearing must_change_password as a side-effect of a real
292
350
  // password change. If the user changes via `anet passwd`, the bootstrap
293
351
  // nudge goes away on next login. SET to 0 explicitly (rather than skip)