@sleep2agi/commhub-server 0.8.8 → 0.9.0-preview.1

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.8",
4
- "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
3
+ "version": "0.9.0-preview.1",
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",
7
7
  "bin": {
@@ -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;
@@ -19,6 +43,14 @@ export interface AuthResult {
19
43
  token?: string; // user token (utok_)
20
44
  network_token?: string; // network token (ntok_) for default network
21
45
  network_id?: string;
46
+ // #261 P0-2 (2026-06-28): true when the logged-in user still has the
47
+ // bootstrap-default password. The CLI client uses this to print a
48
+ // prominent "must change password" warning after login succeeds. NOT
49
+ // a login-blocker — we don't lock out anyone whose deployment ran on
50
+ // the old `admin/anethub` default (back-compat per 通信龙 spec). The
51
+ // field is intentionally absent (rather than `false`) on the normal
52
+ // case so old clients don't even see it.
53
+ must_change_password?: boolean;
22
54
  }
23
55
 
24
56
  function validatePasswordStrength(password: string, label = "password"): string | null {
@@ -93,11 +125,41 @@ export function register(username: string, password: string, email?: string, dis
93
125
 
94
126
  export function login(username: string, password: string): AuthResult {
95
127
  const user = db.get<any>(
96
- "SELECT user_id, username, password_hash, display_name, email, role FROM users WHERE username = ?1",
128
+ "SELECT user_id, username, password_hash, display_name, email, role, must_change_password FROM users WHERE username = ?1",
97
129
  username);
98
130
 
99
- if (!user) return { ok: false, error: "invalid username or password" };
100
- 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
+ }
101
163
 
102
164
  // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
103
165
  // login (cli, dashboard, second machine) gets its own row so they don't
@@ -123,6 +185,9 @@ export function login(username: string, password: string): AuthResult {
123
185
  user: { user_id: user.user_id, username: user.username, display_name: user.display_name, email: user.email, role: user.role },
124
186
  token,
125
187
  network_id: networkId,
188
+ // #261 P0-2 — only include field when truthy (back-compat; old clients
189
+ // don't see this field at all unless their account is flagged).
190
+ ...(user.must_change_password === 1 ? { must_change_password: true } : {}),
126
191
  };
127
192
  }
128
193
 
@@ -276,12 +341,33 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
276
341
  if (passwordError) return { ok: false, error: passwordError };
277
342
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
278
343
  if (!user) return { ok: false, error: "user not found" };
279
- if (user.password_hash !== hashPassword(oldPassword)) return { ok: false, error: "incorrect current password" };
280
- db.run("UPDATE users SET password_hash = ?1, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
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" };
349
+ // #261 P0-2 — clearing must_change_password as a side-effect of a real
350
+ // password change. If the user changes via `anet passwd`, the bootstrap
351
+ // nudge goes away on next login. SET to 0 explicitly (rather than skip)
352
+ // so a future flag flip can't drift the state.
353
+ db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
281
354
  const revoked = revokeOtherUserTokens(userId, currentTokenId);
282
355
  return { ok: true, revoked };
283
356
  }
284
357
 
358
+ /**
359
+ * #261 P0-2 — mark a user as needing to change their password on first
360
+ * login. Called by the hub-bootstrap path (`anet hub start`) AFTER it
361
+ * auto-registers the admin with a random bootstrap password. Idempotent.
362
+ * Returns true if a row was affected (i.e. the user exists), false
363
+ * otherwise. NOT exposed via any HTTP endpoint — internal-only, called
364
+ * via direct module import or a local-process SQLite handle.
365
+ */
366
+ export function markMustChangePassword(userId: string): boolean {
367
+ const r = db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [userId]);
368
+ return r.changes > 0;
369
+ }
370
+
285
371
  export function resetUserPassword(targetUsername: string, callerIsHubAdmin: boolean): { ok: boolean; error?: string; username?: string; user_id?: string; password?: string; token?: string; token_id?: string; revoked?: number } {
286
372
  if (!callerIsHubAdmin) return { ok: false, error: "hub admin required" };
287
373
  const user = db.get<any>("SELECT user_id, username FROM users WHERE username = ?1", targetUsername);