@lifeaitools/clauth 2.15.6 → 2.15.7

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.
@@ -15,7 +15,33 @@ import path from "path";
15
15
  // Cache path — avoids re-querying WMI/CIM on every daemon start.
16
16
  // This eliminates the spawnSync cmd.exe ETIMEDOUT crash that occurs
17
17
  // when PowerShell/WMI is slow on first call after boot.
18
- const CACHE_FILE = path.join(os.tmpdir(), "clauth-machine.cache");
18
+ //
19
+ // Durable as of 2026-09-06 (was os.tmpdir(), see LEGACY_CACHE_FILE below).
20
+ // getMachineId() derives (primary, secondary) from TWO independent execSync
21
+ // calls, each wrapped in its own try/catch that falls through silently on
22
+ // failure (see the comments at those calls). If either call transiently
23
+ // timed out, the resulting pairing was composed differently than a
24
+ // successful run, producing a DIFFERENT sha256 machine hash for the exact
25
+ // same physical machine — surfacing server-side as a false
26
+ // machine_not_found lockout, not a security event. A cache in system temp
27
+ // does not survive Windows Disk Cleanup or some machines' reboot behavior,
28
+ // which re-exposed the flaky first-computation path on any later restart
29
+ // where WMI happened to be slow. Matches the durable pattern boot.key
30
+ // already uses (getBootKeyPath() in cli/commands/serve.js) — same
31
+ // AppData/Roaming/clauth (win32) / ~/.config/clauth (else) directory.
32
+ function getCacheDir() {
33
+ if (os.platform() === "win32") {
34
+ return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "clauth");
35
+ }
36
+ return path.join(os.homedir(), ".config", "clauth");
37
+ }
38
+ const CACHE_FILE = path.join(getCacheDir(), "machine.cache");
39
+ // Pre-2026-09-06 location. An already-registered machine may still have a
40
+ // valid cache here; migrate it into the durable location on first read
41
+ // instead of recomputing via WMI — recomputing is exactly the flaky path
42
+ // this fix exists to avoid, and the durable write means this only ever
43
+ // happens once per machine.
44
+ const LEGACY_CACHE_FILE = path.join(os.tmpdir(), "clauth-machine.cache");
19
45
 
20
46
  function readCache() {
21
47
  try {
@@ -23,12 +49,24 @@ function readCache() {
23
49
  // Validate: must be two non-empty lines (primary:secondary)
24
50
  const [primary, secondary] = raw.split("\n");
25
51
  if (primary && secondary) return { primary: primary.trim(), secondary: secondary.trim() };
26
- } catch { /* cache miss */ }
52
+ } catch { /* durable cache miss */ }
53
+ try {
54
+ const raw = fs.readFileSync(LEGACY_CACHE_FILE, "utf8").trim();
55
+ const [primary, secondary] = raw.split("\n");
56
+ if (primary && secondary) {
57
+ const migrated = { primary: primary.trim(), secondary: secondary.trim() };
58
+ writeCache(migrated.primary, migrated.secondary);
59
+ return migrated;
60
+ }
61
+ } catch { /* no legacy cache either */ }
27
62
  return null;
28
63
  }
29
64
 
30
65
  function writeCache(primary, secondary) {
31
- try { fs.writeFileSync(CACHE_FILE, `${primary}\n${secondary}`, "utf8"); } catch { /* best effort */ }
66
+ try {
67
+ fs.mkdirSync(getCacheDir(), { recursive: true });
68
+ fs.writeFileSync(CACHE_FILE, `${primary}\n${secondary}`, "utf8");
69
+ } catch { /* best effort */ }
32
70
  }
33
71
 
34
72
  function getMachineId() {
@@ -140,4 +178,8 @@ export function deriveSeedHash(machineHash, password) {
140
178
  .digest("hex");
141
179
  }
142
180
 
181
+ // Exported for test path introspection only — not part of the public
182
+ // CLI/API surface.
183
+ export { CACHE_FILE, LEGACY_CACHE_FILE, getCacheDir };
184
+
143
185
  export default { getMachineHash, deriveToken, deriveSeedHash };
@@ -4,7 +4,12 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
 
7
- import { getMachineHash, deriveToken, deriveSeedHash } from "./fingerprint.js";
7
+ import { getMachineHash, deriveToken, deriveSeedHash, CACHE_FILE, LEGACY_CACHE_FILE } from "./fingerprint.js";
8
+
9
+ // CACHE_FILE/LEGACY_CACHE_FILE are the REAL production paths (durable
10
+ // AppData/Roaming/clauth on win32, formerly os.tmpdir()) — read-only in this
11
+ // suite. Every test below only ever reads mtimeMs, never asserts on or
12
+ // writes machine-identifying content into either file.
8
13
 
9
14
  // getMachineId() normally shells out to WMI/registry/ioreg, which this harness
10
15
  // must never do (slow, platform-dependent, pollutes nothing but still real
@@ -84,12 +89,31 @@ test("deriveSeedHash changes with the password", () => {
84
89
  assert.notEqual(deriveSeedHash(hash, "one"), deriveSeedHash(hash, "two"));
85
90
  });
86
91
 
87
- test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file", () => {
92
+ test("machine id cache: a fresh CLAUTH_MACHINE_ID call never touches the WMI cache file (durable or legacy)", () => {
88
93
  // Positive control that the cache mechanism exists and this test can see it,
89
94
  // so "the cache file was untouched" below actually means something.
90
- const cacheFile = path.join(os.tmpdir(), "clauth-machine.cache");
91
- const before = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
95
+ const before = {
96
+ durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
97
+ legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
98
+ };
92
99
  withMachineId("container-fast-path", () => getMachineHash());
93
- const after = fs.existsSync(cacheFile) ? fs.statSync(cacheFile).mtimeMs : null;
94
- assert.equal(before, after, "the container-id fast path must not read or write the WMI cache file");
100
+ const after = {
101
+ durable: fs.existsSync(CACHE_FILE) ? fs.statSync(CACHE_FILE).mtimeMs : null,
102
+ legacy: fs.existsSync(LEGACY_CACHE_FILE) ? fs.statSync(LEGACY_CACHE_FILE).mtimeMs : null,
103
+ };
104
+ assert.deepEqual(before, after, "the container-id fast path must not read or write either cache file");
105
+ });
106
+
107
+ test("cache paths are durable (AppData/Roaming/clauth or ~/.config/clauth), never os.tmpdir()", () => {
108
+ // The whole point of this fix: CACHE_FILE must NOT live in system temp,
109
+ // which Windows Disk Cleanup (or equivalent) can wipe, re-exposing the
110
+ // flaky first-computation WMI path on the next restart.
111
+ assert.ok(!CACHE_FILE.startsWith(os.tmpdir()), `CACHE_FILE must be durable, got: ${CACHE_FILE}`);
112
+ assert.equal(path.basename(CACHE_FILE), "machine.cache");
113
+ const dir = path.dirname(CACHE_FILE);
114
+ assert.ok(dir.endsWith(path.join("clauth")), `cache dir must be a clauth-owned config directory, got: ${dir}`);
115
+ // LEGACY_CACHE_FILE is the pre-fix location, kept only so readCache() can
116
+ // migrate an already-registered machine's existing cache instead of
117
+ // recomputing via WMI (see fingerprint.js).
118
+ assert.equal(LEGACY_CACHE_FILE, path.join(os.tmpdir(), "clauth-machine.cache"));
95
119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "2.15.6",
3
+ "version": "2.15.7",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,12 @@
1
- // clauth — auth-vault Edge Function v2
2
- // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
1
+ // clauth — auth-vault Edge Function v3
2
+ // IP whitelist + machine lockout (fail_count + locked) remain. Rate limiting
3
+ // and audit logging (clauth_audit) removed 2026-09-03 -- see the comments at
4
+ // validateHMAC and where auditLog used to be defined for why: the rate-limit
5
+ // check was an unindexed COUNT against clauth_audit run on EVERY request,
6
+ // pre-auth, and every rejection it produced also wrote an audit row -- a
7
+ // feedback loop that took the whole project down under sustained load. The
8
+ // security value it provided was already covered, tighter, by the
9
+ // per-machine 5-failed-attempts lockout below.
3
10
 
4
11
  import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
5
12
 
@@ -11,8 +18,6 @@ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
11
18
  const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
19
  .split(",").map(s => s.trim()).filter(Boolean);
13
20
 
14
- const RATE_LIMIT_MAX = 30;
15
- const RATE_LIMIT_WINDOW = 60;
16
21
  const REPLAY_WINDOW_MS = 5 * 60 * 1000;
17
22
  const MAX_FAIL_COUNT = 5;
18
23
  const DEFAULT_INSTALL_ID = "default";
@@ -55,20 +60,36 @@ function checkIP(ip: string): { allowed: boolean; reason?: string } {
55
60
  return { allowed: false, reason: `IP not whitelisted: ${ip}` };
56
61
  }
57
62
 
58
- async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
59
- const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
60
- const { count } = await sb.from("clauth_audit")
61
- .select("id", { count: "exact", head: true })
62
- .eq("machine_hash", machine_hash)
63
- .gte("created_at", windowStart);
64
- if ((count || 0) >= RATE_LIMIT_MAX) {
65
- return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
66
- }
67
- return { allowed: true };
68
- }
69
-
63
+ // rate-limiting removed 2026-09-03 -- see supervisor-registry.js's own
64
+ // state.json lock history for the shape of this bug: checkRateLimit() ran
65
+ // this exact query -- a COUNT against clauth_audit filtered by machine_hash
66
+ // and created_at -- on EVERY request, BEFORE authentication (validateHMAC
67
+ // below), against a table with no index on either column and no retention.
68
+ // As the table grew (174,385 rows, unbounded), the scan cost grew with it;
69
+ // under sustained traffic the queries started stacking, Postgres killed them
70
+ // at statement_timeout, and Cloudflare killed the stacked connections at its
71
+ // own 90s ceiling (522s) -- and every one of those rejections ALSO wrote an
72
+ // audit row (see auditLog's removal below), so the failure fed itself. The
73
+ // actual security protection this duplicated is already provided, tighter,
74
+ // by the per-machine lockout in validateHMAC below (5 failed attempts locks
75
+ // the machine -- well under the 30-per-60s this used to allow). Rate
76
+ // limiting for a genuine runaway client belongs at Cloudflare, in front of
77
+ // this function, not as a synchronous Postgres query on the hot path of
78
+ // every request.
70
79
  async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
71
80
  const now = Date.now();
81
+
82
+ // A caller that omits machine_hash entirely used to fall straight through
83
+ // to the clauth_machines lookup below with body.machine_hash === undefined.
84
+ // PostgREST's .single() then answers "0 rows" for a query that can never
85
+ // match, which comes back as an HTTP 406 -- 1,438 of these in 24h
86
+ // (2026-09-06), every one a wasted round trip for a request already known
87
+ // to be malformed. Reject before the DB call: same auth_failed response
88
+ // the caller already gets, no query, no spurious 406 in the logs.
89
+ if (typeof body.machine_hash !== "string" || !body.machine_hash) {
90
+ return { valid: false, reason: "machine_hash_missing" };
91
+ }
92
+
72
93
  if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
73
94
 
74
95
  const { data: machine, error } = await sb.from("clauth_machines")
@@ -99,20 +120,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
99
120
  return { valid: true };
100
121
  }
101
122
 
102
- async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
103
- await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
104
- }
105
-
123
+ // auditLog() removed 2026-09-03 alongside checkRateLimit() -- it wrote a row
124
+ // to clauth_audit on every path through this function, including rejections
125
+ // (rate-limited, auth-denied, IP-blocked), which is what turned "the table
126
+ // got slow" into a feedback loop (more rejections -> more rows -> slower
127
+ // scans -> more rejections). No retention policy ever existed for this
128
+ // table. If durable audit logging is wanted back, it needs its own design --
129
+ // write-only, indexed for its actual read pattern (if any), with a real
130
+ // retention/partition policy -- not a bare insert-on-every-call with no cap.
106
131
  async function handleRetrieve(sb: any, body: any, mh: string) {
107
132
  const { service } = body;
108
133
  if (!service) return { error: "service required" };
109
134
  const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
110
- if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
111
- if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
112
- if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
135
+ if (!svc) return { error: "service_not_found" };
136
+ if (!svc.enabled) return { error: "service_disabled" };
137
+ if (!svc.vault_key) return { error: "no_key_stored" };
113
138
  const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
114
139
  await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
115
- await auditLog(sb, mh, service, "retrieve", "success");
116
140
  return { service, key_type: svc.key_type, value: secret };
117
141
  }
118
142
 
@@ -121,9 +145,8 @@ async function handleWrite(sb: any, body: any, mh: string) {
121
145
  if (!service || !value) return { error: "service and value required" };
122
146
  const vaultKey = `clauth.${service}`;
123
147
  const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
124
- if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
148
+ if (error) return { error: error.message };
125
149
  await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
126
- await auditLog(sb, mh, service, "write", "success");
127
150
  return { success: true, service, vault_key: vaultKey };
128
151
  }
129
152
 
@@ -133,7 +156,6 @@ async function handleEnable(sb: any, body: any, mh: string) {
133
156
  q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
134
157
  const { error } = await q;
135
158
  if (error) return { error: error.message };
136
- await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
137
159
  return { success: true, service, enabled };
138
160
  }
139
161
 
@@ -144,7 +166,6 @@ async function handleAdd(sb: any, body: any, mh: string) {
144
166
  if (project) row.project = project;
145
167
  const { error } = await sb.from("clauth_services").insert(row);
146
168
  if (error) return { error: error.message };
147
- await auditLog(sb, mh, name, "add", "success");
148
169
  return { success: true, name, label, key_type, project: project || null };
149
170
  }
150
171
 
@@ -157,7 +178,6 @@ async function handleUpdate(sb: any, body: any, mh: string) {
157
178
  if (description !== undefined) updates.description = description || null;
158
179
  const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
159
180
  if (error) return { error: error.message };
160
- await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
161
181
  return { success: true, service, ...updates };
162
182
  }
163
183
 
@@ -166,7 +186,6 @@ async function handleRemove(sb: any, body: any, mh: string) {
166
186
  if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
167
187
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
168
188
  await sb.from("clauth_services").delete().eq("name", service);
169
- await auditLog(sb, mh, service, "remove", "success");
170
189
  return { success: true, service };
171
190
  }
172
191
 
@@ -182,7 +201,6 @@ async function handleRevoke(sb: any, body: any, mh: string) {
182
201
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
183
202
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
184
203
  }
185
- await auditLog(sb, mh, service, "revoke", "success");
186
204
  return { success: true, service };
187
205
  }
188
206
 
@@ -193,7 +211,6 @@ async function handleStatus(sb: any, body: any, mh: string) {
193
211
  .order("name");
194
212
  if (body.project) q = q.eq("project", body.project);
195
213
  const { data: services } = await q;
196
- await auditLog(sb, mh, "all", "status", "success");
197
214
  return { services: services || [] };
198
215
  }
199
216
 
@@ -204,7 +221,6 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
204
221
  .update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
205
222
  .eq("machine_hash", mh);
206
223
  if (error) return { error: error.message };
207
- await auditLog(sb, mh, "system", "change-password", "success");
208
224
  return { success: true };
209
225
  }
210
226
 
@@ -231,12 +247,8 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
231
247
  created_by_machine_hash: mh,
232
248
  expires_at,
233
249
  });
234
- if (error) {
235
- await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
- return { error: error.message };
237
- }
250
+ if (error) return { error: error.message };
238
251
 
239
- await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
252
  return { success: true, enrollment_code: code, install_id, expires_at, label };
241
253
  }
242
254
 
@@ -274,7 +286,6 @@ async function handleRedeemEnrollment(sb: any, body: any) {
274
286
  if (consumeError) return { error: consumeError.message };
275
287
  if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
288
 
277
- await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
289
  return { success: true, machine_hash, install_id };
279
290
  }
280
291
 
@@ -314,21 +325,11 @@ Deno.serve(async (req: Request) => {
314
325
 
315
326
  const ipCheck = checkIP(ip);
316
327
  if (!ipCheck.allowed) {
317
- await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
318
328
  return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
319
329
  }
320
330
 
321
- if (body.machine_hash) {
322
- const rateCheck = await checkRateLimit(sb, body.machine_hash);
323
- if (!rateCheck.allowed) {
324
- await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
325
- return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
326
- }
327
- }
328
-
329
331
  const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
330
332
  if (!authResult.valid) {
331
- await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
332
333
  return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
333
334
  }
334
335