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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.9.19",
3
+ "version": "0.9.20",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -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 applied: rate limiting (RPC_RATE_LIMIT_MAX), explicit intent
147
- // requirement for runs, operation allowlist for live-control reads, and cwd
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(events, "pi-crew:rpc:ping", (raw) =>
157
- reply(events, "pi-crew:rpc:ping", requestId(raw), {
158
- success: true,
159
- data: { version: PI_CREW_RPC_VERSION },
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(events, "pi-crew:rpc:run", async (raw) => {
163
- const id = requestId(raw);
164
- try {
165
- // SECURITY (HIGH #4 fix): Rate limit RPC run requests
166
- const rateLimit = checkRpcRateLimit();
167
- if (!rateLimit.allowed) {
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: `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.`,
222
+ error: error instanceof Error ? error.message : String(error),
171
223
  });
172
- return;
173
224
  }
174
- recordRpcRun();
175
- const ctx = getCtx();
176
- if (!ctx) throw new Error("No active pi-crew session context.");
177
- // Validate payload: only allow known fields from TeamToolParamsValue
178
- const ALLOWED_RPC_RUN_KEYS = new Set(["goal", "team", "workflow", "async", "cwd", "config", "skill", "model"]);
179
- let params: TeamToolParamsValue;
180
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
181
- const filtered: Record<string, unknown> = {
182
- ...(raw as object),
183
- };
184
- // Strip any keys not in the allowlist to prevent injection of unexpected fields
185
- for (const key of Object.keys(filtered)) {
186
- if (!ALLOWED_RPC_RUN_KEYS.has(key)) delete filtered[key];
187
- }
188
- params = {
189
- ...filtered,
190
- action: "run",
191
- } as TeamToolParamsValue;
192
- } else {
193
- params = { action: "run" };
194
- }
195
- const permission = isAllowedRpcRunParams(params);
196
- if (!permission.ok) {
197
- reply(events, "pi-crew:rpc:run", id, {
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: permission.error ?? "permission denied",
254
+ error: error instanceof Error ? error.message : String(error),
200
255
  });
201
- return;
202
256
  }
203
- const result = await handleTeamTool(params, ctx);
204
- reply(
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(events, "pi-crew:rpc:live-control", async (raw) => {
250
- const id = requestId(raw);
251
- try {
252
- const ctx = getCtx();
253
- if (!ctx) throw new Error("No active pi-crew session context.");
254
- const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
255
- const rawOp = typeof obj.operation === "string" ? obj.operation : "steer-agent";
256
- // SECURITY: Reject any operation not in the explicit allowlist.
257
- // Mutating ops (approve-plan, cancel-plan, steer-agent, stop-agent, etc.)
258
- // require user consent and are blocked here.
259
- if (!isAllowedRpcOperation(rawOp)) {
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: `RPC operation '${rawOp}' is not allowed. Allowed: ${[...RPC_ALLOWED_OPERATIONS].join(", ")}`,
313
+ error: error instanceof Error ? error.message : String(error),
263
314
  });
264
- return;
265
315
  }
266
- const result = await handleTeamTool(
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
+ }