@lifeaitools/clauth 2.10.1 → 2.15.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.
@@ -66,6 +66,15 @@ function validateCommand(command, field) {
66
66
  export function validateWatchdogService(service) {
67
67
  if (!service || typeof service !== "object") throw new Error("service must be an object");
68
68
  if (!service.id || typeof service.id !== "string") throw new Error("service.id is required");
69
+ // rdc:review finding (2026-09-02): shaped identically to the id checks in
70
+ // cli/supervisor-registry.js (validatePluginManifest, normalizeSurface),
71
+ // which also reject Windows-reserved device names (con/nul/aux/prn/
72
+ // com1-9/lpt1-9) at registration. NOT extended here deliberately: unlike
73
+ // those, service.id is not used to derive a filesystem path anywhere in
74
+ // this file today, so there is nothing for a reserved name to collide
75
+ // with. Revisit if a future change starts deriving a per-service path from
76
+ // service.id -- that would reintroduce the same defect class this comment
77
+ // exists to flag before it does.
69
78
  if (!/^[a-zA-Z0-9_.-]+$/.test(service.id)) throw new Error("service.id may contain only letters, numbers, dot, underscore, and dash");
70
79
  if (!service.label || typeof service.label !== "string") throw new Error("service.label is required");
71
80
  if (!VALID_KINDS.has(service.kind)) throw new Error(`service.kind must be one of ${[...VALID_KINDS].join(", ")}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "2.10.1",
3
+ "version": "2.15.2",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,6 +28,7 @@
28
28
  "node-fetch": "^3.3.2",
29
29
  "ora": "^8.1.0",
30
30
  "pm2": "^7.0.3",
31
+ "proper-lockfile": "^4.1.2",
31
32
  "typescript": "^5.9.3"
32
33
  },
33
34
  "engines": {
Binary file
Binary file
Binary file
@@ -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,18 +60,22 @@ 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();
72
81
  if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
@@ -99,20 +108,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
99
108
  return { valid: true };
100
109
  }
101
110
 
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
-
111
+ // auditLog() removed 2026-09-03 alongside checkRateLimit() -- it wrote a row
112
+ // to clauth_audit on every path through this function, including rejections
113
+ // (rate-limited, auth-denied, IP-blocked), which is what turned "the table
114
+ // got slow" into a feedback loop (more rejections -> more rows -> slower
115
+ // scans -> more rejections). No retention policy ever existed for this
116
+ // table. If durable audit logging is wanted back, it needs its own design --
117
+ // write-only, indexed for its actual read pattern (if any), with a real
118
+ // retention/partition policy -- not a bare insert-on-every-call with no cap.
106
119
  async function handleRetrieve(sb: any, body: any, mh: string) {
107
120
  const { service } = body;
108
121
  if (!service) return { error: "service required" };
109
122
  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" }; }
123
+ if (!svc) return { error: "service_not_found" };
124
+ if (!svc.enabled) return { error: "service_disabled" };
125
+ if (!svc.vault_key) return { error: "no_key_stored" };
113
126
  const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
114
127
  await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
115
- await auditLog(sb, mh, service, "retrieve", "success");
116
128
  return { service, key_type: svc.key_type, value: secret };
117
129
  }
118
130
 
@@ -121,9 +133,8 @@ async function handleWrite(sb: any, body: any, mh: string) {
121
133
  if (!service || !value) return { error: "service and value required" };
122
134
  const vaultKey = `clauth.${service}`;
123
135
  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 }; }
136
+ if (error) return { error: error.message };
125
137
  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
138
  return { success: true, service, vault_key: vaultKey };
128
139
  }
129
140
 
@@ -133,7 +144,6 @@ async function handleEnable(sb: any, body: any, mh: string) {
133
144
  q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
134
145
  const { error } = await q;
135
146
  if (error) return { error: error.message };
136
- await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
137
147
  return { success: true, service, enabled };
138
148
  }
139
149
 
@@ -144,7 +154,6 @@ async function handleAdd(sb: any, body: any, mh: string) {
144
154
  if (project) row.project = project;
145
155
  const { error } = await sb.from("clauth_services").insert(row);
146
156
  if (error) return { error: error.message };
147
- await auditLog(sb, mh, name, "add", "success");
148
157
  return { success: true, name, label, key_type, project: project || null };
149
158
  }
150
159
 
@@ -157,7 +166,6 @@ async function handleUpdate(sb: any, body: any, mh: string) {
157
166
  if (description !== undefined) updates.description = description || null;
158
167
  const { error } = await sb.from("clauth_services").update(updates).eq("name", service);
159
168
  if (error) return { error: error.message };
160
- await auditLog(sb, mh, service, "update", "success", `fields: ${Object.keys(updates).join(", ")}`);
161
169
  return { success: true, service, ...updates };
162
170
  }
163
171
 
@@ -166,7 +174,6 @@ async function handleRemove(sb: any, body: any, mh: string) {
166
174
  if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
167
175
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
168
176
  await sb.from("clauth_services").delete().eq("name", service);
169
- await auditLog(sb, mh, service, "remove", "success");
170
177
  return { success: true, service };
171
178
  }
172
179
 
@@ -182,7 +189,6 @@ async function handleRevoke(sb: any, body: any, mh: string) {
182
189
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
183
190
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
184
191
  }
185
- await auditLog(sb, mh, service, "revoke", "success");
186
192
  return { success: true, service };
187
193
  }
188
194
 
@@ -193,7 +199,6 @@ async function handleStatus(sb: any, body: any, mh: string) {
193
199
  .order("name");
194
200
  if (body.project) q = q.eq("project", body.project);
195
201
  const { data: services } = await q;
196
- await auditLog(sb, mh, "all", "status", "success");
197
202
  return { services: services || [] };
198
203
  }
199
204
 
@@ -204,7 +209,6 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
204
209
  .update({ hmac_seed_hash: new_hmac_seed_hash, fail_count: 0, locked: false })
205
210
  .eq("machine_hash", mh);
206
211
  if (error) return { error: error.message };
207
- await auditLog(sb, mh, "system", "change-password", "success");
208
212
  return { success: true };
209
213
  }
210
214
 
@@ -231,12 +235,8 @@ async function handleCreateEnrollment(sb: any, body: any, mh: string) {
231
235
  created_by_machine_hash: mh,
232
236
  expires_at,
233
237
  });
234
- if (error) {
235
- await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
236
- return { error: error.message };
237
- }
238
+ if (error) return { error: error.message };
238
239
 
239
- await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
240
240
  return { success: true, enrollment_code: code, install_id, expires_at, label };
241
241
  }
242
242
 
@@ -274,7 +274,6 @@ async function handleRedeemEnrollment(sb: any, body: any) {
274
274
  if (consumeError) return { error: consumeError.message };
275
275
  if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
276
276
 
277
- await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
278
277
  return { success: true, machine_hash, install_id };
279
278
  }
280
279
 
@@ -314,21 +313,11 @@ Deno.serve(async (req: Request) => {
314
313
 
315
314
  const ipCheck = checkIP(ip);
316
315
  if (!ipCheck.allowed) {
317
- await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
318
316
  return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
319
317
  }
320
318
 
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
319
  const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
330
320
  if (!authResult.valid) {
331
- await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
332
321
  return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
333
322
  }
334
323
 
@@ -0,0 +1,29 @@
1
+ -- Migration: remove clauth_audit and the rate-limiting it backed
2
+ -- Applied live to the LIFEAI project 2026-09-03; this file makes a fresh
3
+ -- `supabase db push` (a new operator following the setup guide) match that
4
+ -- same state instead of creating a table the current auth-vault code never
5
+ -- reads or writes.
6
+ --
7
+ -- Root cause of the 2026-09-03 incident this closes: checkRateLimit() in
8
+ -- supabase/functions/auth-vault/index.ts ran an unindexed COUNT against
9
+ -- clauth_audit, filtered by machine_hash + created_at, on EVERY request,
10
+ -- BEFORE authentication. As the table grew unbounded (no retention policy
11
+ -- ever existed), the scan cost grew with it; under sustained traffic the
12
+ -- queries stacked, Postgres killed them at statement_timeout, and Cloudflare
13
+ -- killed the stacked connections at its own 90s ceiling (522s). Every one of
14
+ -- those rejections also wrote an audit row via auditLog(), which fed the
15
+ -- same table the check was scanning -- a self-reinforcing feedback loop.
16
+ --
17
+ -- The security value the rate limiter provided was already covered, tighter,
18
+ -- by the per-machine lockout in validateHMAC (clauth_machines.fail_count /
19
+ -- .locked -- 5 failed attempts locks the machine, well under the 30-per-60s
20
+ -- window this used to allow). Rate limiting for a genuine runaway client
21
+ -- belongs at Cloudflare, in front of this function, not as a synchronous
22
+ -- Postgres query on the hot path of every request.
23
+ --
24
+ -- Confirmed before dropping the live table: no function in pg_proc
25
+ -- referenced clauth_audit in its source, and no trigger targeted it --
26
+ -- auth-vault's own auditLog() (now removed from the deployed function) was
27
+ -- its only writer, and RLS (no_anon_audit, see 001_clauth_schema.sql) meant
28
+ -- no client ever had direct write access either.
29
+ DROP TABLE IF EXISTS public.clauth_audit CASCADE;