diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index d304f5e86a1..590988f5d08 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -70,6 +70,14 @@ Set config under `plugins.entries.voice-call.config`: authToken: "...", }, + telnyx: { + apiKey: "...", + connectionId: "...", + // Telnyx webhook public key from the Telnyx Mission Control Portal + // (Base64 string; can also be set via TELNYX_PUBLIC_KEY). + publicKey: "...", + }, + plivo: { authId: "MAxxxxxxxxxxxxxxxxxxxx", authToken: "...", diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index 19d0d23a032..6ac2dd602a2 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -45,6 +45,14 @@ Put under `plugins.entries.voice-call.config`: authToken: "your_token", }, + telnyx: { + apiKey: "KEYxxxx", + connectionId: "CONNxxxx", + // Telnyx webhook public key from the Telnyx Mission Control Portal + // (Base64 string; can also be set via TELNYX_PUBLIC_KEY). + publicKey: "...", + }, + plivo: { authId: "MAxxxxxxxxxxxxxxxxxxxx", authToken: "your_token", diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index ae6f9303c88..b931d6b8f10 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import { describe, expect, it } from "vitest"; import type { WebhookContext } from "../types.js"; import { TelnyxProvider } from "./telnyx.js"; @@ -14,6 +15,13 @@ function createCtx(params?: Partial): WebhookContext { }; } +function decodeBase64Url(input: string): Buffer { + const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); + const padLen = (4 - (normalized.length % 4)) % 4; + const padded = normalized + "=".repeat(padLen); + return Buffer.from(padded, "base64"); +} + describe("TelnyxProvider.verifyWebhook", () => { it("fails closed when public key is missing and skipVerification is false", () => { const provider = new TelnyxProvider( @@ -44,4 +52,70 @@ describe("TelnyxProvider.verifyWebhook", () => { const result = provider.verifyWebhook(createCtx({ headers: {} })); expect(result.ok).toBe(false); }); + + it("verifies a valid signature with a raw Ed25519 public key (Base64)", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + + const jwk = publicKey.export({ format: "jwk" }) as JsonWebKey; + expect(jwk.kty).toBe("OKP"); + expect(jwk.crv).toBe("Ed25519"); + expect(typeof jwk.x).toBe("string"); + + const rawPublicKey = decodeBase64Url(jwk.x as string); + const rawPublicKeyBase64 = rawPublicKey.toString("base64"); + + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: rawPublicKeyBase64 }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "x" }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + + const result = provider.verifyWebhook( + createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }), + ); + expect(result.ok).toBe(true); + }); + + it("verifies a valid signature with a DER SPKI public key (Base64)", () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const spkiDer = publicKey.export({ format: "der", type: "spki" }) as Buffer; + const spkiDerBase64 = spkiDer.toString("base64"); + + const provider = new TelnyxProvider( + { apiKey: "KEY123", connectionId: "CONN456", publicKey: spkiDerBase64 }, + { skipVerification: false }, + ); + + const rawBody = JSON.stringify({ + event_type: "call.initiated", + payload: { call_control_id: "x" }, + }); + const timestamp = String(Math.floor(Date.now() / 1000)); + const signedPayload = `${timestamp}|${rawBody}`; + const signature = crypto.sign(null, Buffer.from(signedPayload), privateKey).toString("base64"); + + const result = provider.verifyWebhook( + createCtx({ + rawBody, + headers: { + "telnyx-signature-ed25519": signature, + "telnyx-timestamp": timestamp, + }, + }), + ); + expect(result.ok).toBe(true); + }); }); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index 895422f72d3..a0b7655fdb8 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -14,6 +14,7 @@ import type { WebhookVerificationResult, } from "../types.js"; import type { VoiceCallProvider } from "./base.js"; +import { verifyTelnyxWebhook } from "../webhook-security.js"; /** * Telnyx Voice API provider implementation. @@ -82,66 +83,11 @@ export class TelnyxProvider implements VoiceCallProvider { * Verify Telnyx webhook signature using Ed25519. */ verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { - if (this.options.skipVerification) { - console.warn("[telnyx] Webhook verification skipped (skipSignatureVerification=true)"); - return { ok: true, reason: "verification skipped (skipSignatureVerification=true)" }; - } + const result = verifyTelnyxWebhook(ctx, this.publicKey, { + skipVerification: this.options.skipVerification, + }); - if (!this.publicKey) { - return { - ok: false, - reason: "Missing telnyx.publicKey (configure to verify webhooks)", - }; - } - - const signature = ctx.headers["telnyx-signature-ed25519"]; - const timestamp = ctx.headers["telnyx-timestamp"]; - - if (!signature || !timestamp) { - return { ok: false, reason: "Missing signature or timestamp header" }; - } - - const signatureStr = Array.isArray(signature) ? signature[0] : signature; - const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp; - - if (!signatureStr || !timestampStr) { - return { ok: false, reason: "Empty signature or timestamp" }; - } - - try { - const signedPayload = `${timestampStr}|${ctx.rawBody}`; - const signatureBuffer = Buffer.from(signatureStr, "base64"); - const publicKeyBuffer = Buffer.from(this.publicKey, "base64"); - - const isValid = crypto.verify( - null, // Ed25519 doesn't use a digest - Buffer.from(signedPayload), - { - key: publicKeyBuffer, - format: "der", - type: "spki", - }, - signatureBuffer, - ); - - if (!isValid) { - return { ok: false, reason: "Invalid signature" }; - } - - // Check timestamp is within 5 minutes - const eventTime = parseInt(timestampStr, 10) * 1000; - const now = Date.now(); - if (Math.abs(now - eventTime) > 5 * 60 * 1000) { - return { ok: false, reason: "Timestamp too old" }; - } - - return { ok: true }; - } catch (err) { - return { - ok: false, - reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`, - }; - } + return { ok: result.ok, reason: result.reason }; } /** diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index eb9bf747130..811a9074037 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -112,6 +112,12 @@ export async function createVoiceCallRuntime(params: { throw new Error("Voice call disabled. Enable the plugin entry in config."); } + if (config.skipSignatureVerification) { + log.warn( + "[voice-call] SECURITY WARNING: skipSignatureVerification=true disables webhook signature verification (development only). Do not use in production.", + ); + } + const validation = validateProviderConfig(config); if (!validation.valid) { throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index f4896809dcd..7a8eccda5ae 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -330,6 +330,111 @@ export interface TwilioVerificationResult { isNgrokFreeTier?: boolean; } +export interface TelnyxVerificationResult { + ok: boolean; + reason?: string; +} + +function decodeBase64OrBase64Url(input: string): Buffer { + // Telnyx docs say Base64; some tooling emits Base64URL. Accept both. + const normalized = input.replace(/-/g, "+").replace(/_/g, "/"); + const padLen = (4 - (normalized.length % 4)) % 4; + const padded = normalized + "=".repeat(padLen); + return Buffer.from(padded, "base64"); +} + +function base64UrlEncode(buf: Buffer): string { + return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} + +function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string { + const trimmed = publicKey.trim(); + + // PEM (spki) support. + if (trimmed.startsWith("-----BEGIN")) { + return trimmed; + } + + // Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key. + const decoded = decodeBase64OrBase64Url(trimmed); + if (decoded.length === 32) { + // JWK is the easiest portable way to import raw Ed25519 keys in Node crypto. + return crypto.createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) }, + format: "jwk", + }); + } + + return crypto.createPublicKey({ + key: decoded, + format: "der", + type: "spki", + }); +} + +/** + * Verify Telnyx webhook signature using Ed25519. + * + * Telnyx signs `timestamp|payload` and provides: + * - `telnyx-signature-ed25519` (Base64 signature) + * - `telnyx-timestamp` (Unix seconds) + */ +export function verifyTelnyxWebhook( + ctx: WebhookContext, + publicKey: string | undefined, + options?: { + /** Skip verification entirely (only for development) */ + skipVerification?: boolean; + /** Maximum allowed clock skew (ms). Defaults to 5 minutes. */ + maxSkewMs?: number; + }, +): TelnyxVerificationResult { + if (options?.skipVerification) { + return { ok: true, reason: "verification skipped (dev mode)" }; + } + + if (!publicKey) { + return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" }; + } + + const signature = getHeader(ctx.headers, "telnyx-signature-ed25519"); + const timestamp = getHeader(ctx.headers, "telnyx-timestamp"); + + if (!signature || !timestamp) { + return { ok: false, reason: "Missing signature or timestamp header" }; + } + + const eventTimeSec = parseInt(timestamp, 10); + if (!Number.isFinite(eventTimeSec)) { + return { ok: false, reason: "Invalid timestamp header" }; + } + + try { + const signedPayload = `${timestamp}|${ctx.rawBody}`; + const signatureBuffer = decodeBase64OrBase64Url(signature); + const key = importEd25519PublicKey(publicKey); + + const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer); + if (!isValid) { + return { ok: false, reason: "Invalid signature" }; + } + + const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000; + const eventTimeMs = eventTimeSec * 1000; + const now = Date.now(); + if (Math.abs(now - eventTimeMs) > maxSkewMs) { + return { ok: false, reason: "Timestamp too old" }; + } + + return { ok: true }; + } catch (err) { + return { + ok: false, + reason: `Verification error: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + /** * Verify Twilio webhook with full context and detailed result. */