@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 +3 -2
- package/src/auth-kdf-migration.test.ts +378 -0
- package/src/auth-tokens.test.ts +18 -9
- package/src/auth.ts +62 -4
- package/src/config-apply-sec1.test.ts +723 -0
- package/src/config-apply-validate.test.ts +260 -0
- package/src/config-apply-validate.ts +171 -0
- package/src/cross-tenant-injection.test.ts +110 -0
- package/src/db.ts +303 -8
- package/src/index.ts +61 -17
- package/src/retention.test.ts +450 -0
- package/src/retention.ts +269 -0
- package/src/stale-sweeper.test.ts +181 -0
- package/src/stale-sweeper.ts +114 -0
- package/src/tools.ts +653 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
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
|
+
});
|
package/src/auth-tokens.test.ts
CHANGED
|
@@ -104,18 +104,27 @@ describe("hashToken — deterministic sha256", () => {
|
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
describe("hashPassword —
|
|
108
|
-
it("returns
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
//
|
|
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)
|
|
108
|
-
|
|
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
|
-
|
|
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)
|