@lifeaitools/clauth 0.2.2 → 0.3.0

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.
@@ -0,0 +1,378 @@
1
+ // cli/commands/serve.js
2
+ // Localhost-only credential daemon with daemon lifecycle management
3
+ // Binds 127.0.0.1 ONLY — unreachable from outside the machine
4
+ // 3 failed requests of any kind → process exits, requires manual restart
5
+ // Supports: start (background daemon), stop, restart, ping, foreground
6
+
7
+ import http from "http";
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { getMachineHash, deriveToken } from "../fingerprint.js";
12
+ import * as api from "../api.js";
13
+ import chalk from "chalk";
14
+
15
+ const PID_FILE = path.join(os.tmpdir(), "clauth-serve.pid");
16
+ const LOG_FILE = path.join(os.tmpdir(), "clauth-serve.log");
17
+
18
+ // ── PID helpers ──────────────────────────────────────────────
19
+ function readPid() {
20
+ try {
21
+ const raw = fs.readFileSync(PID_FILE, "utf8").trim();
22
+ const [pid, port] = raw.split(":");
23
+ return { pid: parseInt(pid, 10), port: parseInt(port, 10) };
24
+ } catch { return null; }
25
+ }
26
+
27
+ function writePid(pid, port) {
28
+ fs.writeFileSync(PID_FILE, `${pid}:${port}`, "utf8");
29
+ }
30
+
31
+ function removePid() {
32
+ try { fs.unlinkSync(PID_FILE); } catch {}
33
+ }
34
+
35
+ function isProcessAlive(pid) {
36
+ try { process.kill(pid, 0); return true; } catch { return false; }
37
+ }
38
+
39
+ // ── Server logic (shared by foreground + daemon) ─────────────
40
+ function createServer(password, whitelist, port) {
41
+ const MAX_FAILS = 3;
42
+ let failCount = 0;
43
+ const machineHash = getMachineHash();
44
+
45
+ function strike(res, code, message) {
46
+ failCount++;
47
+ const remaining = MAX_FAILS - failCount;
48
+ const logLine = `[${new Date().toISOString()}] [FAIL ${failCount}/${MAX_FAILS}] ${message}\n`;
49
+ try { fs.appendFileSync(LOG_FILE, logLine); } catch {}
50
+
51
+ const body = JSON.stringify({
52
+ error: message,
53
+ failures: failCount,
54
+ failures_remaining: remaining,
55
+ ...(failCount >= MAX_FAILS ? { shutdown: true } : {})
56
+ });
57
+
58
+ res.writeHead(code, { "Content-Type": "application/json" });
59
+ res.end(body);
60
+
61
+ if (failCount >= MAX_FAILS) {
62
+ const msg = `[${new Date().toISOString()}] Failure limit reached — shutting down\n`;
63
+ try { fs.appendFileSync(LOG_FILE, msg); } catch {}
64
+ removePid();
65
+ setTimeout(() => process.exit(1), 100);
66
+ }
67
+ }
68
+
69
+ function ok(res, data) {
70
+ res.writeHead(200, { "Content-Type": "application/json" });
71
+ res.end(JSON.stringify(data));
72
+ }
73
+
74
+ const server = http.createServer(async (req, res) => {
75
+ // Hard reject anything not from loopback
76
+ const remote = req.socket.remoteAddress;
77
+ const isLocal = remote === "127.0.0.1" || remote === "::1" || remote === "::ffff:127.0.0.1";
78
+ if (!isLocal) {
79
+ return strike(res, 403, `Rejected non-local address: ${remote}`);
80
+ }
81
+
82
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
83
+ const reqPath = url.pathname;
84
+ const method = req.method;
85
+
86
+ // GET /ping
87
+ if (method === "GET" && reqPath === "/ping") {
88
+ return ok(res, {
89
+ status: "ok",
90
+ pid: process.pid,
91
+ failures: failCount,
92
+ failures_remaining: MAX_FAILS - failCount,
93
+ services: whitelist || "all",
94
+ port
95
+ });
96
+ }
97
+
98
+ // GET /shutdown (for daemon stop)
99
+ if (method === "GET" && reqPath === "/shutdown") {
100
+ ok(res, { ok: true, message: "shutting down" });
101
+ removePid();
102
+ setTimeout(() => process.exit(0), 100);
103
+ return;
104
+ }
105
+
106
+ // GET /status
107
+ if (method === "GET" && reqPath === "/status") {
108
+ try {
109
+ const { token, timestamp } = deriveToken(password, machineHash);
110
+ const result = await api.status(password, machineHash, token, timestamp);
111
+ if (result.error) return strike(res, 502, result.error);
112
+ if (whitelist) {
113
+ result.services = (result.services || []).filter(
114
+ s => whitelist.includes(s.name.toLowerCase())
115
+ );
116
+ }
117
+ return ok(res, result);
118
+ } catch (err) {
119
+ return strike(res, 502, err.message);
120
+ }
121
+ }
122
+
123
+ // GET /get/:service
124
+ const getMatch = reqPath.match(/^\/get\/([a-zA-Z0-9_-]+)$/);
125
+ if (method === "GET" && getMatch) {
126
+ const service = getMatch[1].toLowerCase();
127
+
128
+ if (whitelist && !whitelist.includes(service)) {
129
+ return strike(res, 403, `Service '${service}' not in whitelist`);
130
+ }
131
+
132
+ try {
133
+ const { token, timestamp } = deriveToken(password, machineHash);
134
+ const result = await api.retrieve(password, machineHash, token, timestamp, service);
135
+ if (result.error) return strike(res, 502, result.error);
136
+ return ok(res, { service, value: result.value, key_type: result.key_type });
137
+ } catch (err) {
138
+ return strike(res, 502, err.message);
139
+ }
140
+ }
141
+
142
+ // Unknown route
143
+ return strike(res, 404, `Unknown endpoint: ${reqPath}`);
144
+ });
145
+
146
+ return server;
147
+ }
148
+
149
+ // ── Actions ──────────────────────────────────────────────────
150
+
151
+ async function verifyAuth(password) {
152
+ const machineHash = getMachineHash();
153
+ const { token, timestamp } = deriveToken(password, machineHash);
154
+ const result = await api.test(password, machineHash, token, timestamp);
155
+ if (result.error) throw new Error(result.error);
156
+ }
157
+
158
+ async function actionStart(opts) {
159
+ const port = parseInt(opts.port || "52437", 10);
160
+ const password = opts.pw;
161
+ const whitelist = opts.services
162
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
163
+ : null;
164
+
165
+ // Check for existing instance
166
+ const existing = readPid();
167
+ if (existing && isProcessAlive(existing.pid)) {
168
+ console.log(chalk.yellow(`\n clauth serve already running (PID ${existing.pid}, port ${existing.port})`));
169
+ console.log(chalk.gray(` Stop it first: clauth serve stop\n`));
170
+ process.exit(1);
171
+ }
172
+
173
+ // If we're the daemon child, run the server directly
174
+ if (process.env.__CLAUTH_DAEMON === "1") {
175
+ try {
176
+ await verifyAuth(password);
177
+ } catch (err) {
178
+ const msg = `[${new Date().toISOString()}] Auth failed: ${err.message}\n`;
179
+ fs.appendFileSync(LOG_FILE, msg);
180
+ process.exit(1);
181
+ }
182
+
183
+ const server = createServer(password, whitelist, port);
184
+ server.listen(port, "127.0.0.1", () => {
185
+ writePid(process.pid, port);
186
+ const msg = `[${new Date().toISOString()}] clauth serve started — PID ${process.pid}, port ${port}, services: ${whitelist ? whitelist.join(",") : "all"}\n`;
187
+ fs.appendFileSync(LOG_FILE, msg);
188
+ });
189
+
190
+ server.on("error", err => {
191
+ const msg = `[${new Date().toISOString()}] Server error: ${err.message}\n`;
192
+ fs.appendFileSync(LOG_FILE, msg);
193
+ process.exit(1);
194
+ });
195
+
196
+ const shutdown = () => { removePid(); process.exit(0); };
197
+ process.on("SIGTERM", shutdown);
198
+ process.on("SIGINT", shutdown);
199
+ return;
200
+ }
201
+
202
+ // Parent: verify auth first (show errors to user)
203
+ console.log(chalk.gray("\n Verifying vault credentials..."));
204
+ try {
205
+ await verifyAuth(password);
206
+ } catch (err) {
207
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
208
+ process.exit(1);
209
+ }
210
+ console.log(chalk.green(" ✓ Vault auth verified"));
211
+
212
+ // Spawn detached daemon child
213
+ const { spawn } = await import("child_process");
214
+ const { fileURLToPath } = await import("url");
215
+ const __filename = fileURLToPath(import.meta.url);
216
+
217
+ // Build args: node serve.js --action start --port N --pw PW [--services S]
218
+ const childArgs = [__filename, "--action", "start", "--port", String(port), "--pw", password];
219
+ if (opts.services) childArgs.push("--services", opts.services);
220
+
221
+ const out = fs.openSync(LOG_FILE, "a");
222
+ const child = spawn(process.execPath, childArgs, {
223
+ detached: true,
224
+ stdio: ["ignore", out, out],
225
+ env: { ...process.env, __CLAUTH_DAEMON: "1" },
226
+ });
227
+ child.unref();
228
+
229
+ // Give it a moment then verify
230
+ await new Promise(r => setTimeout(r, 800));
231
+
232
+ const info = readPid();
233
+ if (info && isProcessAlive(info.pid)) {
234
+ console.log(chalk.green(`\n 🔐 clauth serve started`));
235
+ console.log(chalk.gray(` PID: ${info.pid}`));
236
+ console.log(chalk.gray(` Port: 127.0.0.1:${info.port}`));
237
+ console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
238
+ console.log(chalk.gray(` Log: ${LOG_FILE}`));
239
+ console.log(chalk.gray(` Stop: clauth serve stop\n`));
240
+ } else {
241
+ console.log(chalk.red(`\n ❌ Failed to start daemon — check ${LOG_FILE}\n`));
242
+ process.exit(1);
243
+ }
244
+ }
245
+
246
+ async function actionStop() {
247
+ const info = readPid();
248
+ if (!info) {
249
+ console.log(chalk.yellow("\n No clauth serve PID file found — not running.\n"));
250
+ return;
251
+ }
252
+
253
+ if (!isProcessAlive(info.pid)) {
254
+ console.log(chalk.yellow(`\n PID ${info.pid} is not running (stale PID file). Cleaning up.\n`));
255
+ removePid();
256
+ return;
257
+ }
258
+
259
+ // Try HTTP shutdown first (clean)
260
+ try {
261
+ const resp = await fetch(`http://127.0.0.1:${info.port}/shutdown`);
262
+ if (resp.ok) {
263
+ await new Promise(r => setTimeout(r, 300));
264
+ console.log(chalk.green(`\n 🛑 clauth serve stopped (was PID ${info.pid}, port ${info.port})\n`));
265
+ removePid();
266
+ return;
267
+ }
268
+ } catch {}
269
+
270
+ // Fallback: kill the process
271
+ try {
272
+ process.kill(info.pid, "SIGTERM");
273
+ await new Promise(r => setTimeout(r, 300));
274
+ console.log(chalk.green(`\n 🛑 clauth serve stopped via SIGTERM (PID ${info.pid})\n`));
275
+ } catch (err) {
276
+ console.log(chalk.yellow(`\n Could not kill PID ${info.pid}: ${err.message}\n`));
277
+ }
278
+ removePid();
279
+ }
280
+
281
+ async function actionPing() {
282
+ const info = readPid();
283
+ if (!info) {
284
+ console.log(chalk.red("\n clauth serve is not running (no PID file)\n"));
285
+ process.exit(1);
286
+ }
287
+
288
+ if (!isProcessAlive(info.pid)) {
289
+ console.log(chalk.red(`\n PID ${info.pid} is not alive (stale PID file)\n`));
290
+ removePid();
291
+ process.exit(1);
292
+ }
293
+
294
+ try {
295
+ const resp = await fetch(`http://127.0.0.1:${info.port}/ping`);
296
+ const data = await resp.json();
297
+ if (data.status === "ok") {
298
+ console.log(chalk.green(`\n ✅ clauth serve running`));
299
+ console.log(chalk.gray(` PID: ${info.pid}`));
300
+ console.log(chalk.gray(` Port: ${info.port}`));
301
+ console.log(chalk.gray(` Fails: ${data.failures}/${data.failures + data.failures_remaining}`));
302
+ console.log(chalk.gray(` Services: ${Array.isArray(data.services) ? data.services.join(", ") : data.services}\n`));
303
+ } else {
304
+ console.log(chalk.yellow(`\n PID alive but /ping returned unexpected response\n`));
305
+ }
306
+ } catch (err) {
307
+ console.log(chalk.yellow(`\n PID ${info.pid} alive but HTTP failed: ${err.message}\n`));
308
+ }
309
+ }
310
+
311
+ async function actionRestart(opts) {
312
+ const info = readPid();
313
+ if (info && isProcessAlive(info.pid)) {
314
+ await actionStop();
315
+ await new Promise(r => setTimeout(r, 500));
316
+ }
317
+ await actionStart(opts);
318
+ }
319
+
320
+ async function actionForeground(opts) {
321
+ const port = parseInt(opts.port || "52437", 10);
322
+ const password = opts.pw;
323
+ const whitelist = opts.services
324
+ ? opts.services.split(",").map(s => s.trim().toLowerCase())
325
+ : null;
326
+
327
+ console.log(chalk.gray("\n Verifying vault credentials..."));
328
+ try {
329
+ await verifyAuth(password);
330
+ } catch (err) {
331
+ console.log(chalk.red(`\n Auth failed: ${err.message}\n`));
332
+ process.exit(1);
333
+ }
334
+
335
+ console.log(chalk.green(" ✓ Vault auth verified"));
336
+ console.log(chalk.gray(` Port: 127.0.0.1:${port}`));
337
+ console.log(chalk.gray(` Services: ${whitelist ? whitelist.join(", ") : "all"}`));
338
+ console.log(chalk.gray(` Lockout: 3 failures → exit\n`));
339
+
340
+ const server = createServer(password, whitelist, port);
341
+ server.listen(port, "127.0.0.1", () => {
342
+ writePid(process.pid, port);
343
+ console.log(chalk.green(` clauth serve → http://127.0.0.1:${port}`));
344
+ console.log(chalk.gray(" Ctrl+C to stop\n"));
345
+ });
346
+
347
+ server.on("error", err => {
348
+ if (err.code === "EADDRINUSE") {
349
+ console.log(chalk.red(`\n Port ${port} already in use. Use --port to choose another.\n`));
350
+ } else {
351
+ console.log(chalk.red(`\n Server error: ${err.message}\n`));
352
+ }
353
+ process.exit(1);
354
+ });
355
+
356
+ process.on("SIGINT", () => {
357
+ console.log(chalk.yellow("\n Stopping clauth serve...\n"));
358
+ removePid();
359
+ server.close(() => process.exit(0));
360
+ });
361
+ }
362
+
363
+ // ── Export ────────────────────────────────────────────────────
364
+ export async function runServe(opts) {
365
+ const action = opts.action || "foreground";
366
+
367
+ switch (action) {
368
+ case "start": return actionStart(opts);
369
+ case "stop": return actionStop();
370
+ case "restart": return actionRestart(opts);
371
+ case "ping": return actionPing();
372
+ case "foreground": return actionForeground(opts);
373
+ default:
374
+ console.log(chalk.red(`\n Unknown serve action: ${action}`));
375
+ console.log(chalk.gray(" Actions: start | stop | restart | ping | foreground\n"));
376
+ process.exit(1);
377
+ }
378
+ }
package/cli/index.js CHANGED
@@ -11,7 +11,7 @@ import * as api from "./api.js";
11
11
  import os from "os";
12
12
 
13
13
  const config = new Conf({ projectName: "clauth" });
14
- const VERSION = "0.2.2";
14
+ const VERSION = "0.3.0";
15
15
 
16
16
  // ============================================================
17
17
  // Password prompt helper
@@ -53,6 +53,7 @@ program
53
53
  import { runInstall } from './commands/install.js';
54
54
  import { runUninstall } from './commands/uninstall.js';
55
55
  import { runScrub } from './commands/scrub.js';
56
+ import { runServe } from './commands/serve.js';
56
57
 
57
58
  program
58
59
  .command('install')
@@ -457,4 +458,43 @@ program.addHelpText("beforeAll", chalk.cyan(`
457
458
  v${VERSION} — LIFEAI Credential Vault
458
459
  `));
459
460
 
461
+ // ──────────────────────────────────────────────
462
+ // clauth serve [action]
463
+ // ──────────────────────────────────────────────
464
+ program
465
+ .command("serve [action]")
466
+ .description("Manage localhost HTTP vault daemon (start|stop|restart|ping)")
467
+ .option("--port <n>", "Port (default: 52437)")
468
+ .option("-p, --pw <password>", "clauth password (required for start/restart)")
469
+ .option("--services <list>", "Comma-separated service whitelist (default: all)")
470
+ .option("--action <action>", "Internal: action override for daemon child")
471
+ .addHelpText("after", `
472
+ Actions:
473
+ start Start the server as a background daemon
474
+ stop Stop the running daemon
475
+ restart Stop + start
476
+ ping Check if the daemon is running
477
+ foreground Run in foreground (Ctrl+C to stop) — default if no action given
478
+
479
+ Examples:
480
+ clauth serve -p mypass Run in foreground (original behavior)
481
+ clauth serve start -p mypass Start as background daemon
482
+ clauth serve stop Stop the daemon
483
+ clauth serve ping Check status
484
+ clauth serve restart -p mypass Restart the daemon
485
+ clauth serve start --services github,vercel -p mypass
486
+ `)
487
+ .action(async (action, opts) => {
488
+ const resolvedAction = opts.action || action || "foreground";
489
+
490
+ // stop and ping don't need a password
491
+ if (!["stop", "ping"].includes(resolvedAction) && !opts.pw) {
492
+ console.log(chalk.red("\n --pw is required for serve mode\n"));
493
+ console.log(chalk.gray(" Example: clauth serve start --pw yourpassword\n"));
494
+ process.exit(1);
495
+ }
496
+
497
+ await runServe({ ...opts, action: resolvedAction });
498
+ });
499
+
460
500
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lifeaitools/clauth",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Hardware-bound credential vault for the LIFEAI infrastructure stack",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,326 +1,223 @@
1
- // ============================================================
2
- // clauth auth-vault Edge Function
3
- // Supabase Deno runtime
4
- // Routes:
5
- // POST /auth-vault/retrieve — validate HMAC, return key
6
- // POST /auth-vault/write — write/update key in vault
7
- // POST /auth-vault/enable — enable/disable service
8
- // POST /auth-vault/add — add new service to registry
9
- // POST /auth-vault/remove — remove service from registry
10
- // POST /auth-vault/status — list all services + state
11
- // POST /auth-vault/test — dry-run HMAC check only
12
- // POST /auth-vault/rotate — flag a service for rotation
13
- // POST /auth-vault/revoke — delete key from vault
14
- // ============================================================
1
+ // clauth — auth-vault Edge Function v2
2
+ // Added: IP whitelist, rate limiting, machine lockout (fail_count + locked)
15
3
 
16
4
  import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
17
5
 
18
- const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
19
- const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
20
- const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!; // stored in Supabase secrets
6
+ const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
7
+ const SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
8
+ const CLAUTH_HMAC_SALT = Deno.env.get("CLAUTH_HMAC_SALT")!;
9
+ const ADMIN_BOOTSTRAP_TOKEN = Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")!;
21
10
 
22
- const REPLAY_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
11
+ const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
12
+ .split(",").map(s => s.trim()).filter(Boolean);
23
13
 
24
- // ============================================================
25
- // Helpers
26
- // ============================================================
27
-
28
- async function sha256hex(input: string): Promise<string> {
29
- const buf = await crypto.subtle.digest(
30
- "SHA-256",
31
- new TextEncoder().encode(input)
32
- );
33
- return Array.from(new Uint8Array(buf))
34
- .map(b => b.toString(16).padStart(2, "0"))
35
- .join("");
36
- }
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;
37
18
 
38
19
  async function hmacSha256(key: string, message: string): Promise<string> {
39
20
  const cryptoKey = await crypto.subtle.importKey(
40
- "raw",
41
- new TextEncoder().encode(key),
42
- { name: "HMAC", hash: "SHA-256" },
43
- false,
44
- ["sign"]
45
- );
46
- const sig = await crypto.subtle.sign(
47
- "HMAC",
48
- cryptoKey,
49
- new TextEncoder().encode(message)
21
+ "raw", new TextEncoder().encode(key),
22
+ { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
50
23
  );
51
- return Array.from(new Uint8Array(sig))
52
- .map(b => b.toString(16).padStart(2, "0"))
53
- .join("");
24
+ const sig = await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(message));
25
+ return Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, "0")).join("");
54
26
  }
55
27
 
56
- async function validateHMAC(body: {
57
- machine_hash: string;
58
- token: string;
59
- timestamp: number;
60
- password: string;
61
- }): Promise<{ valid: boolean; reason?: string }> {
62
- const now = Date.now();
28
+ function getClientIP(req: Request): string {
29
+ return req.headers.get("cf-connecting-ip") ||
30
+ req.headers.get("x-real-ip") ||
31
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
32
+ }
63
33
 
64
- // Replay check
65
- if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) {
66
- return { valid: false, reason: "timestamp_expired" };
34
+ function checkIP(ip: string): { allowed: boolean; reason?: string } {
35
+ if (ALLOWED_IPS.length === 0) return { allowed: true };
36
+ if (ALLOWED_IPS.includes(ip)) return { allowed: true };
37
+ return { allowed: false, reason: `IP not whitelisted: ${ip}` };
38
+ }
39
+
40
+ async function checkRateLimit(sb: any, machine_hash: string): Promise<{ allowed: boolean; reason?: string }> {
41
+ const windowStart = new Date(Date.now() - RATE_LIMIT_WINDOW * 1000).toISOString();
42
+ const { count } = await sb.from("clauth_audit")
43
+ .select("id", { count: "exact", head: true })
44
+ .eq("machine_hash", machine_hash)
45
+ .gte("created_at", windowStart);
46
+ if ((count || 0) >= RATE_LIMIT_MAX) {
47
+ return { allowed: false, reason: `Rate limit: ${count}/${RATE_LIMIT_MAX} per ${RATE_LIMIT_WINDOW}s` };
67
48
  }
49
+ return { allowed: true };
50
+ }
51
+
52
+ async function validateHMAC(sb: any, body: any): Promise<{ valid: boolean; reason?: string }> {
53
+ const now = Date.now();
54
+ if (Math.abs(now - body.timestamp) > REPLAY_WINDOW_MS) return { valid: false, reason: "timestamp_expired" };
68
55
 
69
- // Lookup machine
70
- const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
71
- const { data: machine, error } = await sb
72
- .from("clauth_machines")
73
- .select("hmac_seed_hash, enabled")
74
- .eq("machine_hash", body.machine_hash)
75
- .single();
56
+ const { data: machine, error } = await sb.from("clauth_machines")
57
+ .select("hmac_seed_hash, enabled, fail_count, locked")
58
+ .eq("machine_hash", body.machine_hash).single();
76
59
 
77
- if (error || !machine) {
78
- return { valid: false, reason: "machine_not_found" };
79
- }
80
- if (!machine.enabled) {
81
- return { valid: false, reason: "machine_disabled" };
82
- }
60
+ if (error || !machine) return { valid: false, reason: "machine_not_found" };
61
+ if (!machine.enabled) return { valid: false, reason: "machine_disabled" };
62
+ if (machine.locked) return { valid: false, reason: "machine_locked" };
83
63
 
84
- // Expected token = HMAC(machine_hash:window, password)
85
- // Client derives token with password only; server verifies the same way
86
- const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
87
- const message = `${body.machine_hash}:${window}`;
64
+ const window = Math.floor(body.timestamp / REPLAY_WINDOW_MS);
65
+ const message = `${body.machine_hash}:${window}`;
88
66
  const expected = await hmacSha256(body.password, message);
89
67
 
90
68
  if (expected !== body.token) {
91
- return { valid: false, reason: "invalid_token" };
69
+ const newCount = (machine.fail_count || 0) + 1;
70
+ const shouldLock = newCount >= MAX_FAIL_COUNT;
71
+ await sb.from("clauth_machines")
72
+ .update({ fail_count: newCount, locked: shouldLock })
73
+ .eq("machine_hash", body.machine_hash);
74
+ return { valid: false, reason: shouldLock ? `machine_locked after ${newCount} failures` : `invalid_token (${newCount}/${MAX_FAIL_COUNT})` };
92
75
  }
93
76
 
94
- // Update last_seen
95
- await sb
96
- .from("clauth_machines")
97
- .update({ last_seen: new Date().toISOString() })
77
+ await sb.from("clauth_machines")
78
+ .update({ last_seen: new Date().toISOString(), fail_count: 0 })
98
79
  .eq("machine_hash", body.machine_hash);
99
-
100
80
  return { valid: true };
101
81
  }
102
82
 
103
- async function auditLog(
104
- sb: ReturnType<typeof createClient>,
105
- machine_hash: string,
106
- service_name: string,
107
- action: string,
108
- result: string,
109
- detail?: string
110
- ) {
111
- await sb.from("clauth_audit").insert({
112
- machine_hash, service_name, action, result, detail
113
- });
83
+ async function auditLog(sb: any, machine_hash: string, service_name: string, action: string, result: string, detail?: string) {
84
+ await sb.from("clauth_audit").insert({ machine_hash, service_name, action, result, detail });
114
85
  }
115
86
 
116
- // ============================================================
117
- // Route handlers
118
- // ============================================================
119
-
120
- async function handleRetrieve(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
87
+ async function handleRetrieve(sb: any, body: any, mh: string) {
121
88
  const { service } = body;
122
89
  if (!service) return { error: "service required" };
123
-
124
- const { data: svc } = await sb
125
- .from("clauth_services")
126
- .select("*")
127
- .eq("name", service)
128
- .single();
129
-
130
- if (!svc) {
131
- await auditLog(sb, machine_hash, service, "retrieve", "fail", "service_not_found");
132
- return { error: "service_not_found" };
133
- }
134
- if (!svc.enabled) {
135
- await auditLog(sb, machine_hash, service, "retrieve", "denied", "service_disabled");
136
- return { error: "service_disabled" };
137
- }
138
- if (!svc.vault_key) {
139
- await auditLog(sb, machine_hash, service, "retrieve", "fail", "no_key_stored");
140
- return { error: "no_key_stored" };
141
- }
142
-
143
- // Retrieve from vault
144
- const { data: secret } = await sb.rpc("vault_decrypt_secret", {
145
- secret_name: svc.vault_key
146
- });
147
-
148
- await sb
149
- .from("clauth_services")
150
- .update({ last_retrieved: new Date().toISOString() })
151
- .eq("name", service);
152
-
153
- await auditLog(sb, machine_hash, service, "retrieve", "success");
90
+ const { data: svc } = await sb.from("clauth_services").select("*").eq("name", service).single();
91
+ if (!svc) { await auditLog(sb, mh, service, "retrieve", "fail", "service_not_found"); return { error: "service_not_found" }; }
92
+ if (!svc.enabled) { await auditLog(sb, mh, service, "retrieve", "denied", "service_disabled"); return { error: "service_disabled" }; }
93
+ if (!svc.vault_key) { await auditLog(sb, mh, service, "retrieve", "fail", "no_key_stored"); return { error: "no_key_stored" }; }
94
+ const { data: secret } = await sb.rpc("vault_decrypt_secret", { secret_name: svc.vault_key });
95
+ await sb.from("clauth_services").update({ last_retrieved: new Date().toISOString() }).eq("name", service);
96
+ await auditLog(sb, mh, service, "retrieve", "success");
154
97
  return { service, key_type: svc.key_type, value: secret };
155
98
  }
156
99
 
157
- async function handleWrite(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
100
+ async function handleWrite(sb: any, body: any, mh: string) {
158
101
  const { service, value } = body;
159
102
  if (!service || !value) return { error: "service and value required" };
160
-
161
103
  const vaultKey = `clauth.${service}`;
162
-
163
- // Upsert into vault
164
- const { error } = await sb.rpc("vault_upsert_secret", {
165
- secret_name: vaultKey,
166
- secret_value: typeof value === "string" ? value : JSON.stringify(value)
167
- });
168
-
169
- if (error) {
170
- await auditLog(sb, machine_hash, service, "write", "fail", error.message);
171
- return { error: error.message };
172
- }
173
-
174
- // Update registry
175
- await sb
176
- .from("clauth_services")
177
- .update({ vault_key: vaultKey, last_rotated: new Date().toISOString() })
178
- .eq("name", service);
179
-
180
- await auditLog(sb, machine_hash, service, "write", "success");
104
+ const { error } = await sb.rpc("vault_upsert_secret", { secret_name: vaultKey, secret_value: typeof value === "string" ? value : JSON.stringify(value) });
105
+ if (error) { await auditLog(sb, mh, service, "write", "fail", error.message); return { error: error.message }; }
106
+ await sb.from("clauth_services").update({ vault_key: vaultKey, last_rotated: new Date().toISOString() }).eq("name", service);
107
+ await auditLog(sb, mh, service, "write", "success");
181
108
  return { success: true, service, vault_key: vaultKey };
182
109
  }
183
110
 
184
- async function handleEnable(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
185
- const { service, enabled } = body; // enabled: true | false
186
- const target = service === "all" ? null : service;
187
-
188
- let query = sb.from("clauth_services").update({ enabled });
189
- if (target) query = query.eq("name", target);
190
- else query = query.not("vault_key", "is", null); // only enable services with keys
191
-
192
- const { error } = await query;
111
+ async function handleEnable(sb: any, body: any, mh: string) {
112
+ const { service, enabled } = body;
113
+ let q = sb.from("clauth_services").update({ enabled });
114
+ q = service !== "all" ? q.eq("name", service) : q.not("vault_key", "is", null);
115
+ const { error } = await q;
193
116
  if (error) return { error: error.message };
194
-
195
- await auditLog(sb, machine_hash, service, enabled ? "enable" : "disable", "success");
117
+ await auditLog(sb, mh, service, enabled ? "enable" : "disable", "success");
196
118
  return { success: true, service, enabled };
197
119
  }
198
120
 
199
- async function handleAdd(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
121
+ async function handleAdd(sb: any, body: any, mh: string) {
200
122
  const { name, label, key_type, description } = body;
201
123
  if (!name || !label || !key_type) return { error: "name, label, key_type required" };
202
-
203
- const { error } = await sb.from("clauth_services").insert({
204
- name, label, key_type, description: description || null
205
- });
206
-
124
+ const { error } = await sb.from("clauth_services").insert({ name, label, key_type, description: description || null });
207
125
  if (error) return { error: error.message };
208
- await auditLog(sb, machine_hash, name, "add", "success");
126
+ await auditLog(sb, mh, name, "add", "success");
209
127
  return { success: true, name, label, key_type };
210
128
  }
211
129
 
212
- async function handleRemove(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
130
+ async function handleRemove(sb: any, body: any, mh: string) {
213
131
  const { service, confirm } = body;
214
- if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) {
215
- return { error: "confirm phrase mismatch", required: `CONFIRM REMOVE ${service.toUpperCase()}` };
216
- }
217
-
132
+ if (confirm !== `CONFIRM REMOVE ${service.toUpperCase()}`) return { error: "confirm phrase mismatch" };
218
133
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
219
134
  await sb.from("clauth_services").delete().eq("name", service);
220
- await auditLog(sb, machine_hash, service, "remove", "success");
135
+ await auditLog(sb, mh, service, "remove", "success");
221
136
  return { success: true, service };
222
137
  }
223
138
 
224
- async function handleRevoke(sb: ReturnType<typeof createClient>, body: any, machine_hash: string) {
139
+ async function handleRevoke(sb: any, body: any, mh: string) {
225
140
  const { service, confirm } = body;
226
141
  const phrase = service === "all" ? "CONFIRM REVOKE ALL" : `CONFIRM REVOKE ${service.toUpperCase()}`;
227
- if (confirm !== phrase) {
228
- return { error: "confirm phrase mismatch", required: phrase };
229
- }
230
-
142
+ if (confirm !== phrase) return { error: "confirm phrase mismatch", required: phrase };
231
143
  if (service === "all") {
232
144
  const { data: svcs } = await sb.from("clauth_services").select("name").not("vault_key", "is", null);
233
- for (const s of svcs || []) {
234
- await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
235
- }
145
+ for (const s of svcs || []) await sb.rpc("vault_delete_secret", { secret_name: `clauth.${s.name}` });
236
146
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).neq("id", "00000000-0000-0000-0000-000000000000");
237
147
  } else {
238
148
  await sb.rpc("vault_delete_secret", { secret_name: `clauth.${service}` });
239
149
  await sb.from("clauth_services").update({ vault_key: null, enabled: false }).eq("name", service);
240
150
  }
241
-
242
- await auditLog(sb, machine_hash, service, "revoke", "success");
151
+ await auditLog(sb, mh, service, "revoke", "success");
243
152
  return { success: true, service };
244
153
  }
245
154
 
246
- async function handleStatus(sb: ReturnType<typeof createClient>, machine_hash: string) {
247
- const { data: services } = await sb
248
- .from("clauth_services")
249
- .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at")
250
- .order("name");
251
-
252
- await auditLog(sb, machine_hash, "all", "status", "success");
155
+ async function handleStatus(sb: any, mh: string) {
156
+ const { data: services } = await sb.from("clauth_services")
157
+ .select("name, label, key_type, enabled, vault_key, last_retrieved, last_rotated, created_at").order("name");
158
+ await auditLog(sb, mh, "all", "status", "success");
253
159
  return { services: services || [] };
254
160
  }
255
161
 
256
- async function handleRegisterMachine(sb: ReturnType<typeof createClient>, body: any) {
162
+ async function handleRegisterMachine(sb: any, body: any) {
257
163
  const { machine_hash, hmac_seed_hash, label, admin_token } = body;
258
- // Bootstrap: requires a one-time admin token stored as env var
259
- if (admin_token !== Deno.env.get("CLAUTH_ADMIN_BOOTSTRAP_TOKEN")) {
260
- return { error: "invalid_admin_token" };
261
- }
262
- const { error } = await sb.from("clauth_machines").upsert({
263
- machine_hash, hmac_seed_hash, label, enabled: true
264
- }, { onConflict: "machine_hash" });
265
-
164
+ if (admin_token !== ADMIN_BOOTSTRAP_TOKEN) return { error: "invalid_admin_token" };
165
+ const { error } = await sb.from("clauth_machines").upsert(
166
+ { machine_hash, hmac_seed_hash, label, enabled: true, fail_count: 0, locked: false },
167
+ { onConflict: "machine_hash" }
168
+ );
266
169
  if (error) return { error: error.message };
267
170
  return { success: true, machine_hash };
268
171
  }
269
172
 
270
- // ============================================================
271
- // Main handler
272
- // ============================================================
273
-
274
173
  Deno.serve(async (req: Request) => {
275
- const url = new URL(req.url);
276
- const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
277
-
278
174
  if (req.method === "OPTIONS") {
279
- return new Response(null, {
280
- headers: {
281
- "Access-Control-Allow-Origin": "*",
282
- "Access-Control-Allow-Methods": "POST, OPTIONS",
283
- "Access-Control-Allow-Headers": "Content-Type, Authorization"
284
- }
285
- });
175
+ return new Response(null, { headers: {
176
+ "Access-Control-Allow-Origin": "*",
177
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
178
+ "Access-Control-Allow-Headers": "Content-Type, Authorization"
179
+ }});
286
180
  }
181
+ if (req.method !== "POST") return Response.json({ error: "POST only" }, { status: 405 });
287
182
 
288
- if (req.method !== "POST") {
289
- return Response.json({ error: "POST only" }, { status: 405 });
290
- }
183
+ const url = new URL(req.url);
184
+ const route = url.pathname.replace(/^\/auth-vault\/?/, "").replace(/^\//, "");
185
+ const body = await req.json().catch(() => ({}));
186
+ const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
187
+ const ip = getClientIP(req);
291
188
 
292
- const body = await req.json().catch(() => ({}));
293
- const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
189
+ if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
294
190
 
295
- // Bootstrap machine registration — no HMAC required, uses admin token
296
- if (route === "register-machine") {
297
- return Response.json(await handleRegisterMachine(sb, body));
191
+ const ipCheck = checkIP(ip);
192
+ if (!ipCheck.allowed) {
193
+ await auditLog(sb, body.machine_hash || "unknown", "system", route, "blocked", ipCheck.reason);
194
+ return Response.json({ error: "ip_blocked", reason: ipCheck.reason }, { status: 403 });
298
195
  }
299
196
 
300
- // All other routes require HMAC validation
301
- const authResult = await validateHMAC({
302
- machine_hash: body.machine_hash,
303
- token: body.token,
304
- timestamp: body.timestamp,
305
- password: body.password
306
- });
197
+ if (body.machine_hash) {
198
+ const rateCheck = await checkRateLimit(sb, body.machine_hash);
199
+ if (!rateCheck.allowed) {
200
+ await auditLog(sb, body.machine_hash, "system", route, "rate_limited", rateCheck.reason);
201
+ return Response.json({ error: "rate_limited", reason: rateCheck.reason }, { status: 429 });
202
+ }
203
+ }
307
204
 
205
+ const authResult = await validateHMAC(sb, { machine_hash: body.machine_hash, token: body.token, timestamp: body.timestamp, password: body.password });
308
206
  if (!authResult.valid) {
309
207
  await auditLog(sb, body.machine_hash || "unknown", body.service || "unknown", route, "denied", authResult.reason);
310
208
  return Response.json({ error: "auth_failed", reason: authResult.reason }, { status: 401 });
311
209
  }
312
210
 
313
- const machine_hash = body.machine_hash;
314
-
211
+ const mh = body.machine_hash;
315
212
  switch (route) {
316
- case "retrieve": return Response.json(await handleRetrieve(sb, body, machine_hash));
317
- case "write": return Response.json(await handleWrite(sb, body, machine_hash));
318
- case "enable": return Response.json(await handleEnable(sb, body, machine_hash));
319
- case "add": return Response.json(await handleAdd(sb, body, machine_hash));
320
- case "remove": return Response.json(await handleRemove(sb, body, machine_hash));
321
- case "revoke": return Response.json(await handleRevoke(sb, body, machine_hash));
322
- case "status": return Response.json(await handleStatus(sb, machine_hash));
323
- case "test": return Response.json({ valid: true, machine_hash, timestamp: body.timestamp });
324
- default: return Response.json({ error: "unknown_route", route }, { status: 404 });
213
+ case "retrieve": return Response.json(await handleRetrieve(sb, body, mh));
214
+ case "write": return Response.json(await handleWrite(sb, body, mh));
215
+ case "enable": return Response.json(await handleEnable(sb, body, mh));
216
+ case "add": return Response.json(await handleAdd(sb, body, mh));
217
+ case "remove": return Response.json(await handleRemove(sb, body, mh));
218
+ case "revoke": return Response.json(await handleRevoke(sb, body, mh));
219
+ case "status": return Response.json(await handleStatus(sb, mh));
220
+ case "test": return Response.json({ valid: true, machine_hash: mh, timestamp: body.timestamp, ip });
221
+ default: return Response.json({ error: "unknown_route", route }, { status: 404 });
325
222
  }
326
223
  });
@@ -0,0 +1,26 @@
1
+ -- Migration: add fail_count and locked to clauth_machines
2
+ -- Run via: clauth install (picks this up automatically) or apply manually
3
+
4
+ ALTER TABLE clauth_machines
5
+ ADD COLUMN IF NOT EXISTS fail_count INTEGER NOT NULL DEFAULT 0,
6
+ ADD COLUMN IF NOT EXISTS locked BOOLEAN NOT NULL DEFAULT false;
7
+
8
+ -- Index for fast lockout checks
9
+ CREATE INDEX IF NOT EXISTS idx_clauth_machines_locked
10
+ ON clauth_machines (machine_hash, locked);
11
+
12
+ -- Admin helper: unlock a machine
13
+ -- Usage: SELECT clauth_unlock_machine('your_machine_hash');
14
+ CREATE OR REPLACE FUNCTION clauth_unlock_machine(p_machine_hash TEXT)
15
+ RETURNS void
16
+ LANGUAGE plpgsql
17
+ SECURITY DEFINER
18
+ AS $$
19
+ BEGIN
20
+ UPDATE clauth_machines
21
+ SET locked = false, fail_count = 0
22
+ WHERE machine_hash = p_machine_hash;
23
+ END;
24
+ $$;
25
+
26
+ REVOKE EXECUTE ON FUNCTION clauth_unlock_machine(TEXT) FROM PUBLIC;