@lifeaitools/clauth 0.2.1 → 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.
- package/.clauth-skill/SKILL.md +109 -66
- package/cli/commands/scrub.js +231 -0
- package/cli/commands/serve.js +378 -0
- package/cli/commands/uninstall.js +164 -0
- package/cli/index.js +94 -6
- package/package.json +1 -1
- package/supabase/functions/auth-vault/index.ts +132 -235
- package/supabase/migrations/20260317_lockout.sql +26 -0
|
@@ -1,326 +1,223 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
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
|
|
19
|
-
const SERVICE_ROLE_KEY
|
|
20
|
-
const CLAUTH_HMAC_SALT
|
|
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
|
|
11
|
+
const ALLOWED_IPS: string[] = (Deno.env.get("CLAUTH_ALLOWED_IPS") || "")
|
|
12
|
+
.split(",").map(s => s.trim()).filter(Boolean);
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
if (
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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:
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
185
|
-
const { service, enabled } = body;
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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,
|
|
135
|
+
await auditLog(sb, mh, service, "remove", "success");
|
|
221
136
|
return { success: true, service };
|
|
222
137
|
}
|
|
223
138
|
|
|
224
|
-
async function handleRevoke(sb:
|
|
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:
|
|
247
|
-
const { data: services } = await sb
|
|
248
|
-
.
|
|
249
|
-
|
|
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:
|
|
162
|
+
async function handleRegisterMachine(sb: any, body: any) {
|
|
257
163
|
const { machine_hash, hmac_seed_hash, label, admin_token } = body;
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
const sb = createClient(SUPABASE_URL, SERVICE_ROLE_KEY);
|
|
189
|
+
if (route === "register-machine") return Response.json(await handleRegisterMachine(sb, body));
|
|
294
190
|
|
|
295
|
-
|
|
296
|
-
if (
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
314
|
-
|
|
211
|
+
const mh = body.machine_hash;
|
|
315
212
|
switch (route) {
|
|
316
|
-
case "retrieve":
|
|
317
|
-
case "write":
|
|
318
|
-
case "enable":
|
|
319
|
-
case "add":
|
|
320
|
-
case "remove":
|
|
321
|
-
case "revoke":
|
|
322
|
-
case "status":
|
|
323
|
-
case "test":
|
|
324
|
-
default:
|
|
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;
|