mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 03:44:31 +00:00
refactor(voice-call): centralize Telnyx webhook verification
This commit is contained in:
@@ -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>): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user