@sleep2agi/commhub-server 0.9.0-preview.107 → 0.9.0-preview.109

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.107",
3
+ "version": "0.9.0-preview.109",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,176 @@
1
+ // #649: a successful login with a weak password flags the account.
2
+ //
3
+ // Accounts created before #261 (2026-06-28) added `must_change_password` sit at 0 even when they
4
+ // still use the bootstrap default. Login is the one place the plaintext is in hand, so a login that
5
+ // succeeds with a password failing validatePasswordStrength (too short / in WEAK_PASSWORDS) sets the
6
+ // flag and returns `must_change_password: true`. Changing the password clears it.
7
+ //
8
+ // Every case goes through the real HTTP route (bootServer + fetch POST /api/auth/login), not the
9
+ // module function, so the response shape is the one clients see. The aggregate runner gives this
10
+ // file its own COMMHUB_DB / HOME / TMPDIR; the hub listens on port 0.
11
+ // Mutation: drop the new block in login() and the weak cases go red (flag stays 0, field absent).
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { createHash } from "crypto";
18
+ import { register } from "./auth.js";
19
+ import { db, hashPassword } from "./db.js";
20
+ import { WEAK_PASSWORDS } from "./password-dict.js";
21
+
22
+ const DIR = mkdtempSync(join(tmpdir(), "anet-weakpw-"));
23
+ let BASE = "";
24
+ let hub: any = null;
25
+ const STRONG = "Str0ng-Fixture-Passw0rd!";
26
+ // Fixture plaintexts only (not anyone's real password).
27
+ const TOO_SHORT = "abc1234"; // 7 chars: fails the length rule (same shape as an old bootstrap default)
28
+ const IN_LIST = "password1"; // ≥ 8 chars but in WEAK_PASSWORDS
29
+
30
+ async function post(path: string, body: unknown, token = "") {
31
+ const res = await fetch(`${BASE}${path}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
34
+ body: JSON.stringify(body),
35
+ });
36
+ return { status: res.status, body: await res.json().catch(() => null) as any };
37
+ }
38
+ const login = (username: string, password: string) => post("/api/auth/login", { username, password });
39
+ const flag = (userId: string) => db.get<any>("SELECT must_change_password AS f FROM users WHERE user_id = ?1", userId)?.f;
40
+
41
+ let seq = 0;
42
+ /** A user created the normal way (strong password), then rewound to an "old account" state:
43
+ * its stored hash replaced by `password` and the flag forced to 0. */
44
+ function oldAccount(password: string, opts: { legacySha256?: boolean } = {}) {
45
+ const username = `weakpw_${Date.now()}_${seq++}`;
46
+ const reg = register(username, STRONG, undefined, undefined, { issueTokens: false });
47
+ expect(reg.ok).toBe(true);
48
+ const userId = reg.user!.user_id;
49
+ const hash = opts.legacySha256 ? createHash("sha256").update(`anet:${password}`).digest("hex") : hashPassword(password); // legacy = pre-A1 `anet:`-prefixed sha256 (auth-kdf-migration.test.ts)
50
+ db.run("UPDATE users SET password_hash = ?1, must_change_password = 0 WHERE user_id = ?2", [hash, userId]);
51
+ expect(flag(userId)).toBe(0);
52
+ return { username, userId };
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
57
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
58
+ process.env.HOST = "127.0.0.1";
59
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
60
+ const mod: any = await import("./server.js");
61
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
62
+ BASE = `http://127.0.0.1:${hub.port}`;
63
+ }, 30_000);
64
+
65
+ afterAll(() => {
66
+ try { hub?.stop?.(true); } catch {}
67
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
68
+ });
69
+
70
+ describe("fixtures are what they claim", () => {
71
+ test("TOO_SHORT is < 8 chars and IN_LIST is >= 8 chars and in WEAK_PASSWORDS; STRONG is neither", () => {
72
+ expect(TOO_SHORT.length).toBeLessThan(8);
73
+ expect(IN_LIST.length).toBeGreaterThanOrEqual(8);
74
+ expect(WEAK_PASSWORDS.has(IN_LIST)).toBe(true);
75
+ expect(STRONG.length).toBeGreaterThanOrEqual(8);
76
+ expect(WEAK_PASSWORDS.has(STRONG.toLowerCase())).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("successful login with a weak password sets must_change_password", () => {
81
+ test("too-short password, flag forced to 0 → login → flag 1 and response says must_change_password: true", async () => {
82
+ const u = oldAccount(TOO_SHORT);
83
+ const r = await login(u.username, TOO_SHORT);
84
+ expect(r.status).toBe(200);
85
+ expect(r.body.ok).toBe(true);
86
+ expect(r.body.must_change_password).toBe(true);
87
+ expect(flag(u.userId)).toBe(1);
88
+ });
89
+
90
+ test("password in WEAK_PASSWORDS (length OK) → flagged", async () => {
91
+ const u = oldAccount(IN_LIST);
92
+ const r = await login(u.username, IN_LIST);
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.must_change_password).toBe(true);
95
+ expect(flag(u.userId)).toBe(1);
96
+ });
97
+
98
+ test("weak-list match is case-insensitive like validatePasswordStrength", async () => {
99
+ const upper = IN_LIST.toUpperCase();
100
+ const u = oldAccount(upper);
101
+ const r = await login(u.username, upper);
102
+ expect(r.status).toBe(200);
103
+ expect(r.body.must_change_password).toBe(true);
104
+ expect(flag(u.userId)).toBe(1);
105
+ });
106
+
107
+ test("legacy sha256 hash + weak password → login succeeds, hash upgraded, flag set", async () => {
108
+ const u = oldAccount(TOO_SHORT, { legacySha256: true });
109
+ const r = await login(u.username, TOO_SHORT);
110
+ expect(r.status).toBe(200);
111
+ expect(r.body.must_change_password).toBe(true);
112
+ expect(flag(u.userId)).toBe(1);
113
+ const stored = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", u.userId).password_hash;
114
+ expect(stored.startsWith("scrypt$")).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe("no flag where it does not belong", () => {
119
+ test("strong password → flag stays 0 and the field is absent", async () => {
120
+ const u = oldAccount(STRONG);
121
+ const r = await login(u.username, STRONG);
122
+ expect(r.status).toBe(200);
123
+ expect(r.body.ok).toBe(true);
124
+ expect("must_change_password" in r.body).toBe(false);
125
+ expect(flag(u.userId)).toBe(0);
126
+ });
127
+
128
+ test("failed login against a weak-password account does not touch the flag", async () => {
129
+ const u = oldAccount(TOO_SHORT);
130
+ const r = await login(u.username, "wrong-password-xyz");
131
+ expect(r.status).toBe(401);
132
+ expect(r.body.must_change_password).toBeUndefined();
133
+ expect(flag(u.userId)).toBe(0);
134
+ });
135
+
136
+ test("an already-flagged account with a strong password still reports the flag (#261 behaviour kept)", async () => {
137
+ const u = oldAccount(STRONG);
138
+ db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [u.userId]);
139
+ const r = await login(u.username, STRONG);
140
+ expect(r.body.must_change_password).toBe(true);
141
+ expect(flag(u.userId)).toBe(1);
142
+ });
143
+ });
144
+
145
+ describe("changing the password clears the flag", () => {
146
+ test("weak login flags → POST /api/auth/password to a strong one → flag 0 → next login has no field", async () => {
147
+ const u = oldAccount(TOO_SHORT);
148
+ const first = await login(u.username, TOO_SHORT);
149
+ expect(first.body.must_change_password).toBe(true);
150
+ const changed = await post("/api/auth/password", { old_password: TOO_SHORT, new_password: STRONG }, first.body.token);
151
+ expect(changed.status).toBe(200);
152
+ expect(changed.body.ok).toBe(true);
153
+ expect(flag(u.userId)).toBe(0);
154
+ const again = await login(u.username, STRONG);
155
+ expect(again.status).toBe(200);
156
+ expect("must_change_password" in again.body).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("the plaintext is never logged", () => {
161
+ test("console output during a weak-password login does not contain the password", async () => {
162
+ const u = oldAccount(IN_LIST);
163
+ const seen: string[] = [];
164
+ const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
165
+ for (const k of Object.keys(orig) as (keyof typeof orig)[]) {
166
+ (console as any)[k] = (...a: unknown[]) => { seen.push(a.map(String).join(" ")); };
167
+ }
168
+ try {
169
+ const r = await login(u.username, IN_LIST);
170
+ expect(r.body.must_change_password).toBe(true);
171
+ } finally {
172
+ Object.assign(console, orig);
173
+ }
174
+ expect(seen.some(l => l.includes(IN_LIST))).toBe(false);
175
+ });
176
+ });
package/src/auth.ts CHANGED
@@ -196,6 +196,20 @@ export function login(username: string, password: string, client: SessionClientI
196
196
  }
197
197
  }
198
198
 
199
+ // #649: a successful login whose plaintext fails the same strength rule as
200
+ // register/changePassword (too short, or in WEAK_PASSWORDS) flags the account.
201
+ // Catches accounts created before #261 added must_change_password (they sit at
202
+ // 0 with the bootstrap default). changePassword clears it. Never log the password.
203
+ let mustChangePassword = user.must_change_password === 1;
204
+ if (!mustChangePassword && validatePasswordStrength(password) !== null) {
205
+ mustChangePassword = true;
206
+ try {
207
+ db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [user.user_id]);
208
+ } catch (e: any) {
209
+ console.log(`[commhub auth] weak-password flag write failed for user ${user.user_id}: ${e?.message ?? e}`);
210
+ }
211
+ }
212
+
199
213
  // Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
200
214
  // login (cli, dashboard, second machine) gets its own row so they don't
201
215
  // kick each other out of session. Tokens can be revoked via /api/auth/tokens.
@@ -223,7 +237,7 @@ export function login(username: string, password: string, client: SessionClientI
223
237
  network_id: networkId,
224
238
  // #261 P0-2 — only include field when truthy (back-compat; old clients
225
239
  // don't see this field at all unless their account is flagged).
226
- ...(user.must_change_password === 1 ? { must_change_password: true } : {}),
240
+ ...(mustChangePassword ? { must_change_password: true } : {}),
227
241
  };
228
242
  }
229
243
 
@@ -231,7 +231,7 @@ export function validateEnvRefs(
231
231
  return out;
232
232
  }
233
233
 
234
- // §4.4.7 — safe serializer for .env.local. Dotenv-quoted with escape
234
+ // §4.4.7 — safe serializer for the node secrets.env file. Dotenv-quoted with escape
235
235
  // for backslash / double-quote / newline / carriage-return so that a
236
236
  // value containing `\n"evil=KEY2"` cannot pollute the next line.
237
237
  export function serializeEnvLocal(env: Record<string, string>): string {
package/src/tools.ts CHANGED
@@ -3879,6 +3879,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3879
3879
  if (!sec1Ok) {
3880
3880
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "cross_network_node" }) }] };
3881
3881
  }
3882
+ // board #627: a binding proves stop/start authority, not an exit-75
3883
+ // supervisor. No verified adopted-wrapper capability exists in this
3884
+ // protocol; a self-reported config_update_capable flag is not proof.
3885
+ // Created authority retains its existing restart behavior unchanged.
3886
+ if (!createdDaemon(nodeId) && activeBinding(nodeId)) {
3887
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false,
3888
+ error: "adopted_restart_requires_daemon", message: "Use Stop, then Start for this adopted node." }) }] };
3889
+ }
3882
3890
  // F-B reaper: same stale-supersede semantics as update_node_config,
3883
3891
  // with same acked_at-anchored liveness clock (see update_node_config
3884
3892
  // for the 通信龙 polish reasoning).
@@ -5889,7 +5897,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5889
5897
  vendor: blob.vendor,
5890
5898
  base_url: blob.base_url,
5891
5899
  model_name: blob.model_name,
5892
- api_key: blob.api_key, // ephemeral; daemon writes to .env.local or in-memory only
5900
+ api_key: blob.api_key, // ephemeral; daemon writes to secrets.env or in-memory only
5893
5901
  }) }] };
5894
5902
  },
5895
5903
  );