mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 11:18:37 +00:00
refactor(voice-call): centralize Telnyx webhook verification
This commit is contained in:
@@ -70,6 +70,14 @@ Set config under `plugins.entries.voice-call.config`:
|
|||||||
authToken: "...",
|
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: {
|
plivo: {
|
||||||
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
||||||
authToken: "...",
|
authToken: "...",
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ Put under `plugins.entries.voice-call.config`:
|
|||||||
authToken: "your_token",
|
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: {
|
plivo: {
|
||||||
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
authId: "MAxxxxxxxxxxxxxxxxxxxx",
|
||||||
authToken: "your_token",
|
authToken: "your_token",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { WebhookContext } from "../types.js";
|
import type { WebhookContext } from "../types.js";
|
||||||
import { TelnyxProvider } from "./telnyx.js";
|
import { TelnyxProvider } from "./telnyx.js";
|
||||||
@@ -14,6 +15,13 @@ function createCtx(params?: Partial<WebhookContext>): 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", () => {
|
describe("TelnyxProvider.verifyWebhook", () => {
|
||||||
it("fails closed when public key is missing and skipVerification is false", () => {
|
it("fails closed when public key is missing and skipVerification is false", () => {
|
||||||
const provider = new TelnyxProvider(
|
const provider = new TelnyxProvider(
|
||||||
@@ -44,4 +52,70 @@ describe("TelnyxProvider.verifyWebhook", () => {
|
|||||||
const result = provider.verifyWebhook(createCtx({ headers: {} }));
|
const result = provider.verifyWebhook(createCtx({ headers: {} }));
|
||||||
expect(result.ok).toBe(false);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
WebhookVerificationResult,
|
WebhookVerificationResult,
|
||||||
} from "../types.js";
|
} from "../types.js";
|
||||||
import type { VoiceCallProvider } from "./base.js";
|
import type { VoiceCallProvider } from "./base.js";
|
||||||
|
import { verifyTelnyxWebhook } from "../webhook-security.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telnyx Voice API provider implementation.
|
* Telnyx Voice API provider implementation.
|
||||||
@@ -82,66 +83,11 @@ export class TelnyxProvider implements VoiceCallProvider {
|
|||||||
* Verify Telnyx webhook signature using Ed25519.
|
* Verify Telnyx webhook signature using Ed25519.
|
||||||
*/
|
*/
|
||||||
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
verifyWebhook(ctx: WebhookContext): WebhookVerificationResult {
|
||||||
if (this.options.skipVerification) {
|
const result = verifyTelnyxWebhook(ctx, this.publicKey, {
|
||||||
console.warn("[telnyx] Webhook verification skipped (skipSignatureVerification=true)");
|
skipVerification: this.options.skipVerification,
|
||||||
return { ok: true, reason: "verification skipped (skipSignatureVerification=true)" };
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.publicKey) {
|
return { ok: result.ok, reason: result.reason };
|
||||||
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)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -112,6 +112,12 @@ export async function createVoiceCallRuntime(params: {
|
|||||||
throw new Error("Voice call disabled. Enable the plugin entry in config.");
|
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);
|
const validation = validateProviderConfig(config);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
throw new Error(`Invalid voice-call config: ${validation.errors.join("; ")}`);
|
||||||
|
|||||||
@@ -330,6 +330,111 @@ export interface TwilioVerificationResult {
|
|||||||
isNgrokFreeTier?: boolean;
|
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.
|
* Verify Twilio webhook with full context and detailed result.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user