@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import { fetchWithSsrFGuard } from "./runtime-api.js";
|
|
2
|
+
import "./api.js";
|
|
3
|
+
import { t as getHeader } from "./http-headers-BrnxBasF.js";
|
|
4
|
+
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
5
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
|
8
|
+
//#region extensions/voice-call/src/webhook-security.ts
|
|
9
|
+
const REPLAY_WINDOW_MS = 600 * 1e3;
|
|
10
|
+
const REPLAY_CACHE_MAX_ENTRIES = 1e4;
|
|
11
|
+
const REPLAY_CACHE_PRUNE_INTERVAL = 64;
|
|
12
|
+
const twilioReplayCache = {
|
|
13
|
+
seenUntil: /* @__PURE__ */ new Map(),
|
|
14
|
+
calls: 0
|
|
15
|
+
};
|
|
16
|
+
const plivoReplayCache = {
|
|
17
|
+
seenUntil: /* @__PURE__ */ new Map(),
|
|
18
|
+
calls: 0
|
|
19
|
+
};
|
|
20
|
+
const telnyxReplayCache = {
|
|
21
|
+
seenUntil: /* @__PURE__ */ new Map(),
|
|
22
|
+
calls: 0
|
|
23
|
+
};
|
|
24
|
+
function sha256Hex(input) {
|
|
25
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
26
|
+
}
|
|
27
|
+
function createSkippedVerificationReplayKey(provider, ctx) {
|
|
28
|
+
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
|
|
29
|
+
}
|
|
30
|
+
function pruneReplayCache(cache, now) {
|
|
31
|
+
for (const [key, expiresAt] of cache.seenUntil) if (expiresAt <= now) cache.seenUntil.delete(key);
|
|
32
|
+
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
33
|
+
const oldest = cache.seenUntil.keys().next().value;
|
|
34
|
+
if (!oldest) break;
|
|
35
|
+
cache.seenUntil.delete(oldest);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function markReplay(cache, replayKey) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
cache.calls += 1;
|
|
41
|
+
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) pruneReplayCache(cache, now);
|
|
42
|
+
const existing = cache.seenUntil.get(replayKey);
|
|
43
|
+
if (existing && existing > now) return true;
|
|
44
|
+
cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
|
|
45
|
+
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) pruneReplayCache(cache, now);
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validate Twilio webhook signature using HMAC-SHA1.
|
|
50
|
+
*
|
|
51
|
+
* Twilio signs requests by concatenating the URL with sorted POST params,
|
|
52
|
+
* then computing HMAC-SHA1 with the auth token.
|
|
53
|
+
*
|
|
54
|
+
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
|
|
55
|
+
*/
|
|
56
|
+
function validateTwilioSignature(authToken, signature, url, params) {
|
|
57
|
+
if (!signature) return false;
|
|
58
|
+
const dataToSign = buildTwilioDataToSign(url, params);
|
|
59
|
+
return timingSafeEqual(signature, crypto.createHmac("sha1", authToken).update(dataToSign).digest("base64"));
|
|
60
|
+
}
|
|
61
|
+
function buildTwilioDataToSign(url, params) {
|
|
62
|
+
let dataToSign = url;
|
|
63
|
+
const sortedParams = Array.from(params.entries()).toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0);
|
|
64
|
+
for (const [key, value] of sortedParams) dataToSign += key + value;
|
|
65
|
+
return dataToSign;
|
|
66
|
+
}
|
|
67
|
+
function buildCanonicalTwilioParamString(params) {
|
|
68
|
+
return Array.from(params.entries()).toSorted((a, b) => a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0).map(([key, value]) => `${key}=${value}`).join("&");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Timing-safe string comparison to prevent timing attacks.
|
|
72
|
+
*/
|
|
73
|
+
function timingSafeEqual(a, b) {
|
|
74
|
+
return safeEqualSecret(a, b);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Validate that a hostname matches RFC 1123 format.
|
|
78
|
+
* Prevents injection of malformed hostnames.
|
|
79
|
+
*/
|
|
80
|
+
function isValidHostname(hostname) {
|
|
81
|
+
if (!hostname || hostname.length > 253) return false;
|
|
82
|
+
return /^([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])?$/.test(hostname);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Safely extract hostname from a host header value.
|
|
86
|
+
* Handles IPv6 addresses and prevents injection via malformed values.
|
|
87
|
+
*/
|
|
88
|
+
function extractHostname(hostHeader) {
|
|
89
|
+
if (!hostHeader) return null;
|
|
90
|
+
let hostname;
|
|
91
|
+
if (hostHeader.startsWith("[")) {
|
|
92
|
+
const endBracket = hostHeader.indexOf("]");
|
|
93
|
+
if (endBracket === -1) return null;
|
|
94
|
+
hostname = hostHeader.slice(1, endBracket);
|
|
95
|
+
return normalizeLowercaseStringOrEmpty(hostname);
|
|
96
|
+
}
|
|
97
|
+
if (hostHeader.includes("@")) return null;
|
|
98
|
+
hostname = hostHeader.split(":")[0];
|
|
99
|
+
if (!isValidHostname(hostname)) return null;
|
|
100
|
+
return normalizeLowercaseStringOrEmpty(hostname);
|
|
101
|
+
}
|
|
102
|
+
function extractHostnameFromHeader(headerValue) {
|
|
103
|
+
const first = headerValue.split(",")[0]?.trim();
|
|
104
|
+
if (!first) return null;
|
|
105
|
+
return extractHostname(first);
|
|
106
|
+
}
|
|
107
|
+
function normalizeAllowedHosts(allowedHosts) {
|
|
108
|
+
if (!allowedHosts || allowedHosts.length === 0) return null;
|
|
109
|
+
const normalized = /* @__PURE__ */ new Set();
|
|
110
|
+
for (const host of allowedHosts) {
|
|
111
|
+
const extracted = extractHostname(host.trim());
|
|
112
|
+
if (extracted) normalized.add(extracted);
|
|
113
|
+
}
|
|
114
|
+
return normalized.size > 0 ? normalized : null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Reconstruct the public webhook URL from request headers.
|
|
118
|
+
*
|
|
119
|
+
* SECURITY: This function validates host headers to prevent host header
|
|
120
|
+
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
|
|
121
|
+
* always provide allowedHosts to whitelist valid hostnames.
|
|
122
|
+
*
|
|
123
|
+
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
|
|
124
|
+
* used by Twilio differs from the local request URL. We use standard
|
|
125
|
+
* forwarding headers to reconstruct it.
|
|
126
|
+
*
|
|
127
|
+
* Priority order:
|
|
128
|
+
* 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
|
|
129
|
+
* 2. X-Original-Host (nginx)
|
|
130
|
+
* 3. Ngrok-Forwarded-Host (ngrok specific)
|
|
131
|
+
* 4. Host header (direct connection)
|
|
132
|
+
*/
|
|
133
|
+
function reconstructWebhookUrl(ctx, options) {
|
|
134
|
+
const { headers } = ctx;
|
|
135
|
+
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
|
|
136
|
+
const hasAllowedHosts = allowedHosts !== null;
|
|
137
|
+
const explicitlyTrusted = options?.trustForwardingHeaders === true;
|
|
138
|
+
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
|
|
139
|
+
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
|
|
140
|
+
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
|
|
141
|
+
const fromTrustedProxy = !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
|
|
142
|
+
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
|
|
143
|
+
const isAllowedForwardedHost = (host) => !allowedHosts || allowedHosts.has(host);
|
|
144
|
+
let proto = "https";
|
|
145
|
+
if (shouldTrustForwardingHeaders) {
|
|
146
|
+
const forwardedProto = getHeader(headers, "x-forwarded-proto");
|
|
147
|
+
if (forwardedProto === "http" || forwardedProto === "https") proto = forwardedProto;
|
|
148
|
+
}
|
|
149
|
+
let host = null;
|
|
150
|
+
if (shouldTrustForwardingHeaders) for (const headerName of [
|
|
151
|
+
"x-forwarded-host",
|
|
152
|
+
"x-original-host",
|
|
153
|
+
"ngrok-forwarded-host"
|
|
154
|
+
]) {
|
|
155
|
+
const headerValue = getHeader(headers, headerName);
|
|
156
|
+
if (headerValue) {
|
|
157
|
+
const extracted = extractHostnameFromHeader(headerValue);
|
|
158
|
+
if (extracted && isAllowedForwardedHost(extracted)) {
|
|
159
|
+
host = extracted;
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!host) {
|
|
165
|
+
const hostHeader = getHeader(headers, "host");
|
|
166
|
+
if (hostHeader) {
|
|
167
|
+
const extracted = extractHostnameFromHeader(hostHeader);
|
|
168
|
+
if (extracted) host = extracted;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (!host) try {
|
|
172
|
+
const extracted = extractHostname(new URL(ctx.url).host);
|
|
173
|
+
if (extracted) host = extracted;
|
|
174
|
+
} catch {
|
|
175
|
+
host = "";
|
|
176
|
+
}
|
|
177
|
+
if (!host) host = "";
|
|
178
|
+
let path = "/";
|
|
179
|
+
try {
|
|
180
|
+
const parsed = new URL(ctx.url);
|
|
181
|
+
path = parsed.pathname + parsed.search;
|
|
182
|
+
} catch {}
|
|
183
|
+
return `${proto}://${host}${path}`;
|
|
184
|
+
}
|
|
185
|
+
function buildTwilioVerificationUrl(ctx, publicUrl, urlOptions) {
|
|
186
|
+
if (!publicUrl) return reconstructWebhookUrl(ctx, urlOptions);
|
|
187
|
+
try {
|
|
188
|
+
const base = new URL(publicUrl);
|
|
189
|
+
const requestUrl = new URL(ctx.url);
|
|
190
|
+
base.pathname = requestUrl.pathname;
|
|
191
|
+
base.search = requestUrl.search;
|
|
192
|
+
return base.toString();
|
|
193
|
+
} catch {
|
|
194
|
+
return publicUrl;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function isLoopbackAddress(address) {
|
|
198
|
+
if (!address) return false;
|
|
199
|
+
if (address === "127.0.0.1" || address === "::1") return true;
|
|
200
|
+
if (address.startsWith("::ffff:127.")) return true;
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
function stripPortFromUrl(url) {
|
|
204
|
+
try {
|
|
205
|
+
const parsed = new URL(url);
|
|
206
|
+
if (!parsed.port) return url;
|
|
207
|
+
parsed.port = "";
|
|
208
|
+
return parsed.toString();
|
|
209
|
+
} catch {
|
|
210
|
+
return url;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function setPortOnUrl(url, port) {
|
|
214
|
+
try {
|
|
215
|
+
const parsed = new URL(url);
|
|
216
|
+
parsed.port = port;
|
|
217
|
+
return parsed.toString();
|
|
218
|
+
} catch {
|
|
219
|
+
return url;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function extractPortFromHostHeader(hostHeader) {
|
|
223
|
+
if (!hostHeader) return;
|
|
224
|
+
try {
|
|
225
|
+
return new URL(`https://${hostHeader}`).port || void 0;
|
|
226
|
+
} catch {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function createTwilioReplayKey(params) {
|
|
231
|
+
const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
|
|
232
|
+
return `twilio:req:${sha256Hex(`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`)}`;
|
|
233
|
+
}
|
|
234
|
+
function decodeBase64OrBase64Url(input) {
|
|
235
|
+
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
236
|
+
const padLen = (4 - normalized.length % 4) % 4;
|
|
237
|
+
const padded = normalized + "=".repeat(padLen);
|
|
238
|
+
return Buffer.from(padded, "base64");
|
|
239
|
+
}
|
|
240
|
+
function base64UrlEncode(buf) {
|
|
241
|
+
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
242
|
+
}
|
|
243
|
+
function importEd25519PublicKey(publicKey) {
|
|
244
|
+
const trimmed = publicKey.trim();
|
|
245
|
+
if (trimmed.startsWith("-----BEGIN")) return trimmed;
|
|
246
|
+
const decoded = decodeBase64OrBase64Url(trimmed);
|
|
247
|
+
if (decoded.length === 32) return crypto.createPublicKey({
|
|
248
|
+
key: {
|
|
249
|
+
kty: "OKP",
|
|
250
|
+
crv: "Ed25519",
|
|
251
|
+
x: base64UrlEncode(decoded)
|
|
252
|
+
},
|
|
253
|
+
format: "jwk"
|
|
254
|
+
});
|
|
255
|
+
return crypto.createPublicKey({
|
|
256
|
+
key: decoded,
|
|
257
|
+
format: "der",
|
|
258
|
+
type: "spki"
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Verify Telnyx webhook signature using Ed25519.
|
|
263
|
+
*
|
|
264
|
+
* Telnyx signs `timestamp|payload` and provides:
|
|
265
|
+
* - `telnyx-signature-ed25519` (Base64 signature)
|
|
266
|
+
* - `telnyx-timestamp` (Unix seconds)
|
|
267
|
+
*/
|
|
268
|
+
function verifyTelnyxWebhook(ctx, publicKey, options) {
|
|
269
|
+
if (options?.skipVerification) {
|
|
270
|
+
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
reason: "verification skipped (dev mode)",
|
|
274
|
+
isReplay: markReplay(telnyxReplayCache, replayKey),
|
|
275
|
+
verifiedRequestKey: replayKey
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!publicKey) return {
|
|
279
|
+
ok: false,
|
|
280
|
+
reason: "Missing telnyx.publicKey (configure to verify webhooks)"
|
|
281
|
+
};
|
|
282
|
+
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
|
|
283
|
+
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
|
|
284
|
+
if (!signature || !timestamp) return {
|
|
285
|
+
ok: false,
|
|
286
|
+
reason: "Missing signature or timestamp header"
|
|
287
|
+
};
|
|
288
|
+
const eventTimeSec = Number.parseInt(timestamp, 10);
|
|
289
|
+
if (!Number.isFinite(eventTimeSec)) return {
|
|
290
|
+
ok: false,
|
|
291
|
+
reason: "Invalid timestamp header"
|
|
292
|
+
};
|
|
293
|
+
try {
|
|
294
|
+
const signedPayload = `${timestamp}|${ctx.rawBody}`;
|
|
295
|
+
const signatureBuffer = decodeBase64OrBase64Url(signature);
|
|
296
|
+
const canonicalSignature = signatureBuffer.toString("base64");
|
|
297
|
+
const key = importEd25519PublicKey(publicKey);
|
|
298
|
+
if (!crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer)) return {
|
|
299
|
+
ok: false,
|
|
300
|
+
reason: "Invalid signature"
|
|
301
|
+
};
|
|
302
|
+
const maxSkewMs = options?.maxSkewMs ?? 300 * 1e3;
|
|
303
|
+
const eventTimeMs = eventTimeSec * 1e3;
|
|
304
|
+
if (Math.abs(Date.now() - eventTimeMs) > maxSkewMs) return {
|
|
305
|
+
ok: false,
|
|
306
|
+
reason: "Timestamp too old"
|
|
307
|
+
};
|
|
308
|
+
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
isReplay: markReplay(telnyxReplayCache, replayKey),
|
|
312
|
+
verifiedRequestKey: replayKey
|
|
313
|
+
};
|
|
314
|
+
} catch (err) {
|
|
315
|
+
return {
|
|
316
|
+
ok: false,
|
|
317
|
+
reason: `Verification error: ${formatErrorMessage(err)}`
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Verify Twilio webhook with full context and detailed result.
|
|
323
|
+
*/
|
|
324
|
+
function verifyTwilioWebhook(ctx, authToken, options) {
|
|
325
|
+
if (options?.skipVerification) {
|
|
326
|
+
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
reason: "verification skipped (dev mode)",
|
|
330
|
+
isReplay: markReplay(twilioReplayCache, replayKey),
|
|
331
|
+
verifiedRequestKey: replayKey
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const signature = getHeader(ctx.headers, "x-twilio-signature");
|
|
335
|
+
if (!signature) return {
|
|
336
|
+
ok: false,
|
|
337
|
+
reason: "Missing X-Twilio-Signature header"
|
|
338
|
+
};
|
|
339
|
+
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
|
|
340
|
+
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
|
|
341
|
+
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
|
|
342
|
+
allowedHosts: options?.allowedHosts,
|
|
343
|
+
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
|
|
344
|
+
trustedProxyIPs: options?.trustedProxyIPs,
|
|
345
|
+
remoteIP: options?.remoteIP
|
|
346
|
+
});
|
|
347
|
+
const params = new URLSearchParams(ctx.rawBody);
|
|
348
|
+
if (validateTwilioSignature(authToken, signature, verificationUrl, params)) {
|
|
349
|
+
const replayKey = createTwilioReplayKey({
|
|
350
|
+
verificationUrl,
|
|
351
|
+
signature,
|
|
352
|
+
requestParams: params
|
|
353
|
+
});
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
verificationUrl,
|
|
357
|
+
isReplay: markReplay(twilioReplayCache, replayKey),
|
|
358
|
+
verifiedRequestKey: replayKey
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const variants = /* @__PURE__ */ new Set();
|
|
362
|
+
variants.add(verificationUrl);
|
|
363
|
+
variants.add(stripPortFromUrl(verificationUrl));
|
|
364
|
+
if (options?.publicUrl) try {
|
|
365
|
+
const publicPort = new URL(options.publicUrl).port;
|
|
366
|
+
if (publicPort) variants.add(setPortOnUrl(verificationUrl, publicPort));
|
|
367
|
+
} catch {}
|
|
368
|
+
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
|
|
369
|
+
if (hostHeaderPort) variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
|
|
370
|
+
for (const candidateUrl of variants) {
|
|
371
|
+
if (candidateUrl === verificationUrl) continue;
|
|
372
|
+
if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) continue;
|
|
373
|
+
const replayKey = createTwilioReplayKey({
|
|
374
|
+
verificationUrl: candidateUrl,
|
|
375
|
+
signature,
|
|
376
|
+
requestParams: params
|
|
377
|
+
});
|
|
378
|
+
return {
|
|
379
|
+
ok: true,
|
|
380
|
+
verificationUrl: candidateUrl,
|
|
381
|
+
isReplay: markReplay(twilioReplayCache, replayKey),
|
|
382
|
+
verifiedRequestKey: replayKey
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
reason: `Invalid signature for URL: ${verificationUrl}`,
|
|
389
|
+
verificationUrl,
|
|
390
|
+
isNgrokFreeTier
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function normalizeSignatureBase64(input) {
|
|
394
|
+
return Buffer.from(input, "base64").toString("base64");
|
|
395
|
+
}
|
|
396
|
+
function getBaseUrlNoQuery(url) {
|
|
397
|
+
const u = new URL(url);
|
|
398
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
399
|
+
}
|
|
400
|
+
function createPlivoV2ReplayKey(url, nonce) {
|
|
401
|
+
return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
|
|
402
|
+
}
|
|
403
|
+
function createPlivoV3ReplayKey(params) {
|
|
404
|
+
return `plivo:v3:${sha256Hex(`${constructPlivoV3BaseUrl({
|
|
405
|
+
method: params.method,
|
|
406
|
+
url: params.url,
|
|
407
|
+
postParams: params.postParams
|
|
408
|
+
})}\n${params.nonce}`)}`;
|
|
409
|
+
}
|
|
410
|
+
function timingSafeEqualString(a, b) {
|
|
411
|
+
return safeEqualSecret(a, b);
|
|
412
|
+
}
|
|
413
|
+
function validatePlivoV2Signature(params) {
|
|
414
|
+
const baseUrl = getBaseUrlNoQuery(params.url);
|
|
415
|
+
return timingSafeEqualString(normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(baseUrl + params.nonce).digest("base64")), normalizeSignatureBase64(params.signature));
|
|
416
|
+
}
|
|
417
|
+
function toParamMapFromSearchParams(sp) {
|
|
418
|
+
const map = {};
|
|
419
|
+
for (const [key, value] of sp.entries()) {
|
|
420
|
+
if (!map[key]) map[key] = [];
|
|
421
|
+
map[key].push(value);
|
|
422
|
+
}
|
|
423
|
+
return map;
|
|
424
|
+
}
|
|
425
|
+
function sortedQueryString(params) {
|
|
426
|
+
const parts = [];
|
|
427
|
+
for (const key of Object.keys(params).toSorted()) {
|
|
428
|
+
const values = [...params[key]].toSorted();
|
|
429
|
+
for (const value of values) parts.push(`${key}=${value}`);
|
|
430
|
+
}
|
|
431
|
+
return parts.join("&");
|
|
432
|
+
}
|
|
433
|
+
function sortedParamsString(params) {
|
|
434
|
+
const parts = [];
|
|
435
|
+
for (const key of Object.keys(params).toSorted()) {
|
|
436
|
+
const values = [...params[key]].toSorted();
|
|
437
|
+
for (const value of values) parts.push(`${key}${value}`);
|
|
438
|
+
}
|
|
439
|
+
return parts.join("");
|
|
440
|
+
}
|
|
441
|
+
function constructPlivoV3BaseUrl(params) {
|
|
442
|
+
const hasPostParams = Object.keys(params.postParams).length > 0;
|
|
443
|
+
const u = new URL(params.url);
|
|
444
|
+
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
|
|
445
|
+
const queryString = sortedQueryString(toParamMapFromSearchParams(u.searchParams));
|
|
446
|
+
let baseUrl = baseNoQuery;
|
|
447
|
+
if (queryString.length > 0 || hasPostParams) baseUrl = `${baseNoQuery}?${queryString}`;
|
|
448
|
+
if (queryString.length > 0 && hasPostParams) baseUrl = `${baseUrl}.`;
|
|
449
|
+
if (params.method === "GET") return baseUrl;
|
|
450
|
+
return baseUrl + sortedParamsString(params.postParams);
|
|
451
|
+
}
|
|
452
|
+
function validatePlivoV3Signature(params) {
|
|
453
|
+
const hmacBase = `${constructPlivoV3BaseUrl({
|
|
454
|
+
method: params.method,
|
|
455
|
+
url: params.url,
|
|
456
|
+
postParams: params.postParams
|
|
457
|
+
})}.${params.nonce}`;
|
|
458
|
+
const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64"));
|
|
459
|
+
const provided = params.signatureHeader.split(",").map((s) => s.trim()).filter(Boolean).map((s) => normalizeSignatureBase64(s));
|
|
460
|
+
for (const sig of provided) if (timingSafeEqualString(expected, sig)) return true;
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
|
|
465
|
+
*
|
|
466
|
+
* Header names (case-insensitive; Node provides lower-case keys):
|
|
467
|
+
* - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
|
|
468
|
+
* - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
|
|
469
|
+
*/
|
|
470
|
+
function verifyPlivoWebhook(ctx, authToken, options) {
|
|
471
|
+
if (options?.skipVerification) {
|
|
472
|
+
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
|
|
473
|
+
return {
|
|
474
|
+
ok: true,
|
|
475
|
+
reason: "verification skipped (dev mode)",
|
|
476
|
+
isReplay: markReplay(plivoReplayCache, replayKey),
|
|
477
|
+
verifiedRequestKey: replayKey
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
|
481
|
+
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
|
|
482
|
+
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
|
|
483
|
+
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
|
|
484
|
+
const reconstructed = reconstructWebhookUrl(ctx, {
|
|
485
|
+
allowedHosts: options?.allowedHosts,
|
|
486
|
+
trustForwardingHeaders: options?.trustForwardingHeaders,
|
|
487
|
+
trustedProxyIPs: options?.trustedProxyIPs,
|
|
488
|
+
remoteIP: options?.remoteIP
|
|
489
|
+
});
|
|
490
|
+
let verificationUrl = reconstructed;
|
|
491
|
+
if (options?.publicUrl) try {
|
|
492
|
+
const req = new URL(reconstructed);
|
|
493
|
+
const base = new URL(options.publicUrl);
|
|
494
|
+
base.pathname = req.pathname;
|
|
495
|
+
base.search = req.search;
|
|
496
|
+
verificationUrl = base.toString();
|
|
497
|
+
} catch {
|
|
498
|
+
verificationUrl = reconstructed;
|
|
499
|
+
}
|
|
500
|
+
if (signatureV3 && nonceV3) {
|
|
501
|
+
const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
|
|
502
|
+
if (!method) return {
|
|
503
|
+
ok: false,
|
|
504
|
+
version: "v3",
|
|
505
|
+
verificationUrl,
|
|
506
|
+
reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`
|
|
507
|
+
};
|
|
508
|
+
const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
|
|
509
|
+
if (!validatePlivoV3Signature({
|
|
510
|
+
authToken,
|
|
511
|
+
signatureHeader: signatureV3,
|
|
512
|
+
nonce: nonceV3,
|
|
513
|
+
method,
|
|
514
|
+
url: verificationUrl,
|
|
515
|
+
postParams
|
|
516
|
+
})) return {
|
|
517
|
+
ok: false,
|
|
518
|
+
version: "v3",
|
|
519
|
+
verificationUrl,
|
|
520
|
+
reason: "Invalid Plivo V3 signature"
|
|
521
|
+
};
|
|
522
|
+
const replayKey = createPlivoV3ReplayKey({
|
|
523
|
+
method,
|
|
524
|
+
url: verificationUrl,
|
|
525
|
+
postParams,
|
|
526
|
+
nonce: nonceV3
|
|
527
|
+
});
|
|
528
|
+
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
529
|
+
return {
|
|
530
|
+
ok: true,
|
|
531
|
+
version: "v3",
|
|
532
|
+
verificationUrl,
|
|
533
|
+
isReplay,
|
|
534
|
+
verifiedRequestKey: replayKey
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
if (signatureV2 && nonceV2) {
|
|
538
|
+
if (!validatePlivoV2Signature({
|
|
539
|
+
authToken,
|
|
540
|
+
signature: signatureV2,
|
|
541
|
+
nonce: nonceV2,
|
|
542
|
+
url: verificationUrl
|
|
543
|
+
})) return {
|
|
544
|
+
ok: false,
|
|
545
|
+
version: "v2",
|
|
546
|
+
verificationUrl,
|
|
547
|
+
reason: "Invalid Plivo V2 signature"
|
|
548
|
+
};
|
|
549
|
+
const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
|
|
550
|
+
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
551
|
+
return {
|
|
552
|
+
ok: true,
|
|
553
|
+
version: "v2",
|
|
554
|
+
verificationUrl,
|
|
555
|
+
isReplay,
|
|
556
|
+
verifiedRequestKey: replayKey
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
reason: "Missing Plivo signature headers (V3 or V2)",
|
|
562
|
+
verificationUrl
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
//#endregion
|
|
566
|
+
//#region extensions/voice-call/src/providers/shared/guarded-json-api.ts
|
|
567
|
+
async function guardedJsonApiRequest(params) {
|
|
568
|
+
const { response, release } = await fetchWithSsrFGuard({
|
|
569
|
+
url: params.url,
|
|
570
|
+
init: {
|
|
571
|
+
method: params.method,
|
|
572
|
+
headers: params.headers,
|
|
573
|
+
body: params.body ? JSON.stringify(params.body) : void 0
|
|
574
|
+
},
|
|
575
|
+
policy: { allowedHostnames: params.allowedHostnames },
|
|
576
|
+
auditContext: params.auditContext
|
|
577
|
+
});
|
|
578
|
+
try {
|
|
579
|
+
if (!response.ok) {
|
|
580
|
+
if (params.allowNotFound && response.status === 404) return;
|
|
581
|
+
const errorText = await response.text();
|
|
582
|
+
throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
|
|
583
|
+
}
|
|
584
|
+
const text = await response.text();
|
|
585
|
+
return text ? JSON.parse(text) : void 0;
|
|
586
|
+
} finally {
|
|
587
|
+
await release();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
//#endregion
|
|
591
|
+
export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, guardedJsonApiRequest as t };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
2
|
+
//#region extensions/voice-call/src/http-headers.ts
|
|
3
|
+
function getHeader(headers, name) {
|
|
4
|
+
const target = normalizeLowercaseStringOrEmpty(name);
|
|
5
|
+
const value = headers[target] ?? Object.entries(headers).find(([key]) => normalizeLowercaseStringOrEmpty(key) === target)?.[1];
|
|
6
|
+
if (Array.isArray(value)) return value[0];
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
//#endregion
|
|
10
|
+
export { getHeader as t };
|