@lifeaitools/clauth 1.9.2 → 1.9.3

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.
@@ -604,6 +604,22 @@ function isProcessAlive(pid) {
604
604
  try { process.kill(pid, 0); return true; } catch { return false; }
605
605
  }
606
606
 
607
+ // STOP-ON-REJECTION classifier. A server `reason` of invalid_token / machine_locked
608
+ // (and friends) is a terminal verdict: the supplied password/machine is wrong or the
609
+ // machine is locked, so every further attempt only burns another of the 5 server-side
610
+ // strikes toward a DB lockout. Transport failures (fetch failed, ECONNREFUSED, …) are
611
+ // NOT verdicts — they carry no `reason` and are safe to retry. Pure + exported so the
612
+ // regression is unit-testable without a live vault.
613
+ export function isTerminalAuthVerdict(reason) {
614
+ if (!reason || typeof reason !== "string") return false;
615
+ return (
616
+ reason.includes("invalid_token") ||
617
+ reason.includes("machine_locked") ||
618
+ reason.includes("machine_disabled") ||
619
+ reason.includes("machine_not_found")
620
+ );
621
+ }
622
+
607
623
  function openBrowser(url) {
608
624
  try {
609
625
  const cmd = os.platform() === "win32" ? `start "" "${url}"`
@@ -1361,8 +1377,17 @@ async function unlock() {
1361
1377
  input.disabled = true;
1362
1378
  btn.disabled = true;
1363
1379
  btn.textContent = "Unlock";
1364
- sub.textContent = "Too many failed attempts";
1365
- err.textContent = "✗ Vault locked restart daemon to try again";
1380
+ if (r.terminal) {
1381
+ // Server rendered a terminal verdict (invalid_token / machine_locked).
1382
+ // Show the reason — this is the one-line signal that diagnoses the lockout.
1383
+ sub.textContent = r.reason ? ("Vault rejected: " + r.reason) : "Vault rejected credentials";
1384
+ err.textContent = "✗ Recovery required — unlock machine + re-seal boot.key (see runbook)";
1385
+ } else {
1386
+ sub.textContent = "Too many failed attempts";
1387
+ err.textContent = r.reason
1388
+ ? ("✗ Vault locked (" + r.reason + ") — restart daemon to try again")
1389
+ : "✗ Vault locked — restart daemon to try again";
1390
+ }
1366
1391
  return;
1367
1392
  }
1368
1393
 
@@ -5376,7 +5401,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5376
5401
  try {
5377
5402
  const { token, timestamp } = deriveToken(pw, machineHash);
5378
5403
  const result = await api.test(pw, machineHash, token, timestamp);
5379
- if (result.error) throw new Error(result.error);
5404
+ if (result.error) {
5405
+ // Preserve the server's `reason` (invalid_token (N/5), machine_locked, …)
5406
+ // so the catch block can surface it AND decide whether to keep retrying.
5407
+ const authError = new Error(result.error);
5408
+ authError.serverReason = result.reason || null;
5409
+ throw authError;
5410
+ }
5380
5411
  password = pw; // unlock — store in process memory only
5381
5412
  writeSession = makeWriteToken();
5382
5413
  authFailCount = 0;
@@ -5436,26 +5467,42 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
5436
5467
  return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
5437
5468
  } catch (authErr) {
5438
5469
  const msg = authErr.message || "";
5470
+ const serverReason = authErr.serverReason || null;
5439
5471
  const isTransport = msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("ENOTFOUND") || msg.includes("network");
5440
5472
  if (isTransport) {
5473
+ // Transport failure — the vault never rendered a verdict. Safe to retry; no strike.
5441
5474
  const failLog = `[${new Date().toISOString()}] Vault unreachable: ${msg}\n`;
5442
5475
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
5443
5476
  res.writeHead(503, { "Content-Type": "application/json", ...CORS });
5444
5477
  return res.end(JSON.stringify({ error: "Vault backend is unreachable — try again in a moment", transport_error: true, detail: msg }));
5445
5478
  }
5479
+ // Server rendered a verdict. Surface its `reason` so the CLI/log shows
5480
+ // WHY (invalid_token (N/5) vs machine_locked) instead of a bare "auth_failed".
5481
+ const reasonSuffix = serverReason ? ` (${serverReason})` : "";
5482
+ // STOP-ON-REJECTION: a server-side machine lock or invalid_token is terminal —
5483
+ // each retry only burns another of the 5 server-side strikes toward a DB lockout.
5484
+ // Hard-lock locally NOW so the browser/CLI cannot keep hammering the vault.
5485
+ const isTerminalVerdict = isTerminalAuthVerdict(serverReason);
5446
5486
  authFailCount++;
5447
5487
  const authRemaining = MAX_AUTH_FAILS - authFailCount;
5448
- const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}\n`;
5488
+ const failLog = `[${new Date().toISOString()}] [AUTH FAIL ${authFailCount}/${MAX_AUTH_FAILS}] ${msg || "Wrong password"}${reasonSuffix}\n`;
5449
5489
  try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
5490
+ if (isTerminalVerdict) {
5491
+ authHardLocked = true;
5492
+ const lockLog = `[${new Date().toISOString()}] Server rejected with terminal verdict${reasonSuffix} — hard-locking locally to stop strike accrual; recover via the runbook (unlock machine + re-seal boot.key)\n`;
5493
+ try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
5494
+ res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5495
+ return res.end(JSON.stringify({ error: "Vault rejected credentials — recovery required", reason: serverReason, hard_locked: true, terminal: true }));
5496
+ }
5450
5497
  if (authFailCount >= MAX_AUTH_FAILS) {
5451
5498
  authHardLocked = true;
5452
5499
  const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
5453
5500
  try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
5454
5501
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5455
- return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", hard_locked: true }));
5502
+ return res.end(JSON.stringify({ error: "Too many failed attempts — restart daemon to try again", reason: serverReason, hard_locked: true }));
5456
5503
  }
5457
5504
  res.writeHead(401, { "Content-Type": "application/json", ...CORS });
5458
- return res.end(JSON.stringify({ error: "Invalid password", failures_remaining: authRemaining }));
5505
+ return res.end(JSON.stringify({ error: "Invalid password", reason: serverReason, failures_remaining: authRemaining }));
5459
5506
  }
5460
5507
  }
5461
5508
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "1.9.2",
3
+ "version": "1.9.3",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "bash scripts/build.sh",
11
+ "test": "node test-auth-verdict.mjs",
11
12
  "postinstall": "node scripts/postinstall.js",
12
13
  "worker:start": "node cli/index.js serve",
13
14
  "worker:stop": "curl -s http://127.0.0.1:52437/shutdown 2>nul || taskkill /F /IM cloudflared.exe 2>nul & exit 0",