pi-crew 0.9.19 → 0.9.20
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/CHANGELOG.md +28 -0
- package/package.json +1 -1
- package/src/extension/cross-extension-rpc.ts +149 -131
- package/src/extension/rpc-hmac.ts +236 -0
- package/src/runtime/child-pi.ts +62 -81
- package/src/runtime/cross-extension-rpc.ts +22 -3
- package/src/tools/safe-bash-extension.ts +2 -2
- package/src/tools/safe-bash.ts +100 -0
- package/src/utils/env-filter.ts +49 -0
- package/docs/A +0 -358
- package/docs/M-A +0 -357
- package/docs/Y +0 -357
- package/docs/a +0 -357
- package/docs/aA +0 -358
- package/docs//303/242mmaAAA/303/242 +0 -357
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,33 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [v0.9.20] — security hardening: RPC HMAC auth + per-task API key scoping + safe-bash whitelist (2026-07-06)
|
|
4
|
+
|
|
5
|
+
Three defense-in-depth security upgrades distilled from cross-source research (52+ repos) and the H-1/H-2/H-6 audit findings. All three are additive (no breaking changes); two are opt-in via env vars, one is default-on.
|
|
6
|
+
|
|
7
|
+
### Highlights
|
|
8
|
+
|
|
9
|
+
- **RPC HMAC authentication (opt-in).** All cross-extension RPC channels (`ping`/`run`/`status`/`live-control` at extension layer; `ping`/`spawn`/`stop` at runtime layer) now support HMAC-SHA256 origin signing. When `PI_CREW_RPC_SECRET` is set, every request must carry a valid signature with timestamp + nonce (anti-replay) and channel binding (cross-channel replay guard). Timing-safe comparison prevents timing attacks. Unset = backward-compatible passthrough. Closes the H-2 authorization-bypass finding where any co-installed extension could spoof `source='pi-crew'` to spawn/kill subagents. New module `src/extension/rpc-hmac.ts`; 24 tests in `test/unit/rpc-hmac-auth.test.ts`.
|
|
10
|
+
- **Per-task API key scoping (default-on).** Child workers previously inherited ALL model provider API keys via a broad allowlist. `buildChildPiSpawnOptions()` now takes an optional `model` param and calls `buildScopedAllowList()` to inject only the provider keys needed for the assigned model. When no model is given, only `BASE_ALLOWLIST` system vars pass through (zero provider keys leak). Reduces blast radius: a compromised child only gets keys for its model. Helpers `providerEnvKeys()` + `buildScopedAllowList()` in `src/utils/env-filter.ts`; per-task wiring in `src/runtime/child-pi.ts`; tests in `test/unit/api-key-scoping.test.ts`.
|
|
11
|
+
- **Safe-bash whitelist mode (opt-in).** A deny-by-default whitelist as an opt-in alternative to the legacy blacklist `isDangerous()`. Enabled via `PI_CREW_SAFE_BASH_MODE=whitelist`. Only 16 read-only commands allowed (`ls cat head tail wc grep find echo pwd date whoami uname df du file stat`). Shell metacharacter regex blocks chaining/substitution before the first-token check (`ls; rm file` cannot smuggle `rm`); unmatched quotes are rejected as malformed input. Legacy blacklist path unchanged when not enabled. `src/tools/safe-bash.ts`; 21 tests in `test/unit/safe-bash-whitelist.test.ts`.
|
|
12
|
+
|
|
13
|
+
### Verification
|
|
14
|
+
|
|
15
|
+
| Gate | Result |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Affected unit tests (20 files) | ✅ 187/187 pass |
|
|
18
|
+
| safe-bash (blacklist + whitelist + ANSI) | ✅ 51/51 |
|
|
19
|
+
| env-filter + API key scoping | ✅ 26/26 |
|
|
20
|
+
| cross-extension-rpc + HMAC | ✅ 48/48 |
|
|
21
|
+
| child-pi (hardening/exit/redaction/compaction) | ✅ 35/35 |
|
|
22
|
+
| security (hardening/artifact/cwd/import/output) | ✅ 27/27 |
|
|
23
|
+
| Live E2E (research workflow) | ✅ 3/3 tasks, end-to-end |
|
|
24
|
+
| Extension load | ✅ |
|
|
25
|
+
|
|
26
|
+
### Files
|
|
27
|
+
|
|
28
|
+
- NEW: `src/extension/rpc-hmac.ts`, `test/unit/rpc-hmac-auth.test.ts`, `test/unit/api-key-scoping.test.ts`, `test/unit/safe-bash-whitelist.test.ts`
|
|
29
|
+
- MOD: `src/extension/cross-extension-rpc.ts`, `src/runtime/cross-extension-rpc.ts`, `src/utils/env-filter.ts`, `src/runtime/child-pi.ts`, `src/tools/safe-bash.ts`, `src/tools/safe-bash-extension.ts`, `test/unit/env-filter.test.ts`, `test/unit/security-hardening.test.ts`
|
|
30
|
+
|
|
3
31
|
## [v0.9.19] — plan-execute workflow + main-session→planner analysis handoff (2026-07-03)
|
|
4
32
|
|
|
5
33
|
New builtin workflow for the common "I already analyzed this — just plan + execute + verify it" case, plus a generic `analysis`/`analysisPath` channel on `team action='run'` for handing caller-session context to planner child workers.
|
package/package.json
CHANGED
|
@@ -3,6 +3,7 @@ import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
|
|
|
3
3
|
import { resolveContainedPath } from "../utils/safe-paths.ts";
|
|
4
4
|
// Lazy-loaded to avoid pulling team-tool.ts (and its entire runtime chain) into module load.
|
|
5
5
|
import type { handleTeamTool as HandleTeamToolFn } from "./team-tool.ts";
|
|
6
|
+
import { withHmacVerification } from "./rpc-hmac.ts";
|
|
6
7
|
|
|
7
8
|
let _cachedHandleTeamTool: typeof HandleTeamToolFn | undefined;
|
|
8
9
|
async function handleTeamTool(
|
|
@@ -143,9 +144,8 @@ function on(events: EventBusLike, channel: string, handler: (raw: unknown) => vo
|
|
|
143
144
|
|
|
144
145
|
// SECURITY TRUST BOUNDARY: RPC channels (pi-crew:rpc:run, pi-crew:rpc:status,
|
|
145
146
|
// pi-crew:rpc:live-control) are accessible to any extension on the shared event
|
|
146
|
-
// bus. Mitigations
|
|
147
|
-
//
|
|
148
|
-
// containment validation. A full fix requires event-bus-level origin signing.
|
|
147
|
+
// bus. Mitigations: rate limiting, explicit intent, operation allowlist, cwd
|
|
148
|
+
// containment validation, and HMAC origin signing (see rpc-hmac.ts).
|
|
149
149
|
|
|
150
150
|
export function registerPiCrewRpc(
|
|
151
151
|
events: EventBusLike | undefined,
|
|
@@ -153,150 +153,168 @@ export function registerPiCrewRpc(
|
|
|
153
153
|
): PiCrewRpcHandle | undefined {
|
|
154
154
|
if (!events) return undefined;
|
|
155
155
|
const unsubs = [
|
|
156
|
-
on(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
on(
|
|
157
|
+
events,
|
|
158
|
+
"pi-crew:rpc:ping",
|
|
159
|
+
withHmacVerification(
|
|
160
|
+
(raw) =>
|
|
161
|
+
reply(events, "pi-crew:rpc:ping", requestId(raw), {
|
|
162
|
+
success: true,
|
|
163
|
+
data: { version: PI_CREW_RPC_VERSION },
|
|
164
|
+
}),
|
|
165
|
+
"pi-crew:rpc:ping",
|
|
166
|
+
),
|
|
161
167
|
),
|
|
162
|
-
on(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
+
on(
|
|
169
|
+
events,
|
|
170
|
+
"pi-crew:rpc:run",
|
|
171
|
+
withHmacVerification(async (raw) => {
|
|
172
|
+
const id = requestId(raw);
|
|
173
|
+
try {
|
|
174
|
+
// SECURITY (HIGH #4 fix): Rate limit RPC run requests
|
|
175
|
+
const rateLimit = checkRpcRateLimit();
|
|
176
|
+
if (!rateLimit.allowed) {
|
|
177
|
+
reply(events, "pi-crew:rpc:run", id, {
|
|
178
|
+
success: false,
|
|
179
|
+
error: `RPC run rate limit exceeded. Max ${RPC_RATE_LIMIT_MAX} requests per ${RPC_RATE_LIMIT_WINDOW_MS / 1000}s. Retry after ${Math.ceil((rateLimit.retryAfterMs ?? 60000) / 1000)}s.`,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
recordRpcRun();
|
|
184
|
+
const ctx = getCtx();
|
|
185
|
+
if (!ctx) throw new Error("No active pi-crew session context.");
|
|
186
|
+
// Validate payload: only allow known fields from TeamToolParamsValue
|
|
187
|
+
const ALLOWED_RPC_RUN_KEYS = new Set(["goal", "team", "workflow", "async", "cwd", "config", "skill", "model"]);
|
|
188
|
+
let params: TeamToolParamsValue;
|
|
189
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
190
|
+
const filtered: Record<string, unknown> = {
|
|
191
|
+
...(raw as object),
|
|
192
|
+
};
|
|
193
|
+
// Strip any keys not in the allowlist to prevent injection of unexpected fields
|
|
194
|
+
for (const key of Object.keys(filtered)) {
|
|
195
|
+
if (!ALLOWED_RPC_RUN_KEYS.has(key)) delete filtered[key];
|
|
196
|
+
}
|
|
197
|
+
params = {
|
|
198
|
+
...filtered,
|
|
199
|
+
action: "run",
|
|
200
|
+
} as TeamToolParamsValue;
|
|
201
|
+
} else {
|
|
202
|
+
params = { action: "run" };
|
|
203
|
+
}
|
|
204
|
+
const permission = isAllowedRpcRunParams(params);
|
|
205
|
+
if (!permission.ok) {
|
|
206
|
+
reply(events, "pi-crew:rpc:run", id, {
|
|
207
|
+
success: false,
|
|
208
|
+
error: permission.error ?? "permission denied",
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const result = await handleTeamTool(params, ctx);
|
|
213
|
+
reply(
|
|
214
|
+
events,
|
|
215
|
+
"pi-crew:rpc:run",
|
|
216
|
+
id,
|
|
217
|
+
result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details },
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
168
220
|
reply(events, "pi-crew:rpc:run", id, {
|
|
169
221
|
success: false,
|
|
170
|
-
error:
|
|
222
|
+
error: error instanceof Error ? error.message : String(error),
|
|
171
223
|
});
|
|
172
|
-
return;
|
|
173
224
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
225
|
+
}, "pi-crew:rpc:run"),
|
|
226
|
+
),
|
|
227
|
+
on(
|
|
228
|
+
events,
|
|
229
|
+
"pi-crew:rpc:status",
|
|
230
|
+
withHmacVerification(async (raw) => {
|
|
231
|
+
const id = requestId(raw);
|
|
232
|
+
try {
|
|
233
|
+
const ctx = getCtx();
|
|
234
|
+
if (!ctx) throw new Error("No active pi-crew session context.");
|
|
235
|
+
const runId = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as { runId?: string }).runId : undefined;
|
|
236
|
+
const result = await handleTeamTool({ action: "status", runId }, ctx);
|
|
237
|
+
reply(
|
|
238
|
+
events,
|
|
239
|
+
"pi-crew:rpc:status",
|
|
240
|
+
id,
|
|
241
|
+
result.isError
|
|
242
|
+
? { success: false, error: textOf(result) }
|
|
243
|
+
: {
|
|
244
|
+
success: true,
|
|
245
|
+
data: {
|
|
246
|
+
text: textOf(result),
|
|
247
|
+
details: result.details,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
} catch (error) {
|
|
252
|
+
reply(events, "pi-crew:rpc:status", id, {
|
|
198
253
|
success: false,
|
|
199
|
-
error:
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
200
255
|
});
|
|
201
|
-
return;
|
|
202
256
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
events,
|
|
206
|
-
"pi-crew:rpc:run",
|
|
207
|
-
id,
|
|
208
|
-
result.isError ? { success: false, error: textOf(result) } : { success: true, data: result.details },
|
|
209
|
-
);
|
|
210
|
-
} catch (error) {
|
|
211
|
-
reply(events, "pi-crew:rpc:run", id, {
|
|
212
|
-
success: false,
|
|
213
|
-
error: error instanceof Error ? error.message : String(error),
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
}),
|
|
217
|
-
on(events, "pi-crew:rpc:status", async (raw) => {
|
|
218
|
-
const id = requestId(raw);
|
|
219
|
-
try {
|
|
220
|
-
const ctx = getCtx();
|
|
221
|
-
if (!ctx) throw new Error("No active pi-crew session context.");
|
|
222
|
-
const runId = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as { runId?: string }).runId : undefined;
|
|
223
|
-
const result = await handleTeamTool({ action: "status", runId }, ctx);
|
|
224
|
-
reply(
|
|
225
|
-
events,
|
|
226
|
-
"pi-crew:rpc:status",
|
|
227
|
-
id,
|
|
228
|
-
result.isError
|
|
229
|
-
? { success: false, error: textOf(result) }
|
|
230
|
-
: {
|
|
231
|
-
success: true,
|
|
232
|
-
data: {
|
|
233
|
-
text: textOf(result),
|
|
234
|
-
details: result.details,
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
);
|
|
238
|
-
} catch (error) {
|
|
239
|
-
reply(events, "pi-crew:rpc:status", id, {
|
|
240
|
-
success: false,
|
|
241
|
-
error: error instanceof Error ? error.message : String(error),
|
|
242
|
-
});
|
|
243
|
-
}
|
|
244
|
-
}),
|
|
257
|
+
}, "pi-crew:rpc:status"),
|
|
258
|
+
),
|
|
245
259
|
on(events, "pi-crew:live-control", (raw) => {
|
|
246
260
|
const request = parseLiveControlRealtimeMessage(raw);
|
|
247
261
|
if (request) publishLiveControlRealtime(request);
|
|
248
262
|
}),
|
|
249
|
-
on(
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
263
|
+
on(
|
|
264
|
+
events,
|
|
265
|
+
"pi-crew:rpc:live-control",
|
|
266
|
+
withHmacVerification(async (raw) => {
|
|
267
|
+
const id = requestId(raw);
|
|
268
|
+
try {
|
|
269
|
+
const ctx = getCtx();
|
|
270
|
+
if (!ctx) throw new Error("No active pi-crew session context.");
|
|
271
|
+
const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
272
|
+
const rawOp = typeof obj.operation === "string" ? obj.operation : "steer-agent";
|
|
273
|
+
// SECURITY: Reject any operation not in the explicit allowlist.
|
|
274
|
+
// Mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
|
|
275
|
+
// require user consent and are blocked here.
|
|
276
|
+
if (!isAllowedRpcOperation(rawOp)) {
|
|
277
|
+
reply(events, "pi-crew:rpc:live-control", id, {
|
|
278
|
+
success: false,
|
|
279
|
+
error: `RPC operation '${rawOp}' is not allowed. Allowed: ${[...RPC_ALLOWED_OPERATIONS].join(", ")}`,
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const result = await handleTeamTool(
|
|
284
|
+
{
|
|
285
|
+
action: "api",
|
|
286
|
+
runId: typeof obj.runId === "string" ? obj.runId : undefined,
|
|
287
|
+
config: {
|
|
288
|
+
operation: rawOp,
|
|
289
|
+
agentId: obj.agentId,
|
|
290
|
+
message: obj.message,
|
|
291
|
+
prompt: obj.prompt,
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
ctx,
|
|
295
|
+
);
|
|
296
|
+
reply(
|
|
297
|
+
events,
|
|
298
|
+
"pi-crew:rpc:live-control",
|
|
299
|
+
id,
|
|
300
|
+
result.isError
|
|
301
|
+
? { success: false, error: textOf(result) }
|
|
302
|
+
: {
|
|
303
|
+
success: true,
|
|
304
|
+
data: {
|
|
305
|
+
text: textOf(result),
|
|
306
|
+
details: result.details,
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
);
|
|
310
|
+
} catch (error) {
|
|
260
311
|
reply(events, "pi-crew:rpc:live-control", id, {
|
|
261
312
|
success: false,
|
|
262
|
-
error:
|
|
313
|
+
error: error instanceof Error ? error.message : String(error),
|
|
263
314
|
});
|
|
264
|
-
return;
|
|
265
315
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
action: "api",
|
|
269
|
-
runId: typeof obj.runId === "string" ? obj.runId : undefined,
|
|
270
|
-
config: {
|
|
271
|
-
operation: rawOp,
|
|
272
|
-
agentId: obj.agentId,
|
|
273
|
-
message: obj.message,
|
|
274
|
-
prompt: obj.prompt,
|
|
275
|
-
},
|
|
276
|
-
},
|
|
277
|
-
ctx,
|
|
278
|
-
);
|
|
279
|
-
reply(
|
|
280
|
-
events,
|
|
281
|
-
"pi-crew:rpc:live-control",
|
|
282
|
-
id,
|
|
283
|
-
result.isError
|
|
284
|
-
? { success: false, error: textOf(result) }
|
|
285
|
-
: {
|
|
286
|
-
success: true,
|
|
287
|
-
data: {
|
|
288
|
-
text: textOf(result),
|
|
289
|
-
details: result.details,
|
|
290
|
-
},
|
|
291
|
-
},
|
|
292
|
-
);
|
|
293
|
-
} catch (error) {
|
|
294
|
-
reply(events, "pi-crew:rpc:live-control", id, {
|
|
295
|
-
success: false,
|
|
296
|
-
error: error instanceof Error ? error.message : String(error),
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
}),
|
|
316
|
+
}, "pi-crew:rpc:live-control"),
|
|
317
|
+
),
|
|
300
318
|
];
|
|
301
319
|
return { unsubscribe: () => unsubs.forEach((unsub) => unsub()) };
|
|
302
320
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HMAC-based origin signing for cross-extension RPC.
|
|
5
|
+
*
|
|
6
|
+
* Shared secret is distributed via environment variable PI_CREW_RPC_SECRET.
|
|
7
|
+
* When the secret is configured, all RPC requests must include a valid HMAC
|
|
8
|
+
* signature. When the secret is NOT configured, HMAC is disabled (backward
|
|
9
|
+
* compat for setups that don't need it).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* Sender: signRpcRequest(params, "my-extension", "pi-crew:rpc:ping")
|
|
13
|
+
* Receiver: wrap handler with withHmacVerification(handler, channel)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const RPC_HMAC_VERSION = 1;
|
|
17
|
+
|
|
18
|
+
const SECRET_ENV_VAR = "PI_CREW_RPC_SECRET";
|
|
19
|
+
const CLOCK_SKEW_TOLERANCE_MS = 5 * 60 * 1000; // 5 min
|
|
20
|
+
const SIGNATURE_VALIDITY_MS = 10 * 60 * 1000; // 10 min
|
|
21
|
+
|
|
22
|
+
// --- Secret management -------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Get the shared secret. Returns undefined if not configured. */
|
|
25
|
+
export function getRpcSecret(): string | undefined {
|
|
26
|
+
return process.env[SECRET_ENV_VAR];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Set the shared secret (for testing or programmatic setup). */
|
|
30
|
+
export function setRpcSecret(secret: string): void {
|
|
31
|
+
process.env[SECRET_ENV_VAR] = secret;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Clear the shared secret. */
|
|
35
|
+
export function clearRpcSecret(): void {
|
|
36
|
+
delete process.env[SECRET_ENV_VAR];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether HMAC authentication is currently enabled. */
|
|
40
|
+
export function isHmacEnabled(): boolean {
|
|
41
|
+
return typeof process.env[SECRET_ENV_VAR] === "string" && process.env[SECRET_ENV_VAR].length > 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Types -------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export interface RpcSignaturePayload {
|
|
47
|
+
version: number;
|
|
48
|
+
origin: string;
|
|
49
|
+
timestamp: number;
|
|
50
|
+
channel: string;
|
|
51
|
+
nonce: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type RpcSignedPayload = RpcSignaturePayload & { signature: string };
|
|
55
|
+
|
|
56
|
+
// --- Signing -----------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create an HMAC signature for an RPC request.
|
|
60
|
+
*
|
|
61
|
+
* @throws if no secret is configured
|
|
62
|
+
*/
|
|
63
|
+
export function createRpcSignature(origin: string, channel: string, body: unknown): RpcSignedPayload {
|
|
64
|
+
const secret = getRpcSecret();
|
|
65
|
+
if (!secret) {
|
|
66
|
+
throw new Error(`[pi-crew HMAC] Cannot create signature: ${SECRET_ENV_VAR} not set.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const payload: RpcSignaturePayload = {
|
|
70
|
+
version: RPC_HMAC_VERSION,
|
|
71
|
+
origin,
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
channel,
|
|
74
|
+
nonce: randomBytes(16).toString("hex"),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const signature = computeHmac(secret, payload, body);
|
|
78
|
+
return { ...payload, signature };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Verification ------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Verify an HMAC signature.
|
|
85
|
+
*
|
|
86
|
+
* @returns `{ valid: true }` or `{ valid: false, reason: string }`
|
|
87
|
+
*/
|
|
88
|
+
export function verifyRpcSignature(payload: RpcSignedPayload, body: unknown): { valid: true } | { valid: false; reason: string } {
|
|
89
|
+
const secret = getRpcSecret();
|
|
90
|
+
if (!secret) {
|
|
91
|
+
return { valid: false, reason: `[pi-crew HMAC] ${SECRET_ENV_VAR} not configured.` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (payload.version !== RPC_HMAC_VERSION) {
|
|
95
|
+
return { valid: false, reason: `HMAC version mismatch: expected ${RPC_HMAC_VERSION}, got ${payload.version}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const age = now - payload.timestamp;
|
|
100
|
+
if (age < -CLOCK_SKEW_TOLERANCE_MS) {
|
|
101
|
+
return { valid: false, reason: `HMAC timestamp in the future by ${Math.abs(age)}ms` };
|
|
102
|
+
}
|
|
103
|
+
if (age > SIGNATURE_VALIDITY_MS) {
|
|
104
|
+
return { valid: false, reason: `HMAC signature expired (${age}ms old)` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const expected = computeHmac(secret, payload, body);
|
|
108
|
+
return timingSafeCompare(payload.signature, expected) ? { valid: true } : { valid: false, reason: "HMAC signature mismatch" };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Middleware --------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Wrap an RPC handler with HMAC verification.
|
|
115
|
+
*
|
|
116
|
+
* When HMAC is NOT configured (getRpcSecret() returns undefined), requests
|
|
117
|
+
* without signatures are passed through (backward compat). When HMAC IS
|
|
118
|
+
* configured, unsigned or invalid signatures are rejected.
|
|
119
|
+
*/
|
|
120
|
+
export function withHmacVerification<P extends { requestId: string }>(
|
|
121
|
+
handler: (params: P) => unknown | Promise<unknown>,
|
|
122
|
+
_channel: string,
|
|
123
|
+
): (params: P) => unknown | Promise<unknown> {
|
|
124
|
+
return (params: P) => {
|
|
125
|
+
if (!isHmacEnabled()) {
|
|
126
|
+
// No secret configured → backward compat: allow unsigned
|
|
127
|
+
return handler(params);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const sigPayload = extractSignaturePayload(params);
|
|
131
|
+
if (!sigPayload) {
|
|
132
|
+
throw new Error("[pi-crew HMAC] Missing HMAC signature in RPC request.");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Strip HMAC fields from body before verification (HMAC was signed over original params)
|
|
136
|
+
const originalBody = stripHmacFields(params);
|
|
137
|
+
const verification = verifyRpcSignature(sigPayload, originalBody);
|
|
138
|
+
if (!verification.valid) {
|
|
139
|
+
throw new Error(`[pi-crew HMAC] ${verification.reason}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Pass original params (with HMAC fields stripped) to handler
|
|
143
|
+
return handler(originalBody as P);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- Helpers -----------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Attach HMAC signature fields to RPC request params.
|
|
151
|
+
*/
|
|
152
|
+
export function signRpcRequest<P extends Record<string, unknown>>(
|
|
153
|
+
params: P,
|
|
154
|
+
origin: string,
|
|
155
|
+
channel: string,
|
|
156
|
+
): P & {
|
|
157
|
+
hmacVersion: number;
|
|
158
|
+
hmacOrigin: string;
|
|
159
|
+
hmacTimestamp: number;
|
|
160
|
+
hmacChannel: string;
|
|
161
|
+
hmacNonce: string;
|
|
162
|
+
hmacSignature: string;
|
|
163
|
+
} {
|
|
164
|
+
const signed = createRpcSignature(origin, channel, params);
|
|
165
|
+
return {
|
|
166
|
+
...params,
|
|
167
|
+
hmacVersion: signed.version,
|
|
168
|
+
hmacOrigin: signed.origin,
|
|
169
|
+
hmacTimestamp: signed.timestamp,
|
|
170
|
+
hmacChannel: signed.channel,
|
|
171
|
+
hmacNonce: signed.nonce,
|
|
172
|
+
hmacSignature: signed.signature,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Internal ----------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Strip HMAC signature fields from params to get the original body. */
|
|
179
|
+
function stripHmacFields<P extends Record<string, unknown>>(params: P): Partial<P> {
|
|
180
|
+
const result = { ...params };
|
|
181
|
+
delete result.hmacVersion;
|
|
182
|
+
delete result.hmacOrigin;
|
|
183
|
+
delete result.hmacTimestamp;
|
|
184
|
+
delete result.hmacChannel;
|
|
185
|
+
delete result.hmacNonce;
|
|
186
|
+
delete result.hmacSignature;
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function computeHmac(secret: string, payload: RpcSignaturePayload, body: unknown): string {
|
|
191
|
+
const payloadForHmac = {
|
|
192
|
+
version: payload.version,
|
|
193
|
+
origin: payload.origin,
|
|
194
|
+
timestamp: payload.timestamp,
|
|
195
|
+
channel: payload.channel,
|
|
196
|
+
nonce: payload.nonce,
|
|
197
|
+
};
|
|
198
|
+
const message = `${JSON.stringify(payloadForHmac)}:${JSON.stringify(body)}`;
|
|
199
|
+
return createHmac("sha256", secret).update(message).digest("hex");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function timingSafeCompare(a: string, b: string): boolean {
|
|
203
|
+
const bufA = Buffer.from(a, "hex");
|
|
204
|
+
const bufB = Buffer.from(b, "hex");
|
|
205
|
+
const maxLen = Math.max(bufA.length, bufB.length);
|
|
206
|
+
const safeA = Buffer.alloc(maxLen);
|
|
207
|
+
const safeB = Buffer.alloc(maxLen);
|
|
208
|
+
bufA.copy(safeA);
|
|
209
|
+
bufB.copy(safeB);
|
|
210
|
+
const equal = timingSafeEqual(safeA, safeB);
|
|
211
|
+
return equal && bufA.length === bufB.length;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Extract HMAC signature payload from request params. */
|
|
215
|
+
export function extractSignaturePayload(params: unknown): RpcSignedPayload | null {
|
|
216
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) return null;
|
|
217
|
+
const obj = params as Record<string, unknown>;
|
|
218
|
+
if (
|
|
219
|
+
typeof obj.hmacVersion !== "number" ||
|
|
220
|
+
typeof obj.hmacOrigin !== "string" ||
|
|
221
|
+
typeof obj.hmacTimestamp !== "number" ||
|
|
222
|
+
typeof obj.hmacChannel !== "string" ||
|
|
223
|
+
typeof obj.hmacNonce !== "string" ||
|
|
224
|
+
typeof obj.hmacSignature !== "string"
|
|
225
|
+
) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
version: obj.hmacVersion,
|
|
230
|
+
origin: obj.hmacOrigin,
|
|
231
|
+
timestamp: obj.hmacTimestamp,
|
|
232
|
+
channel: obj.hmacChannel,
|
|
233
|
+
nonce: obj.hmacNonce,
|
|
234
|
+
signature: obj.hmacSignature,
|
|
235
|
+
};
|
|
236
|
+
}
|