@openclaw/voice-call 2026.2.23 → 2026.2.24
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 +6 -0
- package/package.json +1 -4
- package/src/providers/telnyx.test.ts +33 -0
- package/src/providers/telnyx.ts +1 -1
- package/src/webhook-security.test.ts +36 -1
- package/src/webhook-security.ts +10 -1
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/voice-call",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.24",
|
|
4
4
|
"description": "OpenClaw voice-call plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
@@ -8,9 +8,6 @@
|
|
|
8
8
|
"ws": "^8.19.0",
|
|
9
9
|
"zod": "^4.3.6"
|
|
10
10
|
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"openclaw": "workspace:*"
|
|
13
|
-
},
|
|
14
11
|
"openclaw": {
|
|
15
12
|
"extensions": [
|
|
16
13
|
"./index.ts"
|
|
@@ -103,4 +103,37 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
|
|
103
103
|
const spkiDerBase64 = spkiDer.toString("base64");
|
|
104
104
|
expectWebhookVerificationSucceeds({ publicKey: spkiDerBase64, privateKey });
|
|
105
105
|
});
|
|
106
|
+
|
|
107
|
+
it("returns replay status when the same signed request is seen twice", () => {
|
|
108
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
109
|
+
const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer;
|
|
110
|
+
const provider = new TelnyxProvider(
|
|
111
|
+
{ apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDer.toString("base64") },
|
|
112
|
+
{ skipVerification: false },
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const rawBody = JSON.stringify({
|
|
116
|
+
event_type: "call.initiated",
|
|
117
|
+
payload: { call_control_id: "call-replay-test" },
|
|
118
|
+
nonce: crypto.randomUUID(),
|
|
119
|
+
});
|
|
120
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
121
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
122
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
123
|
+
const ctx = createCtx({
|
|
124
|
+
rawBody,
|
|
125
|
+
headers: {
|
|
126
|
+
"telnyx-signature-ed25519": signature,
|
|
127
|
+
"telnyx-timestamp": timestamp,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const first = provider.verifyWebhook(ctx);
|
|
132
|
+
const second = provider.verifyWebhook(ctx);
|
|
133
|
+
|
|
134
|
+
expect(first.ok).toBe(true);
|
|
135
|
+
expect(first.isReplay).toBeFalsy();
|
|
136
|
+
expect(second.ok).toBe(true);
|
|
137
|
+
expect(second.isReplay).toBe(true);
|
|
138
|
+
});
|
|
106
139
|
});
|
package/src/providers/telnyx.ts
CHANGED
|
@@ -87,7 +87,7 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|
|
87
87
|
skipVerification: this.options.skipVerification,
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
return { ok: result.ok, reason: result.reason };
|
|
90
|
+
return { ok: result.ok, reason: result.reason, isReplay: result.isReplay };
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
verifyPlivoWebhook,
|
|
5
|
+
verifyTelnyxWebhook,
|
|
6
|
+
verifyTwilioWebhook,
|
|
7
|
+
} from "./webhook-security.js";
|
|
4
8
|
|
|
5
9
|
function canonicalizeBase64(input: string): string {
|
|
6
10
|
return Buffer.from(input, "base64").toString("base64");
|
|
@@ -199,6 +203,37 @@ describe("verifyPlivoWebhook", () => {
|
|
|
199
203
|
});
|
|
200
204
|
});
|
|
201
205
|
|
|
206
|
+
describe("verifyTelnyxWebhook", () => {
|
|
207
|
+
it("marks replayed valid requests as replay without failing auth", () => {
|
|
208
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
|
|
209
|
+
const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
210
|
+
const timestamp = String(Math.floor(Date.now() / 1000));
|
|
211
|
+
const rawBody = JSON.stringify({
|
|
212
|
+
data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } },
|
|
213
|
+
nonce: crypto.randomUUID(),
|
|
214
|
+
});
|
|
215
|
+
const signedPayload = `${timestamp}|${rawBody}`;
|
|
216
|
+
const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64");
|
|
217
|
+
const ctx = {
|
|
218
|
+
headers: {
|
|
219
|
+
"telnyx-signature-ed25519": signature,
|
|
220
|
+
"telnyx-timestamp": timestamp,
|
|
221
|
+
},
|
|
222
|
+
rawBody,
|
|
223
|
+
url: "https://example.com/voice/webhook",
|
|
224
|
+
method: "POST" as const,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const first = verifyTelnyxWebhook(ctx, pemPublicKey);
|
|
228
|
+
const second = verifyTelnyxWebhook(ctx, pemPublicKey);
|
|
229
|
+
|
|
230
|
+
expect(first.ok).toBe(true);
|
|
231
|
+
expect(first.isReplay).toBeFalsy();
|
|
232
|
+
expect(second.ok).toBe(true);
|
|
233
|
+
expect(second.isReplay).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
202
237
|
describe("verifyTwilioWebhook", () => {
|
|
203
238
|
it("uses request query when publicUrl omits it", () => {
|
|
204
239
|
const authToken = "test-auth-token";
|
package/src/webhook-security.ts
CHANGED
|
@@ -20,6 +20,11 @@ const plivoReplayCache: ReplayCache = {
|
|
|
20
20
|
calls: 0,
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
+
const telnyxReplayCache: ReplayCache = {
|
|
24
|
+
seenUntil: new Map<string, number>(),
|
|
25
|
+
calls: 0,
|
|
26
|
+
};
|
|
27
|
+
|
|
23
28
|
function sha256Hex(input: string): string {
|
|
24
29
|
return crypto.createHash("sha256").update(input).digest("hex");
|
|
25
30
|
}
|
|
@@ -392,6 +397,8 @@ export interface TwilioVerificationResult {
|
|
|
392
397
|
export interface TelnyxVerificationResult {
|
|
393
398
|
ok: boolean;
|
|
394
399
|
reason?: string;
|
|
400
|
+
/** Request is cryptographically valid but was already processed recently. */
|
|
401
|
+
isReplay?: boolean;
|
|
395
402
|
}
|
|
396
403
|
|
|
397
404
|
function createTwilioReplayKey(params: {
|
|
@@ -499,7 +506,9 @@ export function verifyTelnyxWebhook(
|
|
|
499
506
|
return { ok: false, reason: "Timestamp too old" };
|
|
500
507
|
}
|
|
501
508
|
|
|
502
|
-
|
|
509
|
+
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`;
|
|
510
|
+
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
|
511
|
+
return { ok: true, isReplay };
|
|
503
512
|
} catch (err) {
|
|
504
513
|
return {
|
|
505
514
|
ok: false,
|