@lifeaitools/clauth 1.9.1 → 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
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}"`
|
|
@@ -1148,44 +1164,93 @@ const OAUTH_IMPORT = {
|
|
|
1148
1164
|
}
|
|
1149
1165
|
};
|
|
1150
1166
|
|
|
1151
|
-
function
|
|
1167
|
+
function serviceDomId(name) {
|
|
1168
|
+
return encodeURIComponent(String(name)).replace(/%/g, "_");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function htmlEscape(value) {
|
|
1172
|
+
return String(value ?? "")
|
|
1173
|
+
.replace(/&/g, "&")
|
|
1174
|
+
.replace(/</g, "<")
|
|
1175
|
+
.replace(/>/g, ">")
|
|
1176
|
+
.replace(/"/g, """)
|
|
1177
|
+
.replace(/'/g, "'");
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function jsArg(value) {
|
|
1181
|
+
return JSON.stringify(String(value ?? ""))
|
|
1182
|
+
.replace(/</g, "\\u003c")
|
|
1183
|
+
.replace(/>/g, "\\u003e")
|
|
1184
|
+
.replace(/&/g, "\\u0026")
|
|
1185
|
+
.replace(/'/g, "\\u0027");
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function renderSetPanel(serviceOrName) {
|
|
1189
|
+
const service = typeof serviceOrName === "object" && serviceOrName ? serviceOrName : { name: serviceOrName };
|
|
1190
|
+
const name = service.name;
|
|
1191
|
+
const keyType = String(service.key_type || "").toLowerCase();
|
|
1192
|
+
const id = serviceDomId(name);
|
|
1193
|
+
const arg = jsArg(name);
|
|
1194
|
+
const displayName = htmlEscape(name);
|
|
1152
1195
|
const imp = OAUTH_IMPORT[name];
|
|
1153
1196
|
if (imp) {
|
|
1154
1197
|
const extraHtml = imp.extra.map(f => \`
|
|
1155
1198
|
<div class="oauth-field">
|
|
1156
1199
|
<label class="oauth-label">\${f.label}</label>
|
|
1157
1200
|
\${f.hint ? \`<div class="oauth-hint">\${f.hint}</div>\` : ""}
|
|
1158
|
-
<input type="text" class="oauth-input" id="ofield-\${
|
|
1201
|
+
<input type="text" class="oauth-input" id="ofield-\${id}-\${f.key}" placeholder="Paste \${htmlEscape(f.label)}…" spellcheck="false" autocomplete="off">
|
|
1159
1202
|
</div>
|
|
1160
1203
|
\`).join("");
|
|
1161
1204
|
return \`
|
|
1162
|
-
<div class="set-panel" id="set-panel-\${
|
|
1163
|
-
<label>Set <strong>\${
|
|
1205
|
+
<div class="set-panel" id="set-panel-\${id}">
|
|
1206
|
+
<label>Set <strong>\${displayName}</strong> credentials — paste directly from Google, never in chat</label>
|
|
1164
1207
|
<div class="oauth-fields">
|
|
1165
1208
|
<div class="oauth-field">
|
|
1166
1209
|
<label class="oauth-label">OAuth JSON from Google Cloud Console</label>
|
|
1167
1210
|
<div class="oauth-hint">Download from APIs & Services → Credentials → your OAuth client → ↓ Download JSON</div>
|
|
1168
|
-
<textarea class="set-input" id="ofield-\${
|
|
1211
|
+
<textarea class="set-input" id="ofield-\${id}-json" placeholder='{"installed":{"client_id":"…","client_secret":"…",...}}' spellcheck="false" rows="3"></textarea>
|
|
1169
1212
|
</div>
|
|
1170
1213
|
\${extraHtml}
|
|
1171
1214
|
</div>
|
|
1172
1215
|
<div class="set-foot">
|
|
1173
|
-
<button class="btn btn-save" onclick=
|
|
1174
|
-
<button class="btn btn-cancel" onclick=
|
|
1175
|
-
<span class="set-msg" id="set-msg-\${
|
|
1216
|
+
<button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
|
|
1217
|
+
<button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
|
|
1218
|
+
<span class="set-msg" id="set-msg-\${id}"></span>
|
|
1219
|
+
</div>
|
|
1220
|
+
</div>
|
|
1221
|
+
\`;
|
|
1222
|
+
}
|
|
1223
|
+
if (keyType === "keypair") {
|
|
1224
|
+
return \`
|
|
1225
|
+
<div class="set-panel" id="set-panel-\${id}">
|
|
1226
|
+
<label>New user/pass pair for <strong>\${displayName}</strong> — paste here, never in chat</label>
|
|
1227
|
+
<div class="oauth-fields">
|
|
1228
|
+
<div class="oauth-field">
|
|
1229
|
+
<label class="oauth-label">User / Key</label>
|
|
1230
|
+
<input type="text" class="oauth-input" id="kp-key-\${id}" placeholder="Username, key id, access key id…" spellcheck="false" autocomplete="off">
|
|
1231
|
+
</div>
|
|
1232
|
+
<div class="oauth-field">
|
|
1233
|
+
<label class="oauth-label">Password / Secret</label>
|
|
1234
|
+
<textarea class="set-input" id="kp-value-\${id}" placeholder="Password, API key, private key, secret…" spellcheck="false" rows="3"></textarea>
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
<div class="set-foot">
|
|
1238
|
+
<button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
|
|
1239
|
+
<button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
|
|
1240
|
+
<span class="set-msg" id="set-msg-\${id}"></span>
|
|
1176
1241
|
</div>
|
|
1177
1242
|
</div>
|
|
1178
1243
|
\`;
|
|
1179
1244
|
}
|
|
1180
1245
|
return \`
|
|
1181
|
-
<div class="set-panel" id="set-panel-\${
|
|
1182
|
-
<label>New value for <strong>\${
|
|
1183
|
-
<textarea class="set-input" id="set-input-\${
|
|
1184
|
-
\${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${SERVICE_HINTS[name]}</div>\` : ""}
|
|
1246
|
+
<div class="set-panel" id="set-panel-\${id}">
|
|
1247
|
+
<label>New value for <strong>\${displayName}</strong> — paste here, never in chat</label>
|
|
1248
|
+
<textarea class="set-input" id="set-input-\${id}" placeholder="\${htmlEscape(SERVICE_HINTS[name] || "Paste credential…")}" spellcheck="false"></textarea>
|
|
1249
|
+
\${SERVICE_HINTS[name] ? \`<div style="font-size:.72rem;color:#475569;margin-top:4px;font-family:'Courier New',monospace">\${htmlEscape(SERVICE_HINTS[name])}</div>\` : ""}
|
|
1185
1250
|
<div class="set-foot">
|
|
1186
|
-
<button class="btn btn-save" onclick=
|
|
1187
|
-
<button class="btn btn-cancel" onclick=
|
|
1188
|
-
<span class="set-msg" id="set-msg-\${
|
|
1251
|
+
<button class="btn btn-save" type="button" onclick='saveKey(\${arg})'>Save</button>
|
|
1252
|
+
<button class="btn btn-cancel" type="button" onclick='toggleSet(\${arg})'>Cancel</button>
|
|
1253
|
+
<span class="set-msg" id="set-msg-\${id}"></span>
|
|
1189
1254
|
</div>
|
|
1190
1255
|
</div>
|
|
1191
1256
|
\`;
|
|
@@ -1312,8 +1377,17 @@ async function unlock() {
|
|
|
1312
1377
|
input.disabled = true;
|
|
1313
1378
|
btn.disabled = true;
|
|
1314
1379
|
btn.textContent = "Unlock";
|
|
1315
|
-
|
|
1316
|
-
|
|
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
|
+
}
|
|
1317
1391
|
return;
|
|
1318
1392
|
}
|
|
1319
1393
|
|
|
@@ -1626,60 +1700,64 @@ function renderServiceGrid(services) {
|
|
|
1626
1700
|
: '<p class="loading">No services in this group.</p>';
|
|
1627
1701
|
return;
|
|
1628
1702
|
}
|
|
1629
|
-
grid.innerHTML = filtered.map(s =>
|
|
1703
|
+
grid.innerHTML = filtered.map(s => {
|
|
1704
|
+
const id = serviceDomId(s.name);
|
|
1705
|
+
const nameArg = jsArg(s.name);
|
|
1706
|
+
return \`
|
|
1630
1707
|
<div class="card">
|
|
1631
1708
|
<div style="display:flex;align-items:flex-start;justify-content:space-between">
|
|
1632
1709
|
<div style="flex:1;min-width:0">
|
|
1633
1710
|
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap">
|
|
1634
|
-
<span class="card-name" id="label-display-\${
|
|
1635
|
-
<code style="font-family:'Courier New',monospace;font-size:.75rem;color:#64748b;background:rgba(100,116,139,.1);padding:1px 6px;border-radius:3px;letter-spacing:.3px">\${s.name}</code>
|
|
1711
|
+
<span class="card-name" id="label-display-\${id}">\${htmlEscape(s.label && s.label !== s.name ? s.label : s.name)}</span>
|
|
1712
|
+
<code style="font-family:'Courier New',monospace;font-size:.75rem;color:#64748b;background:rgba(100,116,139,.1);padding:1px 6px;border-radius:3px;letter-spacing:.3px">\${htmlEscape(s.name)}</code>
|
|
1636
1713
|
</div>
|
|
1637
1714
|
<div style="display:flex;align-items:center;gap:6px;margin-top:2px">
|
|
1638
|
-
<div class="card-type">\${s.key_type || "secret"}</div>
|
|
1639
|
-
<span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${
|
|
1640
|
-
<span class="expiry-badge" id="expiry-\${
|
|
1641
|
-
\${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${s.project}</span>\` : ""}
|
|
1715
|
+
<div class="card-type">\${htmlEscape(s.key_type || "secret")}</div>
|
|
1716
|
+
<span class="svc-badge \${s.enabled === false ? "off" : "on"}" id="badge-\${id}">\${s.enabled === false ? "disabled" : "enabled"}</span>
|
|
1717
|
+
<span class="expiry-badge" id="expiry-\${id}" style="font-size:.65rem;border-radius:3px;padding:1px 6px;display:none"></span>
|
|
1718
|
+
\${s.project ? \`<span style="font-size:.68rem;color:#3b82f6;background:rgba(59,130,246,.1);border:1px solid rgba(59,130,246,.2);border-radius:3px;padding:1px 6px">\${htmlEscape(s.project)}</span>\` : ""}
|
|
1642
1719
|
</div>
|
|
1643
|
-
\${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${s.description}</div>\` : ""}
|
|
1720
|
+
\${s.description ? \`<div style="font-size:.78rem;color:#64748b;margin-top:4px;line-height:1.3">\${htmlEscape(s.description)}</div>\` : ""}
|
|
1644
1721
|
\${KEY_URLS[s.name] ? \`<a class="card-getkey" href="\${KEY_URLS[s.name]}" target="_blank" rel="noopener">↗ Get / rotate key</a>\` : ""}
|
|
1645
|
-
\${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${l.label}</a>\`).join("")}
|
|
1722
|
+
\${(EXTRA_LINKS[s.name] || []).map(l => \`<a class="card-getkey" href="\${l.url}" target="_blank" rel="noopener" style="margin-left:0">\${htmlEscape(l.label)}</a>\`).join("")}
|
|
1646
1723
|
</div>
|
|
1647
|
-
<div class="status-dot" id="sdot-\${
|
|
1724
|
+
<div class="status-dot" id="sdot-\${id}" title=""></div>
|
|
1648
1725
|
</div>
|
|
1649
|
-
<div class="card-value" id="val-\${
|
|
1726
|
+
<div class="card-value" id="val-\${id}"></div>
|
|
1650
1727
|
<div class="card-actions">
|
|
1651
|
-
<button class="btn btn-reveal" onclick=
|
|
1652
|
-
<button class="btn btn-copy" id="copybtn-\${
|
|
1653
|
-
<button class="btn btn-set" onclick=
|
|
1654
|
-
<button class="btn-project" onclick=
|
|
1655
|
-
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${
|
|
1656
|
-
<button class="btn-rotate" id="rotbtn-\${
|
|
1657
|
-
<button class="btn-rename" onclick=
|
|
1658
|
-
<button class="btn-delete" onclick=
|
|
1728
|
+
<button class="btn btn-reveal" type="button" onclick='reveal(\${nameArg}, this)'>Reveal</button>
|
|
1729
|
+
<button class="btn btn-copy" id="copybtn-\${id}" style="display:none" type="button" onclick='copyKey(\${nameArg})'>Copy</button>
|
|
1730
|
+
<button class="btn btn-set" type="button" onclick='toggleSet(\${nameArg})'>Set</button>
|
|
1731
|
+
<button class="btn-project" type="button" onclick='toggleProjectEdit(\${nameArg})'>\${s.project ? "✎ Project" : "+ Project"}</button>
|
|
1732
|
+
<button class="btn \${s.enabled === false ? "btn-enable" : "btn-disable"}" id="togbtn-\${id}" type="button" onclick='toggleService(\${nameArg})'>\${s.enabled === false ? "Enable" : "Disable"}</button>
|
|
1733
|
+
<button class="btn-rotate" id="rotbtn-\${id}" style="display:none;background:#0e7490;border:1px solid #06b6d4;color:#cffafe;font-size:.75rem;padding:3px 8px;border-radius:4px;cursor:pointer" type="button" onclick='rotateKey(\${nameArg})'>↻ Rotate</button>
|
|
1734
|
+
<button class="btn-rename" type="button" onclick='toggleLabelEdit(\${nameArg})' title="Edit display label">✏️</button>
|
|
1735
|
+
<button class="btn-delete" type="button" onclick='deleteService(\${nameArg})' title="Delete service">✕</button>
|
|
1659
1736
|
</div>
|
|
1660
|
-
<div class="rename-panel" id="rn-\${
|
|
1737
|
+
<div class="rename-panel" id="rn-\${id}">
|
|
1661
1738
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
|
1662
1739
|
<span style="font-size:.72rem;color:#64748b">Edit label for</span>
|
|
1663
|
-
<code style="font-family:'Courier New',monospace;font-size:.75rem;color:#94a3b8;background:rgba(100,116,139,.1);padding:1px 5px;border-radius:3px">\${s.name}</code>
|
|
1740
|
+
<code style="font-family:'Courier New',monospace;font-size:.75rem;color:#94a3b8;background:rgba(100,116,139,.1);padding:1px 5px;border-radius:3px">\${htmlEscape(s.name)}</code>
|
|
1664
1741
|
<span style="font-size:.68rem;color:#475569">(slug unchanged)</span>
|
|
1665
1742
|
</div>
|
|
1666
1743
|
<div style="display:flex;gap:6px;align-items:center">
|
|
1667
|
-
<input class="rename-input" id="rn-input-\${
|
|
1668
|
-
onkeydown=
|
|
1669
|
-
<button class="btn" onclick=
|
|
1670
|
-
<button class="btn" onclick=
|
|
1671
|
-
<span class="rename-msg" id="rn-msg-\${
|
|
1744
|
+
<input class="rename-input" id="rn-input-\${id}" value="\${htmlEscape(s.label || s.name)}" spellcheck="false" autocomplete="off" placeholder="Human-readable label…"
|
|
1745
|
+
onkeydown='if(event.key==="Enter")saveLabel(\${nameArg});if(event.key==="Escape")toggleLabelEdit(\${nameArg})'>
|
|
1746
|
+
<button class="btn" type="button" onclick='saveLabel(\${nameArg})' style="padding:4px 10px;font-size:.8rem">Save</button>
|
|
1747
|
+
<button class="btn" type="button" onclick='toggleLabelEdit(\${nameArg})' style="padding:4px 10px;font-size:.8rem;background:#1e293b">Cancel</button>
|
|
1748
|
+
<span class="rename-msg" id="rn-msg-\${id}"></span>
|
|
1672
1749
|
</div>
|
|
1673
1750
|
</div>
|
|
1674
|
-
<div class="project-edit" id="pe-\${
|
|
1675
|
-
<input type="text" id="pe-input-\${
|
|
1676
|
-
<button onclick=
|
|
1677
|
-
<button onclick=
|
|
1678
|
-
<span class="pe-msg" id="pe-msg-\${
|
|
1751
|
+
<div class="project-edit" id="pe-\${id}">
|
|
1752
|
+
<input type="text" id="pe-input-\${id}" value="\${htmlEscape(s.project || "")}" placeholder="Project name…" spellcheck="false" autocomplete="off">
|
|
1753
|
+
<button type="button" onclick='saveProject(\${nameArg})'>Save</button>
|
|
1754
|
+
<button type="button" onclick='clearProject(\${nameArg})'>Clear</button>
|
|
1755
|
+
<span class="pe-msg" id="pe-msg-\${id}"></span>
|
|
1679
1756
|
</div>
|
|
1680
|
-
\${renderSetPanel(s
|
|
1757
|
+
\${renderSetPanel(s)}
|
|
1681
1758
|
</div>
|
|
1682
|
-
|
|
1759
|
+
\`;
|
|
1760
|
+
}).join("");
|
|
1683
1761
|
}
|
|
1684
1762
|
|
|
1685
1763
|
async function loadServices() {
|
|
@@ -1733,8 +1811,9 @@ async function loadExpiry() {
|
|
|
1733
1811
|
const data = await fetch(BASE + "/expiry").then(r => r.json());
|
|
1734
1812
|
if (!data.services) return;
|
|
1735
1813
|
for (const [name, info] of Object.entries(data.services)) {
|
|
1736
|
-
const
|
|
1737
|
-
const
|
|
1814
|
+
const id = serviceDomId(name);
|
|
1815
|
+
const el = document.getElementById("expiry-" + id);
|
|
1816
|
+
const rotBtn = document.getElementById("rotbtn-" + id);
|
|
1738
1817
|
if (!el) continue;
|
|
1739
1818
|
|
|
1740
1819
|
// Show rotate button for auto-capable services
|
|
@@ -1780,10 +1859,10 @@ async function loadExpiry() {
|
|
|
1780
1859
|
|
|
1781
1860
|
async function rotateKey(name) {
|
|
1782
1861
|
if (!confirm("Rotate " + name + " key?\\n\\nA new key will be created and the old one deleted.")) return;
|
|
1783
|
-
const rotBtn = document.getElementById("rotbtn-" + name);
|
|
1862
|
+
const rotBtn = document.getElementById("rotbtn-" + serviceDomId(name));
|
|
1784
1863
|
if (rotBtn) { rotBtn.disabled = true; rotBtn.textContent = "rotating…"; }
|
|
1785
1864
|
try {
|
|
1786
|
-
const r = await fetch(BASE + "/rotate/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1865
|
+
const r = await fetch(BASE + "/rotate/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1787
1866
|
if (r.ok) {
|
|
1788
1867
|
if (rotBtn) { rotBtn.textContent = "✓ rotated"; rotBtn.style.background = "#166534"; }
|
|
1789
1868
|
loadExpiry(); // refresh badges
|
|
@@ -1802,7 +1881,7 @@ async function setExpiry(name) {
|
|
|
1802
1881
|
if (!days) return;
|
|
1803
1882
|
const expiresAt = new Date(Date.now() + parseInt(days) * 86400000).toISOString();
|
|
1804
1883
|
try {
|
|
1805
|
-
await fetch(BASE + "/set-expiry/" + name, {
|
|
1884
|
+
await fetch(BASE + "/set-expiry/" + encodeURIComponent(name), {
|
|
1806
1885
|
method: "POST",
|
|
1807
1886
|
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
1808
1887
|
body: JSON.stringify({ expires_at: expiresAt, rotation_days: parseInt(days) }),
|
|
@@ -1813,15 +1892,17 @@ async function setExpiry(name) {
|
|
|
1813
1892
|
|
|
1814
1893
|
// ── Reveal ──────────────────────────────────
|
|
1815
1894
|
async function reveal(name, btn) {
|
|
1816
|
-
const
|
|
1817
|
-
const
|
|
1895
|
+
const id = serviceDomId(name);
|
|
1896
|
+
const valEl = document.getElementById("val-" + id);
|
|
1897
|
+
const copyBtn = document.getElementById("copybtn-" + id);
|
|
1898
|
+
if (!valEl || !copyBtn) return;
|
|
1818
1899
|
if (valEl.style.display === "block") {
|
|
1819
1900
|
valEl.style.display = "none"; copyBtn.style.display = "none";
|
|
1820
1901
|
btn.textContent = "Reveal"; return;
|
|
1821
1902
|
}
|
|
1822
1903
|
valEl.textContent = "fetching…"; valEl.style.display = "block"; btn.textContent = "Hide";
|
|
1823
1904
|
try {
|
|
1824
|
-
const r = await fetch(BASE + "/get/" + name).then(r => r.json());
|
|
1905
|
+
const r = await fetch(BASE + "/get/" + encodeURIComponent(name)).then(r => r.json());
|
|
1825
1906
|
if (r.locked) { showLockScreen(); return; }
|
|
1826
1907
|
if (r.error) throw new Error(r.error);
|
|
1827
1908
|
const imp = OAUTH_IMPORT[name];
|
|
@@ -1843,30 +1924,34 @@ async function reveal(name, btn) {
|
|
|
1843
1924
|
}
|
|
1844
1925
|
|
|
1845
1926
|
async function copyKey(name) {
|
|
1846
|
-
const
|
|
1927
|
+
const id = serviceDomId(name);
|
|
1928
|
+
const val = document.getElementById("val-" + id).textContent;
|
|
1847
1929
|
try {
|
|
1848
1930
|
await navigator.clipboard.writeText(val);
|
|
1849
|
-
const btn = document.getElementById("copybtn-" +
|
|
1931
|
+
const btn = document.getElementById("copybtn-" + id);
|
|
1850
1932
|
btn.textContent = "Copied!"; setTimeout(() => btn.textContent = "Copy", 1500);
|
|
1851
1933
|
} catch {}
|
|
1852
1934
|
}
|
|
1853
1935
|
|
|
1854
1936
|
// ── Project edit ────────────────────────────
|
|
1855
1937
|
function toggleProjectEdit(name) {
|
|
1856
|
-
const
|
|
1938
|
+
const id = serviceDomId(name);
|
|
1939
|
+
const panel = document.getElementById("pe-" + id);
|
|
1940
|
+
if (!panel) return;
|
|
1857
1941
|
const open = panel.classList.contains("open");
|
|
1858
1942
|
panel.classList.toggle("open");
|
|
1859
1943
|
if (!open) {
|
|
1860
1944
|
const svc = allServices.find(s => s.name === name);
|
|
1861
|
-
document.getElementById("pe-input-" +
|
|
1862
|
-
document.getElementById("pe-msg-" +
|
|
1863
|
-
document.getElementById("pe-input-" +
|
|
1945
|
+
document.getElementById("pe-input-" + id).value = svc ? (svc.project || "") : "";
|
|
1946
|
+
document.getElementById("pe-msg-" + id).textContent = "";
|
|
1947
|
+
document.getElementById("pe-input-" + id).focus();
|
|
1864
1948
|
}
|
|
1865
1949
|
}
|
|
1866
1950
|
|
|
1867
1951
|
async function saveProject(name) {
|
|
1868
|
-
const
|
|
1869
|
-
const
|
|
1952
|
+
const id = serviceDomId(name);
|
|
1953
|
+
const input = document.getElementById("pe-input-" + id);
|
|
1954
|
+
const msg = document.getElementById("pe-msg-" + id);
|
|
1870
1955
|
const project = input.value.trim();
|
|
1871
1956
|
msg.textContent = "Saving…"; msg.style.color = "#94a3b8";
|
|
1872
1957
|
try {
|
|
@@ -1888,27 +1973,30 @@ async function saveProject(name) {
|
|
|
1888
1973
|
}
|
|
1889
1974
|
|
|
1890
1975
|
async function clearProject(name) {
|
|
1891
|
-
document.getElementById("pe-input-" + name).value = "";
|
|
1976
|
+
document.getElementById("pe-input-" + serviceDomId(name)).value = "";
|
|
1892
1977
|
await saveProject(name);
|
|
1893
1978
|
}
|
|
1894
1979
|
|
|
1895
1980
|
// ── Rename service ───────────────────────────
|
|
1896
1981
|
function toggleLabelEdit(name) {
|
|
1897
|
-
const
|
|
1982
|
+
const id = serviceDomId(name);
|
|
1983
|
+
const panel = document.getElementById("rn-" + id);
|
|
1984
|
+
if (!panel) return;
|
|
1898
1985
|
const open = panel.style.display === "block";
|
|
1899
1986
|
panel.style.display = open ? "none" : "block";
|
|
1900
1987
|
if (!open) {
|
|
1901
|
-
const inp = document.getElementById("rn-input-" +
|
|
1988
|
+
const inp = document.getElementById("rn-input-" + id);
|
|
1902
1989
|
const svc = allServices.find(s => s.name === name);
|
|
1903
1990
|
inp.value = (svc && svc.label) ? svc.label : name;
|
|
1904
1991
|
inp.focus(); inp.select();
|
|
1905
|
-
document.getElementById("rn-msg-" +
|
|
1992
|
+
document.getElementById("rn-msg-" + id).textContent = "";
|
|
1906
1993
|
}
|
|
1907
1994
|
}
|
|
1908
1995
|
|
|
1909
1996
|
async function saveLabel(name) {
|
|
1910
|
-
const
|
|
1911
|
-
const
|
|
1997
|
+
const id = serviceDomId(name);
|
|
1998
|
+
const inp = document.getElementById("rn-input-" + id);
|
|
1999
|
+
const msg = document.getElementById("rn-msg-" + id);
|
|
1912
2000
|
const newLabel = inp.value.trim();
|
|
1913
2001
|
if (!newLabel) { toggleLabelEdit(name); return; }
|
|
1914
2002
|
const svc = allServices.find(s => s.name === name);
|
|
@@ -1925,7 +2013,7 @@ async function saveLabel(name) {
|
|
|
1925
2013
|
msg.style.color = "#4ade80"; msg.textContent = "✓ Label updated";
|
|
1926
2014
|
// Update in-memory and DOM immediately
|
|
1927
2015
|
if (svc) svc.label = newLabel;
|
|
1928
|
-
const labelEl = document.getElementById("label-display-" +
|
|
2016
|
+
const labelEl = document.getElementById("label-display-" + id);
|
|
1929
2017
|
if (labelEl) labelEl.textContent = newLabel;
|
|
1930
2018
|
setTimeout(() => { toggleLabelEdit(name); }, 800);
|
|
1931
2019
|
} catch (e) {
|
|
@@ -1941,7 +2029,7 @@ async function saveRename(oldName) { await saveLabel(oldName); }
|
|
|
1941
2029
|
async function deleteService(name) {
|
|
1942
2030
|
if (!confirm(\`Delete service "\${name}"? This cannot be undone.\`)) return;
|
|
1943
2031
|
try {
|
|
1944
|
-
const r = await fetch(BASE + "/delete/" + name, { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
2032
|
+
const r = await fetch(BASE + "/delete/" + encodeURIComponent(name), { method: "POST", headers: writeHeaders() }).then(r => r.json());
|
|
1945
2033
|
if (r.locked) { showLockScreen(false); return; }
|
|
1946
2034
|
if (r.error) throw new Error(r.error);
|
|
1947
2035
|
loadServices();
|
|
@@ -1952,31 +2040,46 @@ async function deleteService(name) {
|
|
|
1952
2040
|
|
|
1953
2041
|
// ── Set key ─────────────────────────────────
|
|
1954
2042
|
function toggleSet(name) {
|
|
1955
|
-
const
|
|
1956
|
-
const
|
|
2043
|
+
const id = serviceDomId(name);
|
|
2044
|
+
const panel = document.getElementById("set-panel-" + id);
|
|
2045
|
+
const msg = document.getElementById("set-msg-" + id);
|
|
2046
|
+
if (!panel) return;
|
|
1957
2047
|
const open = panel.style.display === "block";
|
|
1958
2048
|
panel.style.display = open ? "none" : "block";
|
|
1959
2049
|
if (!open) {
|
|
1960
2050
|
if (msg) msg.textContent = "";
|
|
1961
2051
|
const imp = OAUTH_IMPORT[name];
|
|
1962
2052
|
if (imp) {
|
|
1963
|
-
const jsonEl = document.getElementById("ofield-" +
|
|
2053
|
+
const jsonEl = document.getElementById("ofield-" + id + "-json");
|
|
1964
2054
|
if (jsonEl) { jsonEl.value = ""; jsonEl.focus(); }
|
|
1965
|
-
imp.extra.forEach(f => { const el = document.getElementById("ofield-" +
|
|
2055
|
+
imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
|
|
2056
|
+
} else if (getServiceKeyType(name) === "keypair") {
|
|
2057
|
+
const keyEl = document.getElementById("kp-key-" + id);
|
|
2058
|
+
const valueEl = document.getElementById("kp-value-" + id);
|
|
2059
|
+
if (keyEl) { keyEl.value = ""; keyEl.focus(); }
|
|
2060
|
+
if (valueEl) valueEl.value = "";
|
|
1966
2061
|
} else {
|
|
1967
|
-
const input = document.getElementById("set-input-" +
|
|
2062
|
+
const input = document.getElementById("set-input-" + id);
|
|
1968
2063
|
if (input) { input.value = ""; input.focus(); }
|
|
1969
2064
|
}
|
|
1970
2065
|
}
|
|
1971
2066
|
}
|
|
1972
2067
|
|
|
2068
|
+
function getServiceKeyType(name) {
|
|
2069
|
+
const svc = allServices.find(s => s.name === name);
|
|
2070
|
+
return String(svc?.key_type || "").toLowerCase();
|
|
2071
|
+
}
|
|
2072
|
+
|
|
1973
2073
|
async function saveKey(name) {
|
|
1974
|
-
const
|
|
2074
|
+
const id = serviceDomId(name);
|
|
2075
|
+
const msg = document.getElementById("set-msg-" + id);
|
|
2076
|
+
if (!msg) return;
|
|
1975
2077
|
const imp = OAUTH_IMPORT[name];
|
|
2078
|
+
const keyType = getServiceKeyType(name);
|
|
1976
2079
|
let value;
|
|
1977
2080
|
|
|
1978
2081
|
if (imp) {
|
|
1979
|
-
const jsonEl = document.getElementById("ofield-" +
|
|
2082
|
+
const jsonEl = document.getElementById("ofield-" + id + "-json");
|
|
1980
2083
|
const raw = jsonEl ? jsonEl.value.trim() : "";
|
|
1981
2084
|
if (!raw) { msg.className = "set-msg fail"; msg.textContent = "Paste the OAuth JSON first."; return; }
|
|
1982
2085
|
let parsed;
|
|
@@ -1989,21 +2092,29 @@ async function saveKey(name) {
|
|
|
1989
2092
|
obj[k] = src[k];
|
|
1990
2093
|
}
|
|
1991
2094
|
for (const f of imp.extra) {
|
|
1992
|
-
const el = document.getElementById("ofield-" +
|
|
2095
|
+
const el = document.getElementById("ofield-" + id + "-" + f.key);
|
|
1993
2096
|
const v = el ? el.value.trim() : "";
|
|
1994
2097
|
if (!v) { msg.className = "set-msg fail"; msg.textContent = f.label + " is required."; return; }
|
|
1995
2098
|
obj[f.key] = v;
|
|
1996
2099
|
}
|
|
1997
2100
|
value = JSON.stringify(obj);
|
|
2101
|
+
} else if (keyType === "keypair") {
|
|
2102
|
+
const keyEl = document.getElementById("kp-key-" + id);
|
|
2103
|
+
const valueEl = document.getElementById("kp-value-" + id);
|
|
2104
|
+
const user = keyEl ? keyEl.value.trim() : "";
|
|
2105
|
+
const pass = valueEl ? valueEl.value : "";
|
|
2106
|
+
if (!user) { msg.className = "set-msg fail"; msg.textContent = "User / key is required."; return; }
|
|
2107
|
+
if (!pass.trim()) { msg.className = "set-msg fail"; msg.textContent = "Password / secret is required."; return; }
|
|
2108
|
+
value = JSON.stringify({ user, pass, username: user, password: pass, key: user, value: pass });
|
|
1998
2109
|
} else {
|
|
1999
|
-
const input = document.getElementById("set-input-" +
|
|
2110
|
+
const input = document.getElementById("set-input-" + id);
|
|
2000
2111
|
value = input ? input.value : "";
|
|
2001
2112
|
if (!value.trim()) { msg.className = "set-msg fail"; msg.textContent = "Value is empty."; return; }
|
|
2002
2113
|
}
|
|
2003
2114
|
|
|
2004
2115
|
msg.className = "set-msg"; msg.textContent = "Saving…";
|
|
2005
2116
|
try {
|
|
2006
|
-
const r = await fetch(BASE + "/set/" + name, {
|
|
2117
|
+
const r = await fetch(BASE + "/set/" + encodeURIComponent(name), {
|
|
2007
2118
|
method: "POST",
|
|
2008
2119
|
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
2009
2120
|
body: JSON.stringify({ value })
|
|
@@ -2013,17 +2124,23 @@ async function saveKey(name) {
|
|
|
2013
2124
|
if (r.error) throw new Error(r.error);
|
|
2014
2125
|
msg.className = "set-msg ok"; msg.textContent = "✓ Saved";
|
|
2015
2126
|
if (imp) {
|
|
2016
|
-
const jsonEl = document.getElementById("ofield-" +
|
|
2127
|
+
const jsonEl = document.getElementById("ofield-" + id + "-json");
|
|
2017
2128
|
if (jsonEl) jsonEl.value = "";
|
|
2018
|
-
imp.extra.forEach(f => { const el = document.getElementById("ofield-" +
|
|
2129
|
+
imp.extra.forEach(f => { const el = document.getElementById("ofield-" + id + "-" + f.key); if (el) el.value = ""; });
|
|
2130
|
+
} else if (keyType === "keypair") {
|
|
2131
|
+
const keyEl = document.getElementById("kp-key-" + id);
|
|
2132
|
+
const valueEl = document.getElementById("kp-value-" + id);
|
|
2133
|
+
if (keyEl) keyEl.value = "";
|
|
2134
|
+
if (valueEl) valueEl.value = "";
|
|
2019
2135
|
} else {
|
|
2020
|
-
const inp = document.getElementById("set-input-" +
|
|
2136
|
+
const inp = document.getElementById("set-input-" + id);
|
|
2021
2137
|
if (inp) inp.value = "";
|
|
2022
2138
|
}
|
|
2023
|
-
const dot = document.getElementById("sdot-" +
|
|
2139
|
+
const dot = document.getElementById("sdot-" + id);
|
|
2024
2140
|
if (dot) { dot.className = "status-dot"; dot.title = ""; }
|
|
2025
2141
|
setTimeout(() => {
|
|
2026
|
-
document.getElementById("set-panel-" +
|
|
2142
|
+
const panel = document.getElementById("set-panel-" + id);
|
|
2143
|
+
if (panel) panel.style.display = "none";
|
|
2027
2144
|
msg.textContent = "";
|
|
2028
2145
|
}, 1800);
|
|
2029
2146
|
} catch (e) {
|
|
@@ -2033,15 +2150,17 @@ async function saveKey(name) {
|
|
|
2033
2150
|
|
|
2034
2151
|
// ── Enable / Disable service ────────────────
|
|
2035
2152
|
async function toggleService(name) {
|
|
2036
|
-
const
|
|
2037
|
-
const
|
|
2153
|
+
const id = serviceDomId(name);
|
|
2154
|
+
const badge = document.getElementById("badge-" + id);
|
|
2155
|
+
const btn = document.getElementById("togbtn-" + id);
|
|
2156
|
+
if (!badge || !btn) return;
|
|
2038
2157
|
const currently = badge.classList.contains("on");
|
|
2039
2158
|
const newState = !currently;
|
|
2040
2159
|
|
|
2041
2160
|
btn.disabled = true; btn.textContent = "…";
|
|
2042
2161
|
|
|
2043
2162
|
try {
|
|
2044
|
-
const r = await fetch(BASE + "/toggle/" + name, {
|
|
2163
|
+
const r = await fetch(BASE + "/toggle/" + encodeURIComponent(name), {
|
|
2045
2164
|
method: "POST",
|
|
2046
2165
|
headers: writeHeaders({ "Content-Type": "application/json" }),
|
|
2047
2166
|
body: JSON.stringify({ enabled: newState })
|
|
@@ -2097,7 +2216,7 @@ async function checkAll() {
|
|
|
2097
2216
|
}
|
|
2098
2217
|
continue;
|
|
2099
2218
|
}
|
|
2100
|
-
const dot = document.getElementById("sdot-" + name);
|
|
2219
|
+
const dot = document.getElementById("sdot-" + serviceDomId(name));
|
|
2101
2220
|
if (!dot) continue;
|
|
2102
2221
|
if (result.ok) {
|
|
2103
2222
|
dot.className = "status-dot ok"; dot.title = "OK";
|
|
@@ -5282,7 +5401,13 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5282
5401
|
try {
|
|
5283
5402
|
const { token, timestamp } = deriveToken(pw, machineHash);
|
|
5284
5403
|
const result = await api.test(pw, machineHash, token, timestamp);
|
|
5285
|
-
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
|
+
}
|
|
5286
5411
|
password = pw; // unlock — store in process memory only
|
|
5287
5412
|
writeSession = makeWriteToken();
|
|
5288
5413
|
authFailCount = 0;
|
|
@@ -5342,26 +5467,42 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
|
|
|
5342
5467
|
return ok(res, { ok: true, locked: false, write_token: writeSession.token, write_expires_at: new Date(writeSession.expiresAt).toISOString() });
|
|
5343
5468
|
} catch (authErr) {
|
|
5344
5469
|
const msg = authErr.message || "";
|
|
5470
|
+
const serverReason = authErr.serverReason || null;
|
|
5345
5471
|
const isTransport = msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("ETIMEDOUT") || msg.includes("ENOTFOUND") || msg.includes("network");
|
|
5346
5472
|
if (isTransport) {
|
|
5473
|
+
// Transport failure — the vault never rendered a verdict. Safe to retry; no strike.
|
|
5347
5474
|
const failLog = `[${new Date().toISOString()}] Vault unreachable: ${msg}\n`;
|
|
5348
5475
|
try { fs.appendFileSync(LOG_FILE, failLog); } catch {}
|
|
5349
5476
|
res.writeHead(503, { "Content-Type": "application/json", ...CORS });
|
|
5350
5477
|
return res.end(JSON.stringify({ error: "Vault backend is unreachable — try again in a moment", transport_error: true, detail: msg }));
|
|
5351
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);
|
|
5352
5486
|
authFailCount++;
|
|
5353
5487
|
const authRemaining = MAX_AUTH_FAILS - authFailCount;
|
|
5354
|
-
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`;
|
|
5355
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
|
+
}
|
|
5356
5497
|
if (authFailCount >= MAX_AUTH_FAILS) {
|
|
5357
5498
|
authHardLocked = true;
|
|
5358
5499
|
const lockLog = `[${new Date().toISOString()}] Auth failure limit reached — vault hard-locked\n`;
|
|
5359
5500
|
try { fs.appendFileSync(LOG_FILE, lockLog); } catch {}
|
|
5360
5501
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
5361
|
-
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 }));
|
|
5362
5503
|
}
|
|
5363
5504
|
res.writeHead(401, { "Content-Type": "application/json", ...CORS });
|
|
5364
|
-
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 }));
|
|
5365
5506
|
}
|
|
5366
5507
|
}
|
|
5367
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -12,36 +12,36 @@ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
|
12
12
|
.split(",").map(s => s.trim()).filter(Boolean);
|
|
13
13
|
|
|
14
14
|
const RATE_LIMIT_MAX = 30;
|
|
15
|
-
const RATE_LIMIT_WINDOW = 60;
|
|
16
|
-
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
|
-
const MAX_FAIL_COUNT = 5;
|
|
18
|
-
const DEFAULT_INSTALL_ID = "default";
|
|
15
|
+
const RATE_LIMIT_WINDOW = 60;
|
|
16
|
+
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
|
|
17
|
+
const MAX_FAIL_COUNT = 5;
|
|
18
|
+
const DEFAULT_INSTALL_ID = "default";
|
|
19
19
|
|
|
20
|
-
async function hmacSha256(key: string, message: string): Promise<string> {
|
|
20
|
+
async function hmacSha256(key: string, message: string): Promise<string> {
|
|
21
21
|
const cryptoKey = await crypto.subtle.importKey(
|
|
22
22
|
"raw", new TextEncoder().encode(key),
|
|
23
23
|
{ name: "HMAC", hash: "SHA-256" }, false, ["sign"]
|
|
24
24
|
);
|
|
25
25
|
const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
|
|
26
26
|
return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function sha256Hex(value: string): Promise<string> {
|
|
30
|
-
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
31
|
-
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function randomToken(bytes = 24): string {
|
|
35
|
-
const data = new Uint8Array(bytes);
|
|
36
|
-
crypto.getRandomValues(data);
|
|
37
|
-
return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function normalizeInstallId(value: unknown): string {
|
|
41
|
-
const text = String(value || "").trim().toLowerCase();
|
|
42
|
-
const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
-
return normalized || DEFAULT_INSTALL_ID;
|
|
44
|
-
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function sha256Hex(value: string): Promise<string> {
|
|
30
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
|
|
31
|
+
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function randomToken(bytes = 24): string {
|
|
35
|
+
const data = new Uint8Array(bytes);
|
|
36
|
+
crypto.getRandomValues(data);
|
|
37
|
+
return Array.from(data).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeInstallId(value: unknown): string {
|
|
41
|
+
const text = String(value || "").trim().toLowerCase();
|
|
42
|
+
const normalized = text.replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
43
|
+
return normalized || DEFAULT_INSTALL_ID;
|
|
44
|
+
}
|
|
45
45
|
|
|
46
46
|
function getClientIP(req: Request): string {
|
|
47
47
|
return req.headers.get("cf-connecting-ip") ||
|
|
@@ -71,23 +71,23 @@ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reaso
|
|
|
71
71
|
const now = Date.now();
|
|
72
72
|
if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
|
|
73
73
|
|
|
74
|
-
const { data: machine, error } = await sb.from("clauth_machines")
|
|
75
|
-
.select("hmac_seed_hash, enabled, fail_count, locked")
|
|
76
|
-
.eq("machine_hash", body.machine_hash).single();
|
|
74
|
+
const { data: machine, error } = await sb.from("clauth_machines")
|
|
75
|
+
.select("hmac_seed_hash, enabled, fail_count, locked")
|
|
76
|
+
.eq("machine_hash", body.machine_hash).single();
|
|
77
77
|
|
|
78
78
|
if (error || !machine) return { valid: false, reason: "machine_not_found" };
|
|
79
79
|
if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
|
|
80
80
|
if (machine.locked) return { valid: false, reason: "machine_locked" };
|
|
81
81
|
|
|
82
82
|
const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
|
|
83
|
-
const message = `${body.machine_hash}:${window}`;
|
|
84
|
-
const expected = await hmacSha256(body.password, message);
|
|
85
|
-
const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
|
|
86
|
-
|
|
87
|
-
if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
|
|
88
|
-
const newCount = (machine.fail_count || 0) + 1;
|
|
89
|
-
const shouldLock = newCount >= MAX_FAIL_COUNT;
|
|
90
|
-
await sb.from("clauth_machines")
|
|
83
|
+
const message = `${body.machine_hash}:${window}`;
|
|
84
|
+
const expected = await hmacSha256(body.password, message);
|
|
85
|
+
const seedHash = await sha256Hex(`seed:${body.machine_hash}:${body.password}`);
|
|
86
|
+
|
|
87
|
+
if (seedHash !== machine.hmac_seed_hash || expected !== body.token) {
|
|
88
|
+
const newCount = (machine.fail_count || 0) + 1;
|
|
89
|
+
const shouldLock = newCount >= MAX_FAIL_COUNT;
|
|
90
|
+
await sb.from("clauth_machines")
|
|
91
91
|
.update({ fail_count: newCount, locked: shouldLock })
|
|
92
92
|
.eq("machine_hash", body.machine_hash);
|
|
93
93
|
return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
|
|
@@ -197,7 +197,7 @@ async function handleStatus(sb: any, body: any, mh: string) {
|
|
|
197
197
|
return { services: services || [] };
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
-
async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
200
|
+
async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
201
201
|
const { new_hmac_seed_hash } = body;
|
|
202
202
|
if (!new_hmac_seed_hash) return { error: "new_hmac_seed_hash required" };
|
|
203
203
|
const { error } = await sb.from("clauth_machines")
|
|
@@ -206,91 +206,92 @@ async function handleChangePassword(sb: any, body: any, mh: string) {
|
|
|
206
206
|
if (error) return { error: error.message };
|
|
207
207
|
await auditLog(sb, mh, "system", "change-password", "success");
|
|
208
208
|
return { success: true };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
|
|
212
|
-
const { data: machine } = await sb.from("clauth_machines")
|
|
213
|
-
.select("install_id")
|
|
214
|
-
.eq("machine_hash", machine_hash)
|
|
215
|
-
.single();
|
|
216
|
-
return normalizeInstallId(machine?.install_id);
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async function handleCreateEnrollment(sb: any, body: any, mh: string) {
|
|
220
|
-
const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
|
|
221
|
-
const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
|
|
222
|
-
const code = `ce_${randomToken(24)}`;
|
|
223
|
-
const token_hash = await sha256Hex(code);
|
|
224
|
-
const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
|
|
225
|
-
const label = body.label ? String(body.label).slice(0, 120) : null;
|
|
226
|
-
|
|
227
|
-
const { error } = await sb.from("clauth_machine_enrollments").insert({
|
|
228
|
-
install_id,
|
|
229
|
-
token_hash,
|
|
230
|
-
label,
|
|
231
|
-
created_by_machine_hash: mh,
|
|
232
|
-
expires_at,
|
|
233
|
-
});
|
|
234
|
-
if (error) {
|
|
235
|
-
await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
|
|
236
|
-
return { error: error.message };
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
|
|
240
|
-
return { success: true, enrollment_code: code, install_id, expires_at, label };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
async function handleRedeemEnrollment(sb: any, body: any) {
|
|
244
|
-
const { machine_hash, hmac_seed_hash, enrollment_code } = body;
|
|
245
|
-
if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
|
|
246
|
-
return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
const token_hash = await sha256Hex(String(enrollment_code).trim());
|
|
250
|
-
const nowIso = new Date().toISOString();
|
|
251
|
-
const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
|
|
252
|
-
.select("id, install_id, label, expires_at, consumed_at")
|
|
253
|
-
.eq("token_hash", token_hash)
|
|
254
|
-
.single();
|
|
255
|
-
|
|
256
|
-
if (lookupError || !enrollment) return { error: "enrollment_not_found" };
|
|
257
|
-
if (enrollment.consumed_at) return { error: "enrollment_already_used" };
|
|
258
|
-
if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
|
|
259
|
-
|
|
260
|
-
const label = body.label || enrollment.label || null;
|
|
261
|
-
const install_id = normalizeInstallId(enrollment.install_id);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
{
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function getMachineInstallId(sb: any, machine_hash: string): Promise<string> {
|
|
212
|
+
const { data: machine } = await sb.from("clauth_machines")
|
|
213
|
+
.select("install_id")
|
|
214
|
+
.eq("machine_hash", machine_hash)
|
|
215
|
+
.single();
|
|
216
|
+
return normalizeInstallId(machine?.install_id);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function handleCreateEnrollment(sb: any, body: any, mh: string) {
|
|
220
|
+
const install_id = normalizeInstallId(body.install_id || await getMachineInstallId(sb, mh));
|
|
221
|
+
const ttl_minutes = Math.max(5, Math.min(Number(body.ttl_minutes || 60), 24 * 60));
|
|
222
|
+
const code = `ce_${randomToken(24)}`;
|
|
223
|
+
const token_hash = await sha256Hex(code);
|
|
224
|
+
const expires_at = new Date(Date.now() + ttl_minutes * 60 * 1000).toISOString();
|
|
225
|
+
const label = body.label ? String(body.label).slice(0, 120) : null;
|
|
226
|
+
|
|
227
|
+
const { error } = await sb.from("clauth_machine_enrollments").insert({
|
|
228
|
+
install_id,
|
|
229
|
+
token_hash,
|
|
230
|
+
label,
|
|
231
|
+
created_by_machine_hash: mh,
|
|
232
|
+
expires_at,
|
|
233
|
+
});
|
|
234
|
+
if (error) {
|
|
235
|
+
await auditLog(sb, mh, "system", "create-enrollment", "fail", error.message);
|
|
236
|
+
return { error: error.message };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await auditLog(sb, mh, "system", "create-enrollment", "success", `install_id=${install_id}`);
|
|
240
|
+
return { success: true, enrollment_code: code, install_id, expires_at, label };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function handleRedeemEnrollment(sb: any, body: any) {
|
|
244
|
+
const { machine_hash, hmac_seed_hash, enrollment_code } = body;
|
|
245
|
+
if (!machine_hash || !hmac_seed_hash || !enrollment_code) {
|
|
246
|
+
return { error: "machine_hash, hmac_seed_hash, enrollment_code required" };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const token_hash = await sha256Hex(String(enrollment_code).trim());
|
|
250
|
+
const nowIso = new Date().toISOString();
|
|
251
|
+
const { data: enrollment, error: lookupError } = await sb.from("clauth_machine_enrollments")
|
|
252
|
+
.select("id, install_id, label, expires_at, consumed_at")
|
|
253
|
+
.eq("token_hash", token_hash)
|
|
254
|
+
.single();
|
|
255
|
+
|
|
256
|
+
if (lookupError || !enrollment) return { error: "enrollment_not_found" };
|
|
257
|
+
if (enrollment.consumed_at) return { error: "enrollment_already_used" };
|
|
258
|
+
if (new Date(enrollment.expires_at).getTime() < Date.now()) return { error: "enrollment_expired" };
|
|
259
|
+
|
|
260
|
+
const label = body.label || enrollment.label || null;
|
|
261
|
+
const install_id = normalizeInstallId(enrollment.install_id);
|
|
262
|
+
|
|
263
|
+
const { error: machineError } = await sb.from("clauth_machines").upsert(
|
|
264
|
+
{ machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
|
|
265
|
+
{ onConflict: "machine_hash" }
|
|
266
|
+
);
|
|
267
|
+
if (machineError) return { error: machineError.message };
|
|
268
|
+
|
|
269
|
+
const { data: consumedRows, error: consumeError } = await sb.from("clauth_machine_enrollments")
|
|
270
|
+
.update({ consumed_at: nowIso, redeemed_by_machine_hash: machine_hash })
|
|
271
|
+
.eq("id", enrollment.id)
|
|
272
|
+
.is("consumed_at", null)
|
|
273
|
+
.select("id");
|
|
274
|
+
if (consumeError) return { error: consumeError.message };
|
|
275
|
+
if (!consumedRows || consumedRows.length !== 1) return { error: "enrollment_already_used" };
|
|
276
|
+
|
|
277
|
+
await auditLog(sb, machine_hash, "system", "redeem-enrollment", "success", `install_id=${install_id}`);
|
|
278
|
+
return { success: true, machine_hash, install_id };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function handleRegisterMachine(sb: any, body: any) {
|
|
282
|
+
const { machine_hash, hmac_seed_hash, label, admin_token } = body;
|
|
283
|
+
if (body.enrollment_code || body.invite_code) {
|
|
284
|
+
return handleRedeemEnrollment(sb, { ...body, enrollment_code: body.enrollment_code || body.invite_code });
|
|
285
|
+
}
|
|
286
|
+
if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
|
|
287
|
+
const install_id = normalizeInstallId(body.install_id);
|
|
288
|
+
const { error } = await sb.from("clauth_machines").upsert(
|
|
289
|
+
{ machine_hash, hmac_seed_hash, label, install_id, enabled: true, fail_count: 0, locked: false },
|
|
290
|
+
{ onConflict: "machine_hash" }
|
|
291
|
+
);
|
|
292
|
+
if (error) return { error: error.message };
|
|
293
|
+
return { success: true, machine_hash, install_id };
|
|
294
|
+
}
|
|
294
295
|
|
|
295
296
|
Deno.serve(async (req: Request) => {
|
|
296
297
|
if (req.method === "OPTIONS") {
|
|
@@ -308,8 +309,8 @@ Deno.serve(async (req: Request) => {
|
|
|
308
309
|
const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
|
309
310
|
const ip = getClientIP(req);
|
|
310
311
|
|
|
311
|
-
if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
|
|
312
|
-
if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
|
|
312
|
+
if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
|
|
313
|
+
if (route === "redeem-enrollment") return Response.json(await handleRedeemEnrollment(sb, body));
|
|
313
314
|
|
|
314
315
|
const ipCheck = checkIP(ip);
|
|
315
316
|
if (!ipCheck.allowed) {
|
|
@@ -340,10 +341,10 @@ Deno.serve(async (req: Request) => {
|
|
|
340
341
|
case "update": return Response.json(await handleUpdate(sb, body, mh));
|
|
341
342
|
case "remove": return Response.json(await handleRemove(sb, body, mh));
|
|
342
343
|
case "revoke": return Response.json(await handleRevoke(sb, body, mh));
|
|
343
|
-
case "status": return Response.json(await handleStatus(sb, body, mh));
|
|
344
|
-
case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
|
|
345
|
-
case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
|
|
346
|
-
case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
|
|
344
|
+
case "status": return Response.json(await handleStatus(sb, body, mh));
|
|
345
|
+
case "change-password": return Response.json(await handleChangePassword(sb, body, mh));
|
|
346
|
+
case "create-enrollment": return Response.json(await handleCreateEnrollment(sb, body, mh));
|
|
347
|
+
case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
|
|
347
348
|
default: return Response.json({ error: "unknown_route", route }, { status: 404 });
|
|
348
349
|
}
|
|
349
350
|
});
|