@rainfall-devkit/sdk 0.2.1 → 0.2.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/dist/chunk-6FXRLPLR.mjs +436 -0
- package/dist/chunk-CC4O7GSQ.mjs +978 -0
- package/dist/chunk-CQ5TV7CQ.mjs +989 -0
- package/dist/chunk-GPKQUVAV.mjs +987 -0
- package/dist/chunk-KOCCGNEQ.mjs +269 -0
- package/dist/chunk-LJQEO3CY.mjs +150 -0
- package/dist/chunk-NCQVOLS4.mjs +269 -0
- package/dist/chunk-S7MOQCV4.mjs +137 -0
- package/dist/chunk-XHPFY5MH.mjs +132 -0
- package/dist/cli/index.js +1009 -54
- package/dist/cli/index.mjs +244 -24
- package/dist/daemon/index.d.mts +3 -3
- package/dist/daemon/index.d.ts +3 -3
- package/dist/daemon/index.js +411 -129
- package/dist/daemon/index.mjs +2 -1
- package/dist/display-KKJPO6UA.mjs +14 -0
- package/dist/errors-CY6HW2I5.mjs +24 -0
- package/dist/index.d.mts +66 -4
- package/dist/index.d.ts +66 -4
- package/dist/index.js +903 -124
- package/dist/index.mjs +18 -6
- package/dist/listeners-BBNBsJCk.d.ts +372 -0
- package/dist/listeners-CMUKjEkb.d.mts +372 -0
- package/dist/listeners-CadPNUHd.d.ts +372 -0
- package/dist/listeners-Ckdj6D8T.d.mts +372 -0
- package/dist/mcp.d.mts +2 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +405 -101
- package/dist/mcp.mjs +4 -2
- package/dist/param-parser-JVKB5FQK.mjs +12 -0
- package/dist/param-parser-PAKCNDBX.mjs +136 -0
- package/dist/sdk-Cl5Qzt4I.d.mts +1165 -0
- package/dist/sdk-Cl5Qzt4I.d.ts +1165 -0
- package/dist/sdk-DQKNbBce.d.mts +1162 -0
- package/dist/sdk-DQKNbBce.d.ts +1162 -0
- package/package.json +2 -2
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/security/edge-node.ts
|
|
2
|
+
import * as sodium from "libsodium-wrappers-sumo";
|
|
3
|
+
var EdgeNodeSecurity = class {
|
|
4
|
+
sodiumReady;
|
|
5
|
+
backendSecret;
|
|
6
|
+
keyPair;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.sodiumReady = sodium.ready;
|
|
9
|
+
this.backendSecret = options.backendSecret;
|
|
10
|
+
this.keyPair = options.keyPair;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Initialize libsodium
|
|
14
|
+
*/
|
|
15
|
+
async initialize() {
|
|
16
|
+
await this.sodiumReady;
|
|
17
|
+
}
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// JWT Token Management
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/**
|
|
22
|
+
* Generate a JWT token for an edge node
|
|
23
|
+
* Note: In production, this is done by the backend. This is for testing.
|
|
24
|
+
*/
|
|
25
|
+
generateJWT(edgeNodeId, subscriberId, expiresInDays = 30) {
|
|
26
|
+
if (!this.backendSecret) {
|
|
27
|
+
throw new Error("Backend secret not configured");
|
|
28
|
+
}
|
|
29
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
30
|
+
const exp = now + expiresInDays * 24 * 60 * 60;
|
|
31
|
+
const jti = this.generateTokenId();
|
|
32
|
+
const payload = {
|
|
33
|
+
sub: edgeNodeId,
|
|
34
|
+
iss: "rainfall-backend",
|
|
35
|
+
iat: now,
|
|
36
|
+
exp,
|
|
37
|
+
jti,
|
|
38
|
+
scope: ["edge:heartbeat", "edge:claim", "edge:submit", "edge:queue"]
|
|
39
|
+
};
|
|
40
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
41
|
+
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
|
42
|
+
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
|
43
|
+
const signature = this.hmacSha256(
|
|
44
|
+
`${encodedHeader}.${encodedPayload}`,
|
|
45
|
+
this.backendSecret
|
|
46
|
+
);
|
|
47
|
+
const encodedSignature = this.base64UrlEncode(signature);
|
|
48
|
+
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate a JWT token
|
|
52
|
+
*/
|
|
53
|
+
validateJWT(token) {
|
|
54
|
+
const parts = token.split(".");
|
|
55
|
+
if (parts.length !== 3) {
|
|
56
|
+
throw new Error("Invalid JWT format");
|
|
57
|
+
}
|
|
58
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
59
|
+
if (this.backendSecret) {
|
|
60
|
+
const expectedSignature = this.hmacSha256(
|
|
61
|
+
`${encodedHeader}.${encodedPayload}`,
|
|
62
|
+
this.backendSecret
|
|
63
|
+
);
|
|
64
|
+
const expectedEncoded = this.base64UrlEncode(expectedSignature);
|
|
65
|
+
if (!this.timingSafeEqual(encodedSignature, expectedEncoded)) {
|
|
66
|
+
throw new Error("Invalid JWT signature");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const payload = JSON.parse(this.base64UrlDecode(encodedPayload));
|
|
70
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
71
|
+
if (payload.exp < now) {
|
|
72
|
+
throw new Error("JWT token expired");
|
|
73
|
+
}
|
|
74
|
+
if (payload.iss !== "rainfall-backend") {
|
|
75
|
+
throw new Error("Invalid JWT issuer");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
edgeNodeId: payload.sub,
|
|
79
|
+
subscriberId: payload.sub,
|
|
80
|
+
// Same as edge node ID for now
|
|
81
|
+
scopes: payload.scope,
|
|
82
|
+
expiresAt: payload.exp
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Extract bearer token from Authorization header
|
|
87
|
+
*/
|
|
88
|
+
extractBearerToken(authHeader) {
|
|
89
|
+
if (!authHeader) return null;
|
|
90
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
91
|
+
return match ? match[1] : null;
|
|
92
|
+
}
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// ACL Enforcement
|
|
95
|
+
// ============================================================================
|
|
96
|
+
/**
|
|
97
|
+
* Check if an edge node is allowed to perform an action on a job
|
|
98
|
+
* Rule: Edge nodes can only access jobs for their own subscriber
|
|
99
|
+
*/
|
|
100
|
+
checkACL(check) {
|
|
101
|
+
if (check.subscriberId !== check.jobSubscriberId) {
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
reason: `Edge node ${check.edgeNodeId} cannot access jobs from subscriber ${check.jobSubscriberId}`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const allowedActions = ["heartbeat", "claim", "submit", "queue"];
|
|
108
|
+
if (!allowedActions.includes(check.action)) {
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: `Unknown action: ${check.action}`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Middleware-style ACL check for job operations
|
|
118
|
+
*/
|
|
119
|
+
requireSameSubscriber(edgeNodeSubscriberId, jobSubscriberId, operation) {
|
|
120
|
+
const result = this.checkACL({
|
|
121
|
+
edgeNodeId: edgeNodeSubscriberId,
|
|
122
|
+
subscriberId: edgeNodeSubscriberId,
|
|
123
|
+
jobSubscriberId,
|
|
124
|
+
action: operation
|
|
125
|
+
});
|
|
126
|
+
if (!result.allowed) {
|
|
127
|
+
throw new Error(result.reason);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Encryption (Libsodium)
|
|
132
|
+
// ============================================================================
|
|
133
|
+
/**
|
|
134
|
+
* Generate a new Ed25519 key pair for an edge node
|
|
135
|
+
*/
|
|
136
|
+
async generateKeyPair() {
|
|
137
|
+
await this.sodiumReady;
|
|
138
|
+
const keyPair = sodium.crypto_box_keypair();
|
|
139
|
+
return {
|
|
140
|
+
publicKey: this.bytesToBase64(keyPair.publicKey),
|
|
141
|
+
privateKey: this.bytesToBase64(keyPair.privateKey)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Encrypt job parameters for a target edge node using its public key
|
|
146
|
+
*/
|
|
147
|
+
async encryptForEdgeNode(plaintext, targetPublicKeyBase64) {
|
|
148
|
+
await this.sodiumReady;
|
|
149
|
+
if (!this.keyPair) {
|
|
150
|
+
throw new Error("Local key pair not configured");
|
|
151
|
+
}
|
|
152
|
+
const targetPublicKey = this.base64ToBytes(targetPublicKeyBase64);
|
|
153
|
+
const ephemeralKeyPair = sodium.crypto_box_keypair();
|
|
154
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
155
|
+
const message = new TextEncoder().encode(plaintext);
|
|
156
|
+
const ciphertext = sodium.crypto_box_easy(
|
|
157
|
+
message,
|
|
158
|
+
nonce,
|
|
159
|
+
targetPublicKey,
|
|
160
|
+
ephemeralKeyPair.privateKey
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
ciphertext: this.bytesToBase64(ciphertext),
|
|
164
|
+
nonce: this.bytesToBase64(nonce),
|
|
165
|
+
ephemeralPublicKey: this.bytesToBase64(ephemeralKeyPair.publicKey)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Decrypt job parameters received from the backend
|
|
170
|
+
*/
|
|
171
|
+
async decryptFromBackend(encrypted) {
|
|
172
|
+
await this.sodiumReady;
|
|
173
|
+
if (!this.keyPair) {
|
|
174
|
+
throw new Error("Local key pair not configured");
|
|
175
|
+
}
|
|
176
|
+
const privateKey = this.base64ToBytes(this.keyPair.privateKey);
|
|
177
|
+
const ephemeralPublicKey = this.base64ToBytes(encrypted.ephemeralPublicKey);
|
|
178
|
+
const nonce = this.base64ToBytes(encrypted.nonce);
|
|
179
|
+
const ciphertext = this.base64ToBytes(encrypted.ciphertext);
|
|
180
|
+
const decrypted = sodium.crypto_box_open_easy(
|
|
181
|
+
ciphertext,
|
|
182
|
+
nonce,
|
|
183
|
+
ephemeralPublicKey,
|
|
184
|
+
privateKey
|
|
185
|
+
);
|
|
186
|
+
if (!decrypted) {
|
|
187
|
+
throw new Error("Decryption failed - invalid ciphertext or keys");
|
|
188
|
+
}
|
|
189
|
+
return new TextDecoder().decode(decrypted);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Encrypt job parameters for local storage (using secretbox)
|
|
193
|
+
*/
|
|
194
|
+
async encryptLocal(plaintext, key) {
|
|
195
|
+
await this.sodiumReady;
|
|
196
|
+
const keyBytes = this.deriveKey(key);
|
|
197
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
198
|
+
const message = new TextEncoder().encode(plaintext);
|
|
199
|
+
const ciphertext = sodium.crypto_secretbox_easy(message, nonce, keyBytes);
|
|
200
|
+
return {
|
|
201
|
+
ciphertext: this.bytesToBase64(ciphertext),
|
|
202
|
+
nonce: this.bytesToBase64(nonce)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Decrypt locally stored job parameters
|
|
207
|
+
*/
|
|
208
|
+
async decryptLocal(encrypted, key) {
|
|
209
|
+
await this.sodiumReady;
|
|
210
|
+
const keyBytes = this.deriveKey(key);
|
|
211
|
+
const nonce = this.base64ToBytes(encrypted.nonce);
|
|
212
|
+
const ciphertext = this.base64ToBytes(encrypted.ciphertext);
|
|
213
|
+
const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, keyBytes);
|
|
214
|
+
if (!decrypted) {
|
|
215
|
+
throw new Error("Local decryption failed");
|
|
216
|
+
}
|
|
217
|
+
return new TextDecoder().decode(decrypted);
|
|
218
|
+
}
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Utility Methods
|
|
221
|
+
// ============================================================================
|
|
222
|
+
generateTokenId() {
|
|
223
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
224
|
+
}
|
|
225
|
+
base64UrlEncode(str) {
|
|
226
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
227
|
+
}
|
|
228
|
+
base64UrlDecode(str) {
|
|
229
|
+
const padding = "=".repeat((4 - str.length % 4) % 4);
|
|
230
|
+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
|
|
231
|
+
return atob(base64);
|
|
232
|
+
}
|
|
233
|
+
hmacSha256(message, secret) {
|
|
234
|
+
const key = new TextEncoder().encode(secret);
|
|
235
|
+
const msg = new TextEncoder().encode(message);
|
|
236
|
+
const hash = sodium.crypto_auth(msg, key);
|
|
237
|
+
return this.bytesToBase64(hash);
|
|
238
|
+
}
|
|
239
|
+
timingSafeEqual(a, b) {
|
|
240
|
+
if (a.length !== b.length) return false;
|
|
241
|
+
let result = 0;
|
|
242
|
+
for (let i = 0; i < a.length; i++) {
|
|
243
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
244
|
+
}
|
|
245
|
+
return result === 0;
|
|
246
|
+
}
|
|
247
|
+
bytesToBase64(bytes) {
|
|
248
|
+
const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
|
|
249
|
+
return btoa(binString);
|
|
250
|
+
}
|
|
251
|
+
base64ToBytes(base64) {
|
|
252
|
+
const binString = atob(base64);
|
|
253
|
+
return Uint8Array.from(binString, (m) => m.charCodeAt(0));
|
|
254
|
+
}
|
|
255
|
+
deriveKey(password) {
|
|
256
|
+
const passwordBytes = new TextEncoder().encode(password);
|
|
257
|
+
return sodium.crypto_generichash(32, passwordBytes, null);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
async function createEdgeNodeSecurity(options = {}) {
|
|
261
|
+
const security = new EdgeNodeSecurity(options);
|
|
262
|
+
await security.initialize();
|
|
263
|
+
return security;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export {
|
|
267
|
+
EdgeNodeSecurity,
|
|
268
|
+
createEdgeNodeSecurity
|
|
269
|
+
};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var RainfallError = class _RainfallError extends Error {
|
|
3
|
+
constructor(message, code, statusCode, details) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.statusCode = statusCode;
|
|
7
|
+
this.details = details;
|
|
8
|
+
this.name = "RainfallError";
|
|
9
|
+
Object.setPrototypeOf(this, _RainfallError.prototype);
|
|
10
|
+
}
|
|
11
|
+
toJSON() {
|
|
12
|
+
return {
|
|
13
|
+
name: this.name,
|
|
14
|
+
code: this.code,
|
|
15
|
+
message: this.message,
|
|
16
|
+
statusCode: this.statusCode,
|
|
17
|
+
details: this.details
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
var AuthenticationError = class _AuthenticationError extends RainfallError {
|
|
22
|
+
constructor(message = "Invalid API key", details) {
|
|
23
|
+
super(message, "AUTHENTICATION_ERROR", 401, details);
|
|
24
|
+
this.name = "AuthenticationError";
|
|
25
|
+
Object.setPrototypeOf(this, _AuthenticationError.prototype);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
var RateLimitError = class _RateLimitError extends RainfallError {
|
|
29
|
+
retryAfter;
|
|
30
|
+
limit;
|
|
31
|
+
remaining;
|
|
32
|
+
resetAt;
|
|
33
|
+
constructor(message = "Rate limit exceeded", retryAfter = 60, limit = 0, remaining = 0, resetAt) {
|
|
34
|
+
super(message, "RATE_LIMIT_ERROR", 429, { retryAfter, limit, remaining });
|
|
35
|
+
this.name = "RateLimitError";
|
|
36
|
+
this.retryAfter = retryAfter;
|
|
37
|
+
this.limit = limit;
|
|
38
|
+
this.remaining = remaining;
|
|
39
|
+
this.resetAt = resetAt || new Date(Date.now() + retryAfter * 1e3);
|
|
40
|
+
Object.setPrototypeOf(this, _RateLimitError.prototype);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
var ValidationError = class _ValidationError extends RainfallError {
|
|
44
|
+
constructor(message, details) {
|
|
45
|
+
super(message, "VALIDATION_ERROR", 400, details);
|
|
46
|
+
this.name = "ValidationError";
|
|
47
|
+
Object.setPrototypeOf(this, _ValidationError.prototype);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
var NotFoundError = class _NotFoundError extends RainfallError {
|
|
51
|
+
constructor(resource, identifier) {
|
|
52
|
+
super(
|
|
53
|
+
`${resource}${identifier ? ` '${identifier}'` : ""} not found`,
|
|
54
|
+
"NOT_FOUND_ERROR",
|
|
55
|
+
404,
|
|
56
|
+
{ resource, identifier }
|
|
57
|
+
);
|
|
58
|
+
this.name = "NotFoundError";
|
|
59
|
+
Object.setPrototypeOf(this, _NotFoundError.prototype);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var ServerError = class _ServerError extends RainfallError {
|
|
63
|
+
constructor(message = "Internal server error", statusCode = 500) {
|
|
64
|
+
super(message, "SERVER_ERROR", statusCode);
|
|
65
|
+
this.name = "ServerError";
|
|
66
|
+
Object.setPrototypeOf(this, _ServerError.prototype);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
var TimeoutError = class _TimeoutError extends RainfallError {
|
|
70
|
+
constructor(timeoutMs) {
|
|
71
|
+
super(`Request timed out after ${timeoutMs}ms`, "TIMEOUT_ERROR", 408);
|
|
72
|
+
this.name = "TimeoutError";
|
|
73
|
+
Object.setPrototypeOf(this, _TimeoutError.prototype);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var NetworkError = class _NetworkError extends RainfallError {
|
|
77
|
+
constructor(message = "Network error", details) {
|
|
78
|
+
super(message, "NETWORK_ERROR", void 0, details);
|
|
79
|
+
this.name = "NetworkError";
|
|
80
|
+
Object.setPrototypeOf(this, _NetworkError.prototype);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
var ToolNotFoundError = class _ToolNotFoundError extends RainfallError {
|
|
84
|
+
constructor(toolId) {
|
|
85
|
+
super(`Tool '${toolId}' not found`, "TOOL_NOT_FOUND", 404, { toolId });
|
|
86
|
+
this.name = "ToolNotFoundError";
|
|
87
|
+
Object.setPrototypeOf(this, _ToolNotFoundError.prototype);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
function parseErrorResponse(response, data) {
|
|
91
|
+
const statusCode = response.status;
|
|
92
|
+
if (statusCode === 429) {
|
|
93
|
+
const retryAfter = parseInt(response.headers.get("retry-after") || "60", 10);
|
|
94
|
+
const limit = parseInt(response.headers.get("x-ratelimit-limit") || "0", 10);
|
|
95
|
+
const remaining = parseInt(response.headers.get("x-ratelimit-remaining") || "0", 10);
|
|
96
|
+
const resetHeader = response.headers.get("x-ratelimit-reset");
|
|
97
|
+
const resetAt = resetHeader ? new Date(parseInt(resetHeader, 10) * 1e3) : void 0;
|
|
98
|
+
return new RateLimitError(
|
|
99
|
+
typeof data === "object" && data && "message" in data ? String(data.message) : "Rate limit exceeded",
|
|
100
|
+
retryAfter,
|
|
101
|
+
limit,
|
|
102
|
+
remaining,
|
|
103
|
+
resetAt
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
switch (statusCode) {
|
|
107
|
+
case 401:
|
|
108
|
+
return new AuthenticationError(
|
|
109
|
+
typeof data === "object" && data && "message" in data ? String(data.message) : "Invalid API key"
|
|
110
|
+
);
|
|
111
|
+
case 404:
|
|
112
|
+
return new NotFoundError(
|
|
113
|
+
typeof data === "object" && data && "resource" in data ? String(data.resource) : "Resource",
|
|
114
|
+
typeof data === "object" && data && "identifier" in data ? String(data.identifier) : void 0
|
|
115
|
+
);
|
|
116
|
+
case 400:
|
|
117
|
+
return new ValidationError(
|
|
118
|
+
typeof data === "object" && data && "message" in data ? String(data.message) : "Invalid request",
|
|
119
|
+
typeof data === "object" && data && "details" in data ? data.details : void 0
|
|
120
|
+
);
|
|
121
|
+
case 500:
|
|
122
|
+
case 502:
|
|
123
|
+
case 503:
|
|
124
|
+
case 504:
|
|
125
|
+
return new ServerError(
|
|
126
|
+
typeof data === "object" && data && "message" in data ? String(data.message) : "Server error",
|
|
127
|
+
statusCode
|
|
128
|
+
);
|
|
129
|
+
default:
|
|
130
|
+
return new RainfallError(
|
|
131
|
+
typeof data === "object" && data && "message" in data ? String(data.message) : `HTTP ${statusCode}`,
|
|
132
|
+
"UNKNOWN_ERROR",
|
|
133
|
+
statusCode,
|
|
134
|
+
typeof data === "object" ? data : void 0
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export {
|
|
140
|
+
RainfallError,
|
|
141
|
+
AuthenticationError,
|
|
142
|
+
RateLimitError,
|
|
143
|
+
ValidationError,
|
|
144
|
+
NotFoundError,
|
|
145
|
+
ServerError,
|
|
146
|
+
TimeoutError,
|
|
147
|
+
NetworkError,
|
|
148
|
+
ToolNotFoundError,
|
|
149
|
+
parseErrorResponse
|
|
150
|
+
};
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// src/security/edge-node.ts
|
|
2
|
+
import sodium from "libsodium-wrappers-sumo";
|
|
3
|
+
var EdgeNodeSecurity = class {
|
|
4
|
+
sodiumReady;
|
|
5
|
+
backendSecret;
|
|
6
|
+
keyPair;
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.sodiumReady = sodium.ready;
|
|
9
|
+
this.backendSecret = options.backendSecret;
|
|
10
|
+
this.keyPair = options.keyPair;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Initialize libsodium
|
|
14
|
+
*/
|
|
15
|
+
async initialize() {
|
|
16
|
+
await this.sodiumReady;
|
|
17
|
+
}
|
|
18
|
+
// ============================================================================
|
|
19
|
+
// JWT Token Management
|
|
20
|
+
// ============================================================================
|
|
21
|
+
/**
|
|
22
|
+
* Generate a JWT token for an edge node
|
|
23
|
+
* Note: In production, this is done by the backend. This is for testing.
|
|
24
|
+
*/
|
|
25
|
+
generateJWT(edgeNodeId, subscriberId, expiresInDays = 30) {
|
|
26
|
+
if (!this.backendSecret) {
|
|
27
|
+
throw new Error("Backend secret not configured");
|
|
28
|
+
}
|
|
29
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
30
|
+
const exp = now + expiresInDays * 24 * 60 * 60;
|
|
31
|
+
const jti = this.generateTokenId();
|
|
32
|
+
const payload = {
|
|
33
|
+
sub: edgeNodeId,
|
|
34
|
+
iss: "rainfall-backend",
|
|
35
|
+
iat: now,
|
|
36
|
+
exp,
|
|
37
|
+
jti,
|
|
38
|
+
scope: ["edge:heartbeat", "edge:claim", "edge:submit", "edge:queue"]
|
|
39
|
+
};
|
|
40
|
+
const header = { alg: "HS256", typ: "JWT" };
|
|
41
|
+
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
|
42
|
+
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
|
43
|
+
const signature = this.hmacSha256(
|
|
44
|
+
`${encodedHeader}.${encodedPayload}`,
|
|
45
|
+
this.backendSecret
|
|
46
|
+
);
|
|
47
|
+
const encodedSignature = this.base64UrlEncode(signature);
|
|
48
|
+
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate a JWT token
|
|
52
|
+
*/
|
|
53
|
+
validateJWT(token) {
|
|
54
|
+
const parts = token.split(".");
|
|
55
|
+
if (parts.length !== 3) {
|
|
56
|
+
throw new Error("Invalid JWT format");
|
|
57
|
+
}
|
|
58
|
+
const [encodedHeader, encodedPayload, encodedSignature] = parts;
|
|
59
|
+
if (this.backendSecret) {
|
|
60
|
+
const expectedSignature = this.hmacSha256(
|
|
61
|
+
`${encodedHeader}.${encodedPayload}`,
|
|
62
|
+
this.backendSecret
|
|
63
|
+
);
|
|
64
|
+
const expectedEncoded = this.base64UrlEncode(expectedSignature);
|
|
65
|
+
if (!this.timingSafeEqual(encodedSignature, expectedEncoded)) {
|
|
66
|
+
throw new Error("Invalid JWT signature");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const payload = JSON.parse(this.base64UrlDecode(encodedPayload));
|
|
70
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
71
|
+
if (payload.exp < now) {
|
|
72
|
+
throw new Error("JWT token expired");
|
|
73
|
+
}
|
|
74
|
+
if (payload.iss !== "rainfall-backend") {
|
|
75
|
+
throw new Error("Invalid JWT issuer");
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
edgeNodeId: payload.sub,
|
|
79
|
+
subscriberId: payload.sub,
|
|
80
|
+
// Same as edge node ID for now
|
|
81
|
+
scopes: payload.scope,
|
|
82
|
+
expiresAt: payload.exp
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Extract bearer token from Authorization header
|
|
87
|
+
*/
|
|
88
|
+
extractBearerToken(authHeader) {
|
|
89
|
+
if (!authHeader) return null;
|
|
90
|
+
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
|
91
|
+
return match ? match[1] : null;
|
|
92
|
+
}
|
|
93
|
+
// ============================================================================
|
|
94
|
+
// ACL Enforcement
|
|
95
|
+
// ============================================================================
|
|
96
|
+
/**
|
|
97
|
+
* Check if an edge node is allowed to perform an action on a job
|
|
98
|
+
* Rule: Edge nodes can only access jobs for their own subscriber
|
|
99
|
+
*/
|
|
100
|
+
checkACL(check) {
|
|
101
|
+
if (check.subscriberId !== check.jobSubscriberId) {
|
|
102
|
+
return {
|
|
103
|
+
allowed: false,
|
|
104
|
+
reason: `Edge node ${check.edgeNodeId} cannot access jobs from subscriber ${check.jobSubscriberId}`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const allowedActions = ["heartbeat", "claim", "submit", "queue"];
|
|
108
|
+
if (!allowedActions.includes(check.action)) {
|
|
109
|
+
return {
|
|
110
|
+
allowed: false,
|
|
111
|
+
reason: `Unknown action: ${check.action}`
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { allowed: true };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Middleware-style ACL check for job operations
|
|
118
|
+
*/
|
|
119
|
+
requireSameSubscriber(edgeNodeSubscriberId, jobSubscriberId, operation) {
|
|
120
|
+
const result = this.checkACL({
|
|
121
|
+
edgeNodeId: edgeNodeSubscriberId,
|
|
122
|
+
subscriberId: edgeNodeSubscriberId,
|
|
123
|
+
jobSubscriberId,
|
|
124
|
+
action: operation
|
|
125
|
+
});
|
|
126
|
+
if (!result.allowed) {
|
|
127
|
+
throw new Error(result.reason);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// ============================================================================
|
|
131
|
+
// Encryption (Libsodium)
|
|
132
|
+
// ============================================================================
|
|
133
|
+
/**
|
|
134
|
+
* Generate a new Ed25519 key pair for an edge node
|
|
135
|
+
*/
|
|
136
|
+
async generateKeyPair() {
|
|
137
|
+
await this.sodiumReady;
|
|
138
|
+
const keyPair = sodium.crypto_box_keypair();
|
|
139
|
+
return {
|
|
140
|
+
publicKey: this.bytesToBase64(keyPair.publicKey),
|
|
141
|
+
privateKey: this.bytesToBase64(keyPair.privateKey)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Encrypt job parameters for a target edge node using its public key
|
|
146
|
+
*/
|
|
147
|
+
async encryptForEdgeNode(plaintext, targetPublicKeyBase64) {
|
|
148
|
+
await this.sodiumReady;
|
|
149
|
+
if (!this.keyPair) {
|
|
150
|
+
throw new Error("Local key pair not configured");
|
|
151
|
+
}
|
|
152
|
+
const targetPublicKey = this.base64ToBytes(targetPublicKeyBase64);
|
|
153
|
+
const ephemeralKeyPair = sodium.crypto_box_keypair();
|
|
154
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_box_NONCEBYTES);
|
|
155
|
+
const message = new TextEncoder().encode(plaintext);
|
|
156
|
+
const ciphertext = sodium.crypto_box_easy(
|
|
157
|
+
message,
|
|
158
|
+
nonce,
|
|
159
|
+
targetPublicKey,
|
|
160
|
+
ephemeralKeyPair.privateKey
|
|
161
|
+
);
|
|
162
|
+
return {
|
|
163
|
+
ciphertext: this.bytesToBase64(ciphertext),
|
|
164
|
+
nonce: this.bytesToBase64(nonce),
|
|
165
|
+
ephemeralPublicKey: this.bytesToBase64(ephemeralKeyPair.publicKey)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Decrypt job parameters received from the backend
|
|
170
|
+
*/
|
|
171
|
+
async decryptFromBackend(encrypted) {
|
|
172
|
+
await this.sodiumReady;
|
|
173
|
+
if (!this.keyPair) {
|
|
174
|
+
throw new Error("Local key pair not configured");
|
|
175
|
+
}
|
|
176
|
+
const privateKey = this.base64ToBytes(this.keyPair.privateKey);
|
|
177
|
+
const ephemeralPublicKey = this.base64ToBytes(encrypted.ephemeralPublicKey);
|
|
178
|
+
const nonce = this.base64ToBytes(encrypted.nonce);
|
|
179
|
+
const ciphertext = this.base64ToBytes(encrypted.ciphertext);
|
|
180
|
+
const decrypted = sodium.crypto_box_open_easy(
|
|
181
|
+
ciphertext,
|
|
182
|
+
nonce,
|
|
183
|
+
ephemeralPublicKey,
|
|
184
|
+
privateKey
|
|
185
|
+
);
|
|
186
|
+
if (!decrypted) {
|
|
187
|
+
throw new Error("Decryption failed - invalid ciphertext or keys");
|
|
188
|
+
}
|
|
189
|
+
return new TextDecoder().decode(decrypted);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Encrypt job parameters for local storage (using secretbox)
|
|
193
|
+
*/
|
|
194
|
+
async encryptLocal(plaintext, key) {
|
|
195
|
+
await this.sodiumReady;
|
|
196
|
+
const keyBytes = this.deriveKey(key);
|
|
197
|
+
const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
|
|
198
|
+
const message = new TextEncoder().encode(plaintext);
|
|
199
|
+
const ciphertext = sodium.crypto_secretbox_easy(message, nonce, keyBytes);
|
|
200
|
+
return {
|
|
201
|
+
ciphertext: this.bytesToBase64(ciphertext),
|
|
202
|
+
nonce: this.bytesToBase64(nonce)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Decrypt locally stored job parameters
|
|
207
|
+
*/
|
|
208
|
+
async decryptLocal(encrypted, key) {
|
|
209
|
+
await this.sodiumReady;
|
|
210
|
+
const keyBytes = this.deriveKey(key);
|
|
211
|
+
const nonce = this.base64ToBytes(encrypted.nonce);
|
|
212
|
+
const ciphertext = this.base64ToBytes(encrypted.ciphertext);
|
|
213
|
+
const decrypted = sodium.crypto_secretbox_open_easy(ciphertext, nonce, keyBytes);
|
|
214
|
+
if (!decrypted) {
|
|
215
|
+
throw new Error("Local decryption failed");
|
|
216
|
+
}
|
|
217
|
+
return new TextDecoder().decode(decrypted);
|
|
218
|
+
}
|
|
219
|
+
// ============================================================================
|
|
220
|
+
// Utility Methods
|
|
221
|
+
// ============================================================================
|
|
222
|
+
generateTokenId() {
|
|
223
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`;
|
|
224
|
+
}
|
|
225
|
+
base64UrlEncode(str) {
|
|
226
|
+
return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
227
|
+
}
|
|
228
|
+
base64UrlDecode(str) {
|
|
229
|
+
const padding = "=".repeat((4 - str.length % 4) % 4);
|
|
230
|
+
const base64 = str.replace(/-/g, "+").replace(/_/g, "/") + padding;
|
|
231
|
+
return atob(base64);
|
|
232
|
+
}
|
|
233
|
+
hmacSha256(message, secret) {
|
|
234
|
+
const key = new TextEncoder().encode(secret);
|
|
235
|
+
const msg = new TextEncoder().encode(message);
|
|
236
|
+
const hash = sodium.crypto_auth(msg, key);
|
|
237
|
+
return this.bytesToBase64(hash);
|
|
238
|
+
}
|
|
239
|
+
timingSafeEqual(a, b) {
|
|
240
|
+
if (a.length !== b.length) return false;
|
|
241
|
+
let result = 0;
|
|
242
|
+
for (let i = 0; i < a.length; i++) {
|
|
243
|
+
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
244
|
+
}
|
|
245
|
+
return result === 0;
|
|
246
|
+
}
|
|
247
|
+
bytesToBase64(bytes) {
|
|
248
|
+
const binString = Array.from(bytes, (b) => String.fromCharCode(b)).join("");
|
|
249
|
+
return btoa(binString);
|
|
250
|
+
}
|
|
251
|
+
base64ToBytes(base64) {
|
|
252
|
+
const binString = atob(base64);
|
|
253
|
+
return Uint8Array.from(binString, (m) => m.charCodeAt(0));
|
|
254
|
+
}
|
|
255
|
+
deriveKey(password) {
|
|
256
|
+
const passwordBytes = new TextEncoder().encode(password);
|
|
257
|
+
return sodium.crypto_generichash(32, passwordBytes, null);
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
async function createEdgeNodeSecurity(options = {}) {
|
|
261
|
+
const security = new EdgeNodeSecurity(options);
|
|
262
|
+
await security.initialize();
|
|
263
|
+
return security;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export {
|
|
267
|
+
EdgeNodeSecurity,
|
|
268
|
+
createEdgeNodeSecurity
|
|
269
|
+
};
|