@openclaw/voice-call 2026.2.12 → 2026.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ import crypto from "node:crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import type { WebhookContext } from "../types.js";
4
+ import { TelnyxProvider } from "./telnyx.js";
5
+
6
+ function createCtx(params?: Partial<WebhookContext>): WebhookContext {
7
+ return {
8
+ headers: {},
9
+ rawBody: "{}",
10
+ url: "http://localhost/voice/webhook",
11
+ method: "POST",
12
+ query: {},
13
+ remoteAddress: "127.0.0.1",
14
+ ...params,
15
+ };
16
+ }
17
+
18
+ function decodeBase64Url(input: string): Buffer {
19
+ const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
20
+ const padLen = (4 - (normalized.length % 4)) % 4;
21
+ const padded = normalized + "=".repeat(padLen);
22
+ return Buffer.from(padded, "base64");
23
+ }
24
+
25
+ describe("TelnyxProvider.verifyWebhook", () => {
26
+ it("fails closed when public key is missing and skipVerification is false", () => {
27
+ const provider = new TelnyxProvider(
28
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
29
+ { skipVerification: false },
30
+ );
31
+
32
+ const result = provider.verifyWebhook(createCtx());
33
+ expect(result.ok).toBe(false);
34
+ });
35
+
36
+ it("allows requests when skipVerification is true (development only)", () => {
37
+ const provider = new TelnyxProvider(
38
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: undefined },
39
+ { skipVerification: true },
40
+ );
41
+
42
+ const result = provider.verifyWebhook(createCtx());
43
+ expect(result.ok).toBe(true);
44
+ });
45
+
46
+ it("fails when signature headers are missing (with public key configured)", () => {
47
+ const provider = new TelnyxProvider(
48
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: "public-key" },
49
+ { skipVerification: false },
50
+ );
51
+
52
+ const result = provider.verifyWebhook(createCtx({ headers: {} }));
53
+ expect(result.ok).toBe(false);
54
+ });
55
+
56
+ it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => {
57
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
58
+
59
+ const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey;
60
+ expect(jwk.kty).toBe("OKP");
61
+ expect(jwk.crv).toBe("Ed25519");
62
+ expect(typeof jwk.x).toBe("string");
63
+
64
+ const rawPublicKey = decodeBase64Url(jwk.x as string);
65
+ const rawPublicKeyBase64 = rawPublicKey.toString("base64");
66
+
67
+ const provider = new TelnyxProvider(
68
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 },
69
+ { skipVerification: false },
70
+ );
71
+
72
+ const rawBody = JSON.stringify({
73
+ event_type: "call.initiated",
74
+ payload: { call_control_id: "x" },
75
+ });
76
+ const timestamp = String(Math.floor(Date.now() / 1000));
77
+ const signedPayload = `${timestamp}|${rawBody}`;
78
+ const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
79
+
80
+ const result = provider.verifyWebhook(
81
+ createCtx({
82
+ rawBody,
83
+ headers: {
84
+ "telnyx-signature-ed25519": signature,
85
+ "telnyx-timestamp": timestamp,
86
+ },
87
+ }),
88
+ );
89
+ expect(result.ok).toBe(true);
90
+ });
91
+
92
+ it("verifies a valid signature with a DER SPKI public key (Base64)", () => {
93
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
94
+ const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
95
+ const spkiDerBase64 = spkiDer.toString("base64");
96
+
97
+ const provider = new TelnyxProvider(
98
+ { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 },
99
+ { skipVerification: false },
100
+ );
101
+
102
+ const rawBody = JSON.stringify({
103
+ event_type: "call.initiated",
104
+ payload: { call_control_id: "x" },
105
+ });
106
+ const timestamp = String(Math.floor(Date.now() / 1000));
107
+ const signedPayload = `${timestamp}|${rawBody}`;
108
+ const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
109
+
110
+ const result = provider.verifyWebhook(
111
+ createCtx({
112
+ rawBody,
113
+ headers: {
114
+ "telnyx-signature-ed25519": signature,
115
+ "telnyx-timestamp": timestamp,
116
+ },
117
+ }),
118
+ );
119
+ expect(result.ok).toBe(true);
120
+ });
121
+ });
@@ -14,6 +14,7 @@ import type {
14
14
  WebhookVerificationResult,
15
15
  } from "../types.js";
16
16
  import type { VoiceCallProvider } from "./base.js";
17
+ import { verifyTelnyxWebhook } from "../webhook-security.js";
17
18
 
18
19
  /**
19
20
  * Telnyx Voice API provider implementation.
@@ -22,8 +23,8 @@ import type { VoiceCallProvider } from "./base.js";
22
23
  * @see https://developers.telnyx.com/docs/api/v2/call-control
23
24
  */
24
25
  export interface TelnyxProviderOptions {
25
- /** Allow unsigned webhooks when no public key is configured */
26
- allowUnsignedWebhooks?: boolean;
26
+ /** Skip webhook signature verification (development only, NOT for production) */
27
+ skipVerification?: boolean;
27
28
  }
28
29
 
29
30
  export class TelnyxProvider implements VoiceCallProvider {
@@ -82,65 +83,11 @@ export class TelnyxProvider implements VoiceCallProvider {
82
83
  * Verify Telnyx webhook signature using Ed25519.
83
84
  */
84
85
  verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
85
- if (!this.publicKey) {
86
- if (this.options.allowUnsignedWebhooks) {
87
- console.warn("[telnyx] Webhook verification skipped (no public key configured)");
88
- return { ok: true, reason: "verification skipped (no public key configured)" };
89
- }
90
- return {
91
- ok: false,
92
- reason: "Missing telnyx.publicKey (configure to verify webhooks)",
93
- };
94
- }
95
-
96
- const signature = ctx.headers["telnyx-signature-ed25519"];
97
- const timestamp = ctx.headers["telnyx-timestamp"];
98
-
99
- if (!signature || !timestamp) {
100
- return { ok: false, reason: "Missing signature or timestamp header" };
101
- }
102
-
103
- const signatureStr = Array.isArray(signature) ? signature[0] : signature;
104
- const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp;
105
-
106
- if (!signatureStr || !timestampStr) {
107
- return { ok: false, reason: "Empty signature or timestamp" };
108
- }
109
-
110
- try {
111
- const signedPayload = `${timestampStr}|${ctx.rawBody}`;
112
- const signatureBuffer = Buffer.from(signatureStr, "base64");
113
- const publicKeyBuffer = Buffer.from(this.publicKey, "base64");
114
-
115
- const isValid = crypto.verify(
116
- null, // Ed25519 doesn't use a digest
117
- Buffer.from(signedPayload),
118
- {
119
- key: publicKeyBuffer,
120
- format: "der",
121
- type: "spki",
122
- },
123
- signatureBuffer,
124
- );
125
-
126
- if (!isValid) {
127
- return { ok: false, reason: "Invalid signature" };
128
- }
129
-
130
- // Check timestamp is within 5 minutes
131
- const eventTime = parseInt(timestampStr, 10) * 1000;
132
- const now = Date.now();
133
- if (Math.abs(now - eventTime) > 5 * 60 * 1000) {
134
- return { ok: false, reason: "Timestamp too old" };
135
- }
86
+ const result = verifyTelnyxWebhook(ctx, this.publicKey, {
87
+ skipVerification: this.options.skipVerification,
88
+ });
136
89
 
137
- return { ok: true };
138
- } catch (err) {
139
- return {
140
- ok: false,
141
- reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
142
- };
143
- }
90
+ return { ok: result.ok, reason: result.reason };
144
91
  }
145
92
 
146
93
  /**
package/src/runtime.ts CHANGED
@@ -55,8 +55,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
55
55
  publicKey: config.telnyx?.publicKey,
56
56
  },
57
57
  {
58
- allowUnsignedWebhooks:
59
- config.inboundPolicy === "open" || config.inboundPolicy === "disabled",
58
+ skipVerification: config.skipSignatureVerification,
60
59
  },
61
60
  );
62
61
  case "twilio":
@@ -113,6 +112,12 @@ export async function createVoiceCallRuntime(params: {
113
112
  throw new Error("Voice call disabled. Enable the plugin entry in config.");
114
113
  }
115
114
 
115
+ if (config.skipSignatureVerification) {
116
+ log.warn(
117
+ "[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.",
118
+ );
119
+ }
120
+
116
121
  const validation = validateProviderConfig(config);
117
122
  if (!validation.valid) {
118
123
  throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
@@ -222,9 +222,16 @@ describe("verifyTwilioWebhook", () => {
222
222
  expect(result.reason).toMatch(/Invalid signature/);
223
223
  });
224
224
 
225
- it("allows invalid signatures for ngrok free tier only on loopback", () => {
225
+ it("accepts valid signatures for ngrok free tier on loopback when compatibility mode is enabled", () => {
226
226
  const authToken = "test-auth-token";
227
227
  const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
228
+ const webhookUrl = "https://local.ngrok-free.app/voice/webhook";
229
+
230
+ const signature = twilioSignature({
231
+ authToken,
232
+ url: webhookUrl,
233
+ postBody,
234
+ });
228
235
 
229
236
  const result = verifyTwilioWebhook(
230
237
  {
@@ -232,7 +239,7 @@ describe("verifyTwilioWebhook", () => {
232
239
  host: "127.0.0.1:3334",
233
240
  "x-forwarded-proto": "https",
234
241
  "x-forwarded-host": "local.ngrok-free.app",
235
- "x-twilio-signature": "invalid",
242
+ "x-twilio-signature": signature,
236
243
  },
237
244
  rawBody: postBody,
238
245
  url: "http://127.0.0.1:3334/voice/webhook",
@@ -244,8 +251,33 @@ describe("verifyTwilioWebhook", () => {
244
251
  );
245
252
 
246
253
  expect(result.ok).toBe(true);
254
+ expect(result.verificationUrl).toBe(webhookUrl);
255
+ });
256
+
257
+ it("does not allow invalid signatures for ngrok free tier on loopback", () => {
258
+ const authToken = "test-auth-token";
259
+ const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
260
+
261
+ const result = verifyTwilioWebhook(
262
+ {
263
+ headers: {
264
+ host: "127.0.0.1:3334",
265
+ "x-forwarded-proto": "https",
266
+ "x-forwarded-host": "local.ngrok-free.app",
267
+ "x-twilio-signature": "invalid",
268
+ },
269
+ rawBody: postBody,
270
+ url: "http://127.0.0.1:3334/voice/webhook",
271
+ method: "POST",
272
+ remoteAddress: "127.0.0.1",
273
+ },
274
+ authToken,
275
+ { allowNgrokFreeTierLoopbackBypass: true },
276
+ );
277
+
278
+ expect(result.ok).toBe(false);
279
+ expect(result.reason).toMatch(/Invalid signature/);
247
280
  expect(result.isNgrokFreeTier).toBe(true);
248
- expect(result.reason).toMatch(/compatibility mode/);
249
281
  });
250
282
 
251
283
  it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
@@ -330,6 +330,111 @@ export interface TwilioVerificationResult {
330
330
  isNgrokFreeTier?: boolean;
331
331
  }
332
332
 
333
+ export interface TelnyxVerificationResult {
334
+ ok: boolean;
335
+ reason?: string;
336
+ }
337
+
338
+ function decodeBase64OrBase64Url(input: string): Buffer {
339
+ // Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
340
+ const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
341
+ const padLen = (4 - (normalized.length % 4)) % 4;
342
+ const padded = normalized + "=".repeat(padLen);
343
+ return Buffer.from(padded, "base64");
344
+ }
345
+
346
+ function base64UrlEncode(buf: Buffer): string {
347
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
348
+ }
349
+
350
+ function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
351
+ const trimmed = publicKey.trim();
352
+
353
+ // PEM (spki) support.
354
+ if (trimmed.startsWith("-----BEGIN")) {
355
+ return trimmed;
356
+ }
357
+
358
+ // Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
359
+ const decoded = decodeBase64OrBase64Url(trimmed);
360
+ if (decoded.length === 32) {
361
+ // JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
362
+ return crypto.createPublicKey({
363
+ key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
364
+ format: "jwk",
365
+ });
366
+ }
367
+
368
+ return crypto.createPublicKey({
369
+ key: decoded,
370
+ format: "der",
371
+ type: "spki",
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Verify Telnyx webhook signature using Ed25519.
377
+ *
378
+ * Telnyx signs `timestamp|payload` and provides:
379
+ * - `telnyx-signature-ed25519` (Base64 signature)
380
+ * - `telnyx-timestamp` (Unix seconds)
381
+ */
382
+ export function verifyTelnyxWebhook(
383
+ ctx: WebhookContext,
384
+ publicKey: string | undefined,
385
+ options?: {
386
+ /** Skip verification entirely (only for development) */
387
+ skipVerification?: boolean;
388
+ /** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
389
+ maxSkewMs?: number;
390
+ },
391
+ ): TelnyxVerificationResult {
392
+ if (options?.skipVerification) {
393
+ return { ok: true, reason: "verification skipped (dev mode)" };
394
+ }
395
+
396
+ if (!publicKey) {
397
+ return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
398
+ }
399
+
400
+ const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
401
+ const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
402
+
403
+ if (!signature || !timestamp) {
404
+ return { ok: false, reason: "Missing signature or timestamp header" };
405
+ }
406
+
407
+ const eventTimeSec = parseInt(timestamp, 10);
408
+ if (!Number.isFinite(eventTimeSec)) {
409
+ return { ok: false, reason: "Invalid timestamp header" };
410
+ }
411
+
412
+ try {
413
+ const signedPayload = `${timestamp}|${ctx.rawBody}`;
414
+ const signatureBuffer = decodeBase64OrBase64Url(signature);
415
+ const key = importEd25519PublicKey(publicKey);
416
+
417
+ const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
418
+ if (!isValid) {
419
+ return { ok: false, reason: "Invalid signature" };
420
+ }
421
+
422
+ const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
423
+ const eventTimeMs = eventTimeSec * 1000;
424
+ const now = Date.now();
425
+ if (Math.abs(now - eventTimeMs) > maxSkewMs) {
426
+ return { ok: false, reason: "Timestamp too old" };
427
+ }
428
+
429
+ return { ok: true };
430
+ } catch (err) {
431
+ return {
432
+ ok: false,
433
+ reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`,
434
+ };
435
+ }
436
+ }
437
+
333
438
  /**
334
439
  * Verify Twilio webhook with full context and detailed result.
335
440
  */
@@ -339,7 +444,13 @@ export function verifyTwilioWebhook(
339
444
  options?: {
340
445
  /** Override the public URL (e.g., from config) */
341
446
  publicUrl?: string;
342
- /** Allow ngrok free tier compatibility mode (loopback only, less secure) */
447
+ /**
448
+ * Allow ngrok free tier compatibility mode (loopback only).
449
+ *
450
+ * IMPORTANT: This does NOT bypass signature verification.
451
+ * It only enables trusting forwarded headers on loopback so we can
452
+ * reconstruct the public ngrok URL that Twilio used for signing.
453
+ */
343
454
  allowNgrokFreeTierLoopbackBypass?: boolean;
344
455
  /** Skip verification entirely (only for development) */
345
456
  skipVerification?: boolean;
@@ -401,18 +512,6 @@ export function verifyTwilioWebhook(
401
512
  const isNgrokFreeTier =
402
513
  verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
403
514
 
404
- if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
405
- console.warn(
406
- "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
407
- );
408
- return {
409
- ok: true,
410
- reason: "ngrok free tier compatibility mode (loopback only)",
411
- verificationUrl,
412
- isNgrokFreeTier: true,
413
- };
414
- }
415
-
416
515
  return {
417
516
  ok: false,
418
517
  reason: `Invalid signature for URL: ${verificationUrl}`,
package/src/webhook.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import http from "node:http";
3
3
  import { URL } from "node:url";
4
+ import {
5
+ isRequestBodyLimitError,
6
+ readRequestBodyWithLimit,
7
+ requestBodyErrorToText,
8
+ } from "openclaw/plugin-sdk";
4
9
  import type { VoiceCallConfig } from "./config.js";
5
10
  import type { CoreConfig } from "./core-bridge.js";
6
11
  import type { CallManager } from "./manager.js";
@@ -244,11 +249,16 @@ export class VoiceCallWebhookServer {
244
249
  try {
245
250
  body = await this.readBody(req, MAX_WEBHOOK_BODY_BYTES);
246
251
  } catch (err) {
247
- if (err instanceof Error && err.message === "PayloadTooLarge") {
252
+ if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
248
253
  res.statusCode = 413;
249
254
  res.end("Payload Too Large");
250
255
  return;
251
256
  }
257
+ if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
258
+ res.statusCode = 408;
259
+ res.end(requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
260
+ return;
261
+ }
252
262
  throw err;
253
263
  }
254
264
 
@@ -303,42 +313,7 @@ export class VoiceCallWebhookServer {
303
313
  maxBytes: number,
304
314
  timeoutMs = 30_000,
305
315
  ): Promise<string> {
306
- return new Promise((resolve, reject) => {
307
- let done = false;
308
- const finish = (fn: () => void) => {
309
- if (done) {
310
- return;
311
- }
312
- done = true;
313
- clearTimeout(timer);
314
- fn();
315
- };
316
-
317
- const timer = setTimeout(() => {
318
- finish(() => {
319
- const err = new Error("Request body timeout");
320
- req.destroy(err);
321
- reject(err);
322
- });
323
- }, timeoutMs);
324
-
325
- const chunks: Buffer[] = [];
326
- let totalBytes = 0;
327
- req.on("data", (chunk: Buffer) => {
328
- totalBytes += chunk.length;
329
- if (totalBytes > maxBytes) {
330
- finish(() => {
331
- req.destroy();
332
- reject(new Error("PayloadTooLarge"));
333
- });
334
- return;
335
- }
336
- chunks.push(chunk);
337
- });
338
- req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8"))));
339
- req.on("error", (err) => finish(() => reject(err)));
340
- req.on("close", () => finish(() => reject(new Error("Connection closed"))));
341
- });
316
+ return readRequestBodyWithLimit(req, { maxBytes, timeoutMs });
342
317
  }
343
318
 
344
319
  /**