From ad77666054651c1fd77b1dc60fd6a8db6600a29a Mon Sep 17 00:00:00 2001 From: Jacob Tomlinson Date: Mon, 30 Mar 2026 12:01:43 -0700 Subject: [PATCH] fix(voice-call): canonicalize Telnyx replay request keys (#57829) --- .../voice-call/src/webhook-security.test.ts | 39 +++++++++++++++++++ extensions/voice-call/src/webhook-security.ts | 4 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 0494f78f5ba..c818bfa7903 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -393,6 +393,45 @@ describe("verifyTelnyxWebhook", () => { expectReplayResultPair(first, second); }); + it("treats Base64 and Base64URL signatures as the same replayed request", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const pemPublicKey = publicKey.export({ format: "pem", type: "spki" }).toString(); + const timestamp = String(Math.floor(Date.now() / 1000)); + const rawBody = JSON.stringify({ + data: { event_type: "call.initiated", payload: { call_control_id: "call-1" } }, + nonce: crypto.randomUUID(), + }); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + const urlSafeSignature = signature.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); + const first = verifyTelnyxWebhook( + { + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST" as const, + }, + pemPublicKey, + ); + const second = verifyTelnyxWebhook( + { + headers: { + "telnyx-signature-ed25519": urlSafeSignature, + "telnyx-timestamp": timestamp, + }, + rawBody, + url: "https://example.com/voice/webhook", + method: "POST" as const, + }, + pemPublicKey, + ); + + expectReplayResultPair(first, second); + }); + it("returns a stable request key when verification is skipped", () => { const ctx = { headers: {}, diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index deff7bb94ed..a6ff0f6a674 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -534,6 +534,8 @@ export function verifyTelnyxWebhook( try { const signedPayload = `${timestamp}|${ctx.rawBody}`; const signatureBuffer = decodeBase64OrBase64Url(signature); + // Canonicalize equivalent Base64/Base64URL encodings before replay hashing. + const canonicalSignature = signatureBuffer.toString("base64"); const key = importEd25519PublicKey(publicKey); const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer); @@ -548,7 +550,7 @@ export function verifyTelnyxWebhook( return { ok: false, reason: "Timestamp too old" }; } - const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; + const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`; const isReplay = markReplay(telnyxReplayCache, replayKey); return { ok: true, isReplay, verifiedRequestKey: replayKey }; } catch (err) {