@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.1

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.
Files changed (126) hide show
  1. package/dist/api.js +2 -0
  2. package/dist/call-status-CXldV5o8.js +32 -0
  3. package/dist/cli-metadata.js +12 -0
  4. package/dist/config-7w04YpHh.js +548 -0
  5. package/dist/config-compat-B0me39_4.js +129 -0
  6. package/dist/guarded-json-api-Btx5EE4w.js +591 -0
  7. package/dist/http-headers-BrnxBasF.js +10 -0
  8. package/dist/index.js +1284 -0
  9. package/dist/mock-CeKvfVEd.js +135 -0
  10. package/dist/plivo-B-a7KFoT.js +393 -0
  11. package/dist/realtime-handler-B63CIDP2.js +325 -0
  12. package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
  13. package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
  14. package/dist/response-generator-BrcmwDZU.js +182 -0
  15. package/dist/response-model-CyF5K80p.js +12 -0
  16. package/dist/runtime-api.js +6 -0
  17. package/dist/runtime-entry-88ytYAQa.js +3119 -0
  18. package/dist/runtime-entry.js +2 -0
  19. package/dist/setup-api.js +37 -0
  20. package/dist/telnyx-jjBE8boz.js +260 -0
  21. package/dist/twilio-1OqbcXLL.js +676 -0
  22. package/dist/voice-mapping-BYDGdWGx.js +40 -0
  23. package/package.json +14 -6
  24. package/api.ts +0 -16
  25. package/cli-metadata.ts +0 -10
  26. package/config-api.ts +0 -12
  27. package/index.test.ts +0 -943
  28. package/index.ts +0 -794
  29. package/runtime-api.ts +0 -20
  30. package/runtime-entry.ts +0 -1
  31. package/setup-api.ts +0 -47
  32. package/src/allowlist.test.ts +0 -18
  33. package/src/allowlist.ts +0 -19
  34. package/src/cli.ts +0 -845
  35. package/src/config-compat.test.ts +0 -120
  36. package/src/config-compat.ts +0 -227
  37. package/src/config.test.ts +0 -479
  38. package/src/config.ts +0 -808
  39. package/src/core-bridge.ts +0 -14
  40. package/src/deep-merge.test.ts +0 -40
  41. package/src/deep-merge.ts +0 -23
  42. package/src/gateway-continue-operation.ts +0 -200
  43. package/src/http-headers.test.ts +0 -16
  44. package/src/http-headers.ts +0 -15
  45. package/src/manager/context.ts +0 -42
  46. package/src/manager/events.test.ts +0 -581
  47. package/src/manager/events.ts +0 -288
  48. package/src/manager/lifecycle.ts +0 -53
  49. package/src/manager/lookup.test.ts +0 -52
  50. package/src/manager/lookup.ts +0 -35
  51. package/src/manager/outbound.test.ts +0 -528
  52. package/src/manager/outbound.ts +0 -486
  53. package/src/manager/state.ts +0 -48
  54. package/src/manager/store.ts +0 -106
  55. package/src/manager/timers.test.ts +0 -129
  56. package/src/manager/timers.ts +0 -113
  57. package/src/manager/twiml.test.ts +0 -13
  58. package/src/manager/twiml.ts +0 -17
  59. package/src/manager.closed-loop.test.ts +0 -236
  60. package/src/manager.inbound-allowlist.test.ts +0 -188
  61. package/src/manager.notify.test.ts +0 -377
  62. package/src/manager.restore.test.ts +0 -183
  63. package/src/manager.test-harness.ts +0 -127
  64. package/src/manager.ts +0 -392
  65. package/src/media-stream.test.ts +0 -768
  66. package/src/media-stream.ts +0 -708
  67. package/src/providers/base.ts +0 -97
  68. package/src/providers/mock.test.ts +0 -78
  69. package/src/providers/mock.ts +0 -185
  70. package/src/providers/plivo.test.ts +0 -93
  71. package/src/providers/plivo.ts +0 -601
  72. package/src/providers/shared/call-status.test.ts +0 -24
  73. package/src/providers/shared/call-status.ts +0 -24
  74. package/src/providers/shared/guarded-json-api.test.ts +0 -106
  75. package/src/providers/shared/guarded-json-api.ts +0 -42
  76. package/src/providers/telnyx.test.ts +0 -340
  77. package/src/providers/telnyx.ts +0 -394
  78. package/src/providers/twilio/api.test.ts +0 -145
  79. package/src/providers/twilio/api.ts +0 -93
  80. package/src/providers/twilio/twiml-policy.test.ts +0 -84
  81. package/src/providers/twilio/twiml-policy.ts +0 -87
  82. package/src/providers/twilio/webhook.ts +0 -34
  83. package/src/providers/twilio.test.ts +0 -591
  84. package/src/providers/twilio.ts +0 -861
  85. package/src/providers/twilio.types.ts +0 -17
  86. package/src/realtime-defaults.ts +0 -3
  87. package/src/realtime-fast-context.test.ts +0 -88
  88. package/src/realtime-fast-context.ts +0 -165
  89. package/src/realtime-transcription.runtime.ts +0 -4
  90. package/src/realtime-voice.runtime.ts +0 -5
  91. package/src/response-generator.test.ts +0 -321
  92. package/src/response-generator.ts +0 -318
  93. package/src/response-model.test.ts +0 -71
  94. package/src/response-model.ts +0 -23
  95. package/src/runtime.test.ts +0 -536
  96. package/src/runtime.ts +0 -510
  97. package/src/telephony-audio.test.ts +0 -61
  98. package/src/telephony-audio.ts +0 -12
  99. package/src/telephony-tts.test.ts +0 -196
  100. package/src/telephony-tts.ts +0 -235
  101. package/src/test-fixtures.ts +0 -73
  102. package/src/tts-provider-voice.test.ts +0 -34
  103. package/src/tts-provider-voice.ts +0 -21
  104. package/src/tunnel.test.ts +0 -166
  105. package/src/tunnel.ts +0 -314
  106. package/src/types.ts +0 -291
  107. package/src/utils.test.ts +0 -17
  108. package/src/utils.ts +0 -14
  109. package/src/voice-mapping.test.ts +0 -34
  110. package/src/voice-mapping.ts +0 -68
  111. package/src/webhook/realtime-handler.test.ts +0 -598
  112. package/src/webhook/realtime-handler.ts +0 -485
  113. package/src/webhook/stale-call-reaper.test.ts +0 -88
  114. package/src/webhook/stale-call-reaper.ts +0 -38
  115. package/src/webhook/tailscale.test.ts +0 -214
  116. package/src/webhook/tailscale.ts +0 -129
  117. package/src/webhook-exposure.test.ts +0 -33
  118. package/src/webhook-exposure.ts +0 -84
  119. package/src/webhook-security.test.ts +0 -770
  120. package/src/webhook-security.ts +0 -994
  121. package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
  122. package/src/webhook.test.ts +0 -1470
  123. package/src/webhook.ts +0 -908
  124. package/src/webhook.types.ts +0 -5
  125. package/src/websocket-test-support.ts +0 -72
  126. package/tsconfig.json +0 -16
@@ -1,994 +0,0 @@
1
- import crypto from "node:crypto";
2
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
- import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
4
- import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
5
- import { getHeader } from "./http-headers.js";
6
- import type { WebhookContext } from "./types.js";
7
-
8
- const REPLAY_WINDOW_MS = 10 * 60 * 1000;
9
- const REPLAY_CACHE_MAX_ENTRIES = 10_000;
10
- const REPLAY_CACHE_PRUNE_INTERVAL = 64;
11
-
12
- type ReplayCache = {
13
- seenUntil: Map<string, number>;
14
- calls: number;
15
- };
16
-
17
- const twilioReplayCache: ReplayCache = {
18
- seenUntil: new Map<string, number>(),
19
- calls: 0,
20
- };
21
-
22
- const plivoReplayCache: ReplayCache = {
23
- seenUntil: new Map<string, number>(),
24
- calls: 0,
25
- };
26
-
27
- const telnyxReplayCache: ReplayCache = {
28
- seenUntil: new Map<string, number>(),
29
- calls: 0,
30
- };
31
-
32
- function sha256Hex(input: string): string {
33
- return crypto.createHash("sha256").update(input).digest("hex");
34
- }
35
-
36
- function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
37
- return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
38
- }
39
-
40
- function pruneReplayCache(cache: ReplayCache, now: number): void {
41
- for (const [key, expiresAt] of cache.seenUntil) {
42
- if (expiresAt <= now) {
43
- cache.seenUntil.delete(key);
44
- }
45
- }
46
- while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
47
- const oldest = cache.seenUntil.keys().next().value;
48
- if (!oldest) {
49
- break;
50
- }
51
- cache.seenUntil.delete(oldest);
52
- }
53
- }
54
-
55
- function markReplay(cache: ReplayCache, replayKey: string): boolean {
56
- const now = Date.now();
57
- cache.calls += 1;
58
- if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) {
59
- pruneReplayCache(cache, now);
60
- }
61
-
62
- const existing = cache.seenUntil.get(replayKey);
63
- if (existing && existing > now) {
64
- return true;
65
- }
66
-
67
- cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
68
- if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
69
- pruneReplayCache(cache, now);
70
- }
71
- return false;
72
- }
73
-
74
- /**
75
- * Validate Twilio webhook signature using HMAC-SHA1.
76
- *
77
- * Twilio signs requests by concatenating the URL with sorted POST params,
78
- * then computing HMAC-SHA1 with the auth token.
79
- *
80
- * @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
81
- */
82
- function validateTwilioSignature(
83
- authToken: string,
84
- signature: string | undefined,
85
- url: string,
86
- params: URLSearchParams,
87
- ): boolean {
88
- if (!signature) {
89
- return false;
90
- }
91
-
92
- const dataToSign = buildTwilioDataToSign(url, params);
93
-
94
- // HMAC-SHA1 with auth token, then base64 encode
95
- const expectedSignature = crypto
96
- .createHmac("sha1", authToken)
97
- .update(dataToSign)
98
- .digest("base64");
99
-
100
- // Use timing-safe comparison to prevent timing attacks
101
- return timingSafeEqual(signature, expectedSignature);
102
- }
103
-
104
- function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
105
- let dataToSign = url;
106
- const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
107
- a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
108
- );
109
- for (const [key, value] of sortedParams) {
110
- dataToSign += key + value;
111
- }
112
- return dataToSign;
113
- }
114
-
115
- function buildCanonicalTwilioParamString(params: URLSearchParams): string {
116
- return Array.from(params.entries())
117
- .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
118
- .map(([key, value]) => `${key}=${value}`)
119
- .join("&");
120
- }
121
-
122
- /**
123
- * Timing-safe string comparison to prevent timing attacks.
124
- */
125
- function timingSafeEqual(a: string, b: string): boolean {
126
- return safeEqualSecret(a, b);
127
- }
128
-
129
- /**
130
- * Configuration for secure URL reconstruction.
131
- */
132
- interface WebhookUrlOptions {
133
- /**
134
- * Whitelist of allowed hostnames. If provided, only these hosts will be
135
- * accepted from forwarding headers. This prevents host header injection attacks.
136
- *
137
- * SECURITY: You must provide this OR set trustForwardingHeaders=true to use
138
- * X-Forwarded-Host headers. Without either, forwarding headers are ignored.
139
- */
140
- allowedHosts?: string[];
141
- /**
142
- * Explicitly trust X-Forwarded-* headers without a whitelist.
143
- * WARNING: Only set this to true if you trust your proxy configuration
144
- * and understand the security implications.
145
- *
146
- * @default false
147
- */
148
- trustForwardingHeaders?: boolean;
149
- /**
150
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only be
151
- * trusted if the request comes from one of these IPs.
152
- * Requires remoteIP to be set for validation.
153
- */
154
- trustedProxyIPs?: string[];
155
- /**
156
- * The IP address of the incoming request (for proxy validation).
157
- */
158
- remoteIP?: string;
159
- }
160
-
161
- /**
162
- * Validate that a hostname matches RFC 1123 format.
163
- * Prevents injection of malformed hostnames.
164
- */
165
- function isValidHostname(hostname: string): boolean {
166
- if (!hostname || hostname.length > 253) {
167
- return false;
168
- }
169
- // RFC 1123 hostname: alphanumeric, hyphens, dots
170
- // Also allow ngrok/tunnel subdomains
171
- const hostnameRegex =
172
- /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
173
- return hostnameRegex.test(hostname);
174
- }
175
-
176
- /**
177
- * Safely extract hostname from a host header value.
178
- * Handles IPv6 addresses and prevents injection via malformed values.
179
- */
180
- function extractHostname(hostHeader: string): string | null {
181
- if (!hostHeader) {
182
- return null;
183
- }
184
-
185
- let hostname: string;
186
-
187
- // Handle IPv6 addresses: [::1]:8080
188
- if (hostHeader.startsWith("[")) {
189
- const endBracket = hostHeader.indexOf("]");
190
- if (endBracket === -1) {
191
- return null; // Malformed IPv6
192
- }
193
- hostname = hostHeader.slice(1, endBracket);
194
- return normalizeLowercaseStringOrEmpty(hostname);
195
- }
196
-
197
- // Handle IPv4/domain with optional port
198
- // Check for @ which could indicate user info injection attempt
199
- if (hostHeader.includes("@")) {
200
- return null; // Reject potential injection: attacker.com:80@legitimate.com
201
- }
202
-
203
- hostname = hostHeader.split(":")[0];
204
-
205
- // Validate the extracted hostname
206
- if (!isValidHostname(hostname)) {
207
- return null;
208
- }
209
-
210
- return normalizeLowercaseStringOrEmpty(hostname);
211
- }
212
-
213
- function extractHostnameFromHeader(headerValue: string): string | null {
214
- const first = headerValue.split(",")[0]?.trim();
215
- if (!first) {
216
- return null;
217
- }
218
- return extractHostname(first);
219
- }
220
-
221
- function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
222
- if (!allowedHosts || allowedHosts.length === 0) {
223
- return null;
224
- }
225
- const normalized = new Set<string>();
226
- for (const host of allowedHosts) {
227
- const extracted = extractHostname(host.trim());
228
- if (extracted) {
229
- normalized.add(extracted);
230
- }
231
- }
232
- return normalized.size > 0 ? normalized : null;
233
- }
234
-
235
- /**
236
- * Reconstruct the public webhook URL from request headers.
237
- *
238
- * SECURITY: This function validates host headers to prevent host header
239
- * injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
240
- * always provide allowedHosts to whitelist valid hostnames.
241
- *
242
- * When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
243
- * used by Twilio differs from the local request URL. We use standard
244
- * forwarding headers to reconstruct it.
245
- *
246
- * Priority order:
247
- * 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
248
- * 2. X-Original-Host (nginx)
249
- * 3. Ngrok-Forwarded-Host (ngrok specific)
250
- * 4. Host header (direct connection)
251
- */
252
- export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
253
- const { headers } = ctx;
254
-
255
- // SECURITY: Only trust forwarding headers if explicitly configured.
256
- // Either allowedHosts must be set (for whitelist validation) or
257
- // trustForwardingHeaders must be true (explicit opt-in to trust).
258
- const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
259
- const hasAllowedHosts = allowedHosts !== null;
260
- const explicitlyTrusted = options?.trustForwardingHeaders === true;
261
-
262
- // Also check trusted proxy IPs if configured
263
- const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
264
- const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
265
- const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
266
- const fromTrustedProxy =
267
- !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
268
-
269
- // Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
270
- const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
271
-
272
- const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
273
-
274
- // Determine protocol - only trust X-Forwarded-Proto from trusted proxies
275
- let proto = "https";
276
- if (shouldTrustForwardingHeaders) {
277
- const forwardedProto = getHeader(headers, "x-forwarded-proto");
278
- if (forwardedProto === "http" || forwardedProto === "https") {
279
- proto = forwardedProto;
280
- }
281
- }
282
-
283
- // Determine host - with security validation
284
- let host: string | null = null;
285
-
286
- if (shouldTrustForwardingHeaders) {
287
- // Try forwarding headers in priority order
288
- const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
289
-
290
- for (const headerName of forwardingHeaders) {
291
- const headerValue = getHeader(headers, headerName);
292
- if (headerValue) {
293
- const extracted = extractHostnameFromHeader(headerValue);
294
- if (extracted && isAllowedForwardedHost(extracted)) {
295
- host = extracted;
296
- break;
297
- }
298
- }
299
- }
300
- }
301
-
302
- // Fallback to Host header if no valid forwarding header found
303
- if (!host) {
304
- const hostHeader = getHeader(headers, "host");
305
- if (hostHeader) {
306
- const extracted = extractHostnameFromHeader(hostHeader);
307
- if (extracted) {
308
- host = extracted;
309
- }
310
- }
311
- }
312
-
313
- // Last resort: try to extract from ctx.url
314
- if (!host) {
315
- try {
316
- const parsed = new URL(ctx.url);
317
- const extracted = extractHostname(parsed.host);
318
- if (extracted) {
319
- host = extracted;
320
- }
321
- } catch {
322
- // URL parsing failed - use empty string (will result in invalid URL)
323
- host = "";
324
- }
325
- }
326
-
327
- if (!host) {
328
- host = "";
329
- }
330
-
331
- // Extract path from the context URL (fallback to "/" on parse failure)
332
- let path = "/";
333
- try {
334
- const parsed = new URL(ctx.url);
335
- path = parsed.pathname + parsed.search;
336
- } catch {
337
- // URL parsing failed
338
- }
339
-
340
- return `${proto}://${host}${path}`;
341
- }
342
-
343
- function buildTwilioVerificationUrl(
344
- ctx: WebhookContext,
345
- publicUrl?: string,
346
- urlOptions?: WebhookUrlOptions,
347
- ): string {
348
- if (!publicUrl) {
349
- return reconstructWebhookUrl(ctx, urlOptions);
350
- }
351
-
352
- try {
353
- const base = new URL(publicUrl);
354
- const requestUrl = new URL(ctx.url);
355
- base.pathname = requestUrl.pathname;
356
- base.search = requestUrl.search;
357
- return base.toString();
358
- } catch {
359
- return publicUrl;
360
- }
361
- }
362
-
363
- function isLoopbackAddress(address?: string): boolean {
364
- if (!address) {
365
- return false;
366
- }
367
- if (address === "127.0.0.1" || address === "::1") {
368
- return true;
369
- }
370
- if (address.startsWith("::ffff:127.")) {
371
- return true;
372
- }
373
- return false;
374
- }
375
-
376
- function stripPortFromUrl(url: string): string {
377
- try {
378
- const parsed = new URL(url);
379
- if (!parsed.port) {
380
- return url;
381
- }
382
- parsed.port = "";
383
- return parsed.toString();
384
- } catch {
385
- return url;
386
- }
387
- }
388
-
389
- function setPortOnUrl(url: string, port: string): string {
390
- try {
391
- const parsed = new URL(url);
392
- parsed.port = port;
393
- return parsed.toString();
394
- } catch {
395
- return url;
396
- }
397
- }
398
-
399
- function extractPortFromHostHeader(hostHeader?: string): string | undefined {
400
- if (!hostHeader) {
401
- return undefined;
402
- }
403
- try {
404
- const parsed = new URL(`https://${hostHeader}`);
405
- return parsed.port || undefined;
406
- } catch {
407
- return undefined;
408
- }
409
- }
410
-
411
- /**
412
- * Result of Twilio webhook verification with detailed info.
413
- */
414
- interface TwilioVerificationResult {
415
- ok: boolean;
416
- reason?: string;
417
- /** The URL that was used for verification (for debugging) */
418
- verificationUrl?: string;
419
- /** Whether we're running behind ngrok free tier */
420
- isNgrokFreeTier?: boolean;
421
- /** Request is cryptographically valid but was already processed recently. */
422
- isReplay?: boolean;
423
- /** Stable request identity derived from signed Twilio material. */
424
- verifiedRequestKey?: string;
425
- }
426
-
427
- interface TelnyxVerificationResult {
428
- ok: boolean;
429
- reason?: string;
430
- /** Request is cryptographically valid but was already processed recently. */
431
- isReplay?: boolean;
432
- /** Stable request identity derived from signed Telnyx material. */
433
- verifiedRequestKey?: string;
434
- }
435
-
436
- function createTwilioReplayKey(params: {
437
- verificationUrl: string;
438
- signature: string;
439
- requestParams: URLSearchParams;
440
- }): string {
441
- const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
442
- return `twilio:req:${sha256Hex(
443
- `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
444
- )}`;
445
- }
446
-
447
- function decodeBase64OrBase64Url(input: string): Buffer {
448
- // Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
449
- const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
450
- const padLen = (4 - (normalized.length % 4)) % 4;
451
- const padded = normalized + "=".repeat(padLen);
452
- return Buffer.from(padded, "base64");
453
- }
454
-
455
- function base64UrlEncode(buf: Buffer): string {
456
- return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
457
- }
458
-
459
- function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
460
- const trimmed = publicKey.trim();
461
-
462
- // PEM (spki) support.
463
- if (trimmed.startsWith("-----BEGIN")) {
464
- return trimmed;
465
- }
466
-
467
- // Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
468
- const decoded = decodeBase64OrBase64Url(trimmed);
469
- if (decoded.length === 32) {
470
- // JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
471
- return crypto.createPublicKey({
472
- key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
473
- format: "jwk",
474
- });
475
- }
476
-
477
- return crypto.createPublicKey({
478
- key: decoded,
479
- format: "der",
480
- type: "spki",
481
- });
482
- }
483
-
484
- /**
485
- * Verify Telnyx webhook signature using Ed25519.
486
- *
487
- * Telnyx signs `timestamp|payload` and provides:
488
- * - `telnyx-signature-ed25519` (Base64 signature)
489
- * - `telnyx-timestamp` (Unix seconds)
490
- */
491
- export function verifyTelnyxWebhook(
492
- ctx: WebhookContext,
493
- publicKey: string | undefined,
494
- options?: {
495
- /** Skip verification entirely (only for development) */
496
- skipVerification?: boolean;
497
- /** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
498
- maxSkewMs?: number;
499
- },
500
- ): TelnyxVerificationResult {
501
- if (options?.skipVerification) {
502
- const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
503
- const isReplay = markReplay(telnyxReplayCache, replayKey);
504
- return {
505
- ok: true,
506
- reason: "verification skipped (dev mode)",
507
- isReplay,
508
- verifiedRequestKey: replayKey,
509
- };
510
- }
511
-
512
- if (!publicKey) {
513
- return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
514
- }
515
-
516
- const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
517
- const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
518
-
519
- if (!signature || !timestamp) {
520
- return { ok: false, reason: "Missing signature or timestamp header" };
521
- }
522
-
523
- const eventTimeSec = Number.parseInt(timestamp, 10);
524
- if (!Number.isFinite(eventTimeSec)) {
525
- return { ok: false, reason: "Invalid timestamp header" };
526
- }
527
-
528
- try {
529
- const signedPayload = `${timestamp}|${ctx.rawBody}`;
530
- const signatureBuffer = decodeBase64OrBase64Url(signature);
531
- // Canonicalize equivalent Base64/Base64URL encodings before replay hashing.
532
- const canonicalSignature = signatureBuffer.toString("base64");
533
- const key = importEd25519PublicKey(publicKey);
534
-
535
- const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
536
- if (!isValid) {
537
- return { ok: false, reason: "Invalid signature" };
538
- }
539
-
540
- const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
541
- const eventTimeMs = eventTimeSec * 1000;
542
- const now = Date.now();
543
- if (Math.abs(now - eventTimeMs) > maxSkewMs) {
544
- return { ok: false, reason: "Timestamp too old" };
545
- }
546
-
547
- const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
548
- const isReplay = markReplay(telnyxReplayCache, replayKey);
549
- return { ok: true, isReplay, verifiedRequestKey: replayKey };
550
- } catch (err) {
551
- return {
552
- ok: false,
553
- reason: `Verification error: ${formatErrorMessage(err)}`,
554
- };
555
- }
556
- }
557
-
558
- /**
559
- * Verify Twilio webhook with full context and detailed result.
560
- */
561
- export function verifyTwilioWebhook(
562
- ctx: WebhookContext,
563
- authToken: string,
564
- options?: {
565
- /** Override the public URL (e.g., from config) */
566
- publicUrl?: string;
567
- /**
568
- * Allow ngrok free tier compatibility mode (loopback only).
569
- *
570
- * IMPORTANT: This does NOT bypass signature verification.
571
- * It only enables trusting forwarded headers on loopback so we can
572
- * reconstruct the public ngrok URL that Twilio used for signing.
573
- */
574
- allowNgrokFreeTierLoopbackBypass?: boolean;
575
- /** Skip verification entirely (only for development) */
576
- skipVerification?: boolean;
577
- /**
578
- * Whitelist of allowed hostnames for host header validation.
579
- * Prevents host header injection attacks.
580
- */
581
- allowedHosts?: string[];
582
- /**
583
- * Explicitly trust X-Forwarded-* headers without a whitelist.
584
- * WARNING: Only enable if you trust your proxy configuration.
585
- * @default false
586
- */
587
- trustForwardingHeaders?: boolean;
588
- /**
589
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only
590
- * be trusted from these IPs.
591
- */
592
- trustedProxyIPs?: string[];
593
- /**
594
- * The remote IP address of the request (for proxy validation).
595
- */
596
- remoteIP?: string;
597
- },
598
- ): TwilioVerificationResult {
599
- // Allow skipping verification for development/testing
600
- if (options?.skipVerification) {
601
- const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
602
- const isReplay = markReplay(twilioReplayCache, replayKey);
603
- return {
604
- ok: true,
605
- reason: "verification skipped (dev mode)",
606
- isReplay,
607
- verifiedRequestKey: replayKey,
608
- };
609
- }
610
-
611
- const signature = getHeader(ctx.headers, "x-twilio-signature");
612
-
613
- if (!signature) {
614
- return { ok: false, reason: "Missing X-Twilio-Signature header" };
615
- }
616
-
617
- const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
618
- const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
619
-
620
- // Reconstruct the URL Twilio used
621
- const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
622
- allowedHosts: options?.allowedHosts,
623
- trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
624
- trustedProxyIPs: options?.trustedProxyIPs,
625
- remoteIP: options?.remoteIP,
626
- });
627
-
628
- // Parse the body as URL-encoded params
629
- const params = new URLSearchParams(ctx.rawBody);
630
-
631
- const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
632
-
633
- if (isValid) {
634
- const replayKey = createTwilioReplayKey({
635
- verificationUrl,
636
- signature,
637
- requestParams: params,
638
- });
639
- const isReplay = markReplay(twilioReplayCache, replayKey);
640
- return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
641
- }
642
-
643
- // Twilio webhook signatures can differ in whether port is included.
644
- // Retry a small, deterministic set of URL variants before failing closed.
645
- const variants = new Set<string>();
646
- variants.add(verificationUrl);
647
- variants.add(stripPortFromUrl(verificationUrl));
648
-
649
- if (options?.publicUrl) {
650
- try {
651
- const publicPort = new URL(options.publicUrl).port;
652
- if (publicPort) {
653
- variants.add(setPortOnUrl(verificationUrl, publicPort));
654
- }
655
- } catch {
656
- // ignore invalid publicUrl; primary verification already used best effort
657
- }
658
- }
659
-
660
- const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
661
- if (hostHeaderPort) {
662
- variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
663
- }
664
-
665
- for (const candidateUrl of variants) {
666
- if (candidateUrl === verificationUrl) {
667
- continue;
668
- }
669
- const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
670
- if (!isValidCandidate) {
671
- continue;
672
- }
673
- const replayKey = createTwilioReplayKey({
674
- verificationUrl: candidateUrl,
675
- signature,
676
- requestParams: params,
677
- });
678
- const isReplay = markReplay(twilioReplayCache, replayKey);
679
- return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
680
- }
681
-
682
- // Check if this is ngrok free tier - the URL might have different format
683
- const isNgrokFreeTier =
684
- verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
685
-
686
- return {
687
- ok: false,
688
- reason: `Invalid signature for URL: ${verificationUrl}`,
689
- verificationUrl,
690
- isNgrokFreeTier,
691
- };
692
- }
693
-
694
- // -----------------------------------------------------------------------------
695
- // Plivo webhook verification
696
- // -----------------------------------------------------------------------------
697
-
698
- /**
699
- * Result of Plivo webhook verification with detailed info.
700
- */
701
- interface PlivoVerificationResult {
702
- ok: boolean;
703
- reason?: string;
704
- verificationUrl?: string;
705
- /** Signature version used for verification */
706
- version?: "v3" | "v2";
707
- /** Request is cryptographically valid but was already processed recently. */
708
- isReplay?: boolean;
709
- /** Stable request identity derived from signed Plivo material. */
710
- verifiedRequestKey?: string;
711
- }
712
-
713
- function normalizeSignatureBase64(input: string): string {
714
- // Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
715
- return Buffer.from(input, "base64").toString("base64");
716
- }
717
-
718
- function getBaseUrlNoQuery(url: string): string {
719
- const u = new URL(url);
720
- return `${u.protocol}//${u.host}${u.pathname}`;
721
- }
722
-
723
- function createPlivoV2ReplayKey(url: string, nonce: string): string {
724
- return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
725
- }
726
-
727
- function createPlivoV3ReplayKey(params: {
728
- method: "GET" | "POST";
729
- url: string;
730
- postParams: PlivoParamMap;
731
- nonce: string;
732
- }): string {
733
- const baseUrl = constructPlivoV3BaseUrl({
734
- method: params.method,
735
- url: params.url,
736
- postParams: params.postParams,
737
- });
738
- return `plivo:v3:${sha256Hex(`${baseUrl}\n${params.nonce}`)}`;
739
- }
740
-
741
- function timingSafeEqualString(a: string, b: string): boolean {
742
- return safeEqualSecret(a, b);
743
- }
744
-
745
- function validatePlivoV2Signature(params: {
746
- authToken: string;
747
- signature: string;
748
- nonce: string;
749
- url: string;
750
- }): boolean {
751
- const baseUrl = getBaseUrlNoQuery(params.url);
752
- const digest = crypto
753
- .createHmac("sha256", params.authToken)
754
- .update(baseUrl + params.nonce)
755
- .digest("base64");
756
- const expected = normalizeSignatureBase64(digest);
757
- const provided = normalizeSignatureBase64(params.signature);
758
- return timingSafeEqualString(expected, provided);
759
- }
760
-
761
- type PlivoParamMap = Record<string, string[]>;
762
-
763
- function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
764
- const map: PlivoParamMap = {};
765
- for (const [key, value] of sp.entries()) {
766
- if (!map[key]) {
767
- map[key] = [];
768
- }
769
- map[key].push(value);
770
- }
771
- return map;
772
- }
773
-
774
- function sortedQueryString(params: PlivoParamMap): string {
775
- const parts: string[] = [];
776
- for (const key of Object.keys(params).toSorted()) {
777
- const values = [...params[key]].toSorted();
778
- for (const value of values) {
779
- parts.push(`${key}=${value}`);
780
- }
781
- }
782
- return parts.join("&");
783
- }
784
-
785
- function sortedParamsString(params: PlivoParamMap): string {
786
- const parts: string[] = [];
787
- for (const key of Object.keys(params).toSorted()) {
788
- const values = [...params[key]].toSorted();
789
- for (const value of values) {
790
- parts.push(`${key}${value}`);
791
- }
792
- }
793
- return parts.join("");
794
- }
795
-
796
- function constructPlivoV3BaseUrl(params: {
797
- method: "GET" | "POST";
798
- url: string;
799
- postParams: PlivoParamMap;
800
- }): string {
801
- const hasPostParams = Object.keys(params.postParams).length > 0;
802
- const u = new URL(params.url);
803
- const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
804
-
805
- const queryMap = toParamMapFromSearchParams(u.searchParams);
806
- const queryString = sortedQueryString(queryMap);
807
-
808
- // In the Plivo V3 algorithm, the query portion is always sorted, and if we
809
- // have POST params we add a '.' separator after the query string.
810
- let baseUrl = baseNoQuery;
811
- if (queryString.length > 0 || hasPostParams) {
812
- baseUrl = `${baseNoQuery}?${queryString}`;
813
- }
814
- if (queryString.length > 0 && hasPostParams) {
815
- baseUrl = `${baseUrl}.`;
816
- }
817
-
818
- if (params.method === "GET") {
819
- return baseUrl;
820
- }
821
-
822
- return baseUrl + sortedParamsString(params.postParams);
823
- }
824
-
825
- function validatePlivoV3Signature(params: {
826
- authToken: string;
827
- signatureHeader: string;
828
- nonce: string;
829
- method: "GET" | "POST";
830
- url: string;
831
- postParams: PlivoParamMap;
832
- }): boolean {
833
- const baseUrl = constructPlivoV3BaseUrl({
834
- method: params.method,
835
- url: params.url,
836
- postParams: params.postParams,
837
- });
838
-
839
- const hmacBase = `${baseUrl}.${params.nonce}`;
840
- const digest = crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64");
841
- const expected = normalizeSignatureBase64(digest);
842
-
843
- // Header can contain multiple signatures separated by commas.
844
- const provided = params.signatureHeader
845
- .split(",")
846
- .map((s) => s.trim())
847
- .filter(Boolean)
848
- .map((s) => normalizeSignatureBase64(s));
849
-
850
- for (const sig of provided) {
851
- if (timingSafeEqualString(expected, sig)) {
852
- return true;
853
- }
854
- }
855
- return false;
856
- }
857
-
858
- /**
859
- * Verify Plivo webhooks using V3 signature if present; fall back to V2.
860
- *
861
- * Header names (case-insensitive; Node provides lower-case keys):
862
- * - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
863
- * - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
864
- */
865
- export function verifyPlivoWebhook(
866
- ctx: WebhookContext,
867
- authToken: string,
868
- options?: {
869
- /** Override the public URL origin (host) used for verification */
870
- publicUrl?: string;
871
- /** Skip verification entirely (only for development) */
872
- skipVerification?: boolean;
873
- /**
874
- * Whitelist of allowed hostnames for host header validation.
875
- * Prevents host header injection attacks.
876
- */
877
- allowedHosts?: string[];
878
- /**
879
- * Explicitly trust X-Forwarded-* headers without a whitelist.
880
- * WARNING: Only enable if you trust your proxy configuration.
881
- * @default false
882
- */
883
- trustForwardingHeaders?: boolean;
884
- /**
885
- * List of trusted proxy IP addresses. X-Forwarded-* headers will only
886
- * be trusted from these IPs.
887
- */
888
- trustedProxyIPs?: string[];
889
- /**
890
- * The remote IP address of the request (for proxy validation).
891
- */
892
- remoteIP?: string;
893
- },
894
- ): PlivoVerificationResult {
895
- if (options?.skipVerification) {
896
- const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
897
- const isReplay = markReplay(plivoReplayCache, replayKey);
898
- return {
899
- ok: true,
900
- reason: "verification skipped (dev mode)",
901
- isReplay,
902
- verifiedRequestKey: replayKey,
903
- };
904
- }
905
-
906
- const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
907
- const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
908
- const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
909
- const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
910
-
911
- const reconstructed = reconstructWebhookUrl(ctx, {
912
- allowedHosts: options?.allowedHosts,
913
- trustForwardingHeaders: options?.trustForwardingHeaders,
914
- trustedProxyIPs: options?.trustedProxyIPs,
915
- remoteIP: options?.remoteIP,
916
- });
917
- let verificationUrl = reconstructed;
918
- if (options?.publicUrl) {
919
- try {
920
- const req = new URL(reconstructed);
921
- const base = new URL(options.publicUrl);
922
- base.pathname = req.pathname;
923
- base.search = req.search;
924
- verificationUrl = base.toString();
925
- } catch {
926
- verificationUrl = reconstructed;
927
- }
928
- }
929
-
930
- if (signatureV3 && nonceV3) {
931
- const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
932
-
933
- if (!method) {
934
- return {
935
- ok: false,
936
- version: "v3",
937
- verificationUrl,
938
- reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
939
- };
940
- }
941
-
942
- const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
943
- const ok = validatePlivoV3Signature({
944
- authToken,
945
- signatureHeader: signatureV3,
946
- nonce: nonceV3,
947
- method,
948
- url: verificationUrl,
949
- postParams,
950
- });
951
- if (!ok) {
952
- return {
953
- ok: false,
954
- version: "v3",
955
- verificationUrl,
956
- reason: "Invalid Plivo V3 signature",
957
- };
958
- }
959
- const replayKey = createPlivoV3ReplayKey({
960
- method,
961
- url: verificationUrl,
962
- postParams,
963
- nonce: nonceV3,
964
- });
965
- const isReplay = markReplay(plivoReplayCache, replayKey);
966
- return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
967
- }
968
-
969
- if (signatureV2 && nonceV2) {
970
- const ok = validatePlivoV2Signature({
971
- authToken,
972
- signature: signatureV2,
973
- nonce: nonceV2,
974
- url: verificationUrl,
975
- });
976
- if (!ok) {
977
- return {
978
- ok: false,
979
- version: "v2",
980
- verificationUrl,
981
- reason: "Invalid Plivo V2 signature",
982
- };
983
- }
984
- const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
985
- const isReplay = markReplay(plivoReplayCache, replayKey);
986
- return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
987
- }
988
-
989
- return {
990
- ok: false,
991
- reason: "Missing Plivo signature headers (V3 or V2)",
992
- verificationUrl,
993
- };
994
- }