@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.
- package/cli/commands/serve.js +53 -6
- package/package.json +2 -1
package/cli/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
1365
|
-
|
|
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)
|
|
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.
|
|
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",
|