@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.
- package/cli/fingerprint.js +45 -3
- package/cli/fingerprint.test.js +30 -6
- package/package.json +1 -1
- package/supabase/functions/auth-vault/index.ts +50 -49
package/cli/fingerprint.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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 };
|
package/cli/fingerprint.test.js
CHANGED
|
@@ -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
|
|
91
|
-
|
|
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 =
|
|
94
|
-
|
|
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,5 +1,12 @@
|
|
|
1
|
-
// clauth — auth-vault Edge Function
|
|
2
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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)
|
|
111
|
-
if (!svc.enabled)
|
|
112
|
-
if (!svc.vault_key)
|
|
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)
|
|
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
|
|