Voice Call: add Plivo provider
This commit is contained in:
committed by
Peter Steinberger
parent
0a1eeedc10
commit
946b0229e8
@@ -195,3 +195,233 @@ export function verifyTwilioWebhook(
|
||||
isNgrokFreeTier,
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Plivo webhook verification
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result of Plivo webhook verification with detailed info.
|
||||
*/
|
||||
export interface PlivoVerificationResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
verificationUrl?: string;
|
||||
/** Signature version used for verification */
|
||||
version?: "v3" | "v2";
|
||||
}
|
||||
|
||||
function normalizeSignatureBase64(input: string): string {
|
||||
// Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
|
||||
return Buffer.from(input, "base64").toString("base64");
|
||||
}
|
||||
|
||||
function getBaseUrlNoQuery(url: string): string {
|
||||
const u = new URL(url);
|
||||
return `${u.protocol}//${u.host}${u.pathname}`;
|
||||
}
|
||||
|
||||
function timingSafeEqualString(a: string, b: string): boolean {
|
||||
if (a.length !== b.length) {
|
||||
const dummy = Buffer.from(a);
|
||||
crypto.timingSafeEqual(dummy, dummy);
|
||||
return false;
|
||||
}
|
||||
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
}
|
||||
|
||||
function validatePlivoV2Signature(params: {
|
||||
authToken: string;
|
||||
signature: string;
|
||||
nonce: string;
|
||||
url: string;
|
||||
}): boolean {
|
||||
const baseUrl = getBaseUrlNoQuery(params.url);
|
||||
const digest = crypto
|
||||
.createHmac("sha256", params.authToken)
|
||||
.update(baseUrl + params.nonce)
|
||||
.digest("base64");
|
||||
const expected = normalizeSignatureBase64(digest);
|
||||
const provided = normalizeSignatureBase64(params.signature);
|
||||
return timingSafeEqualString(expected, provided);
|
||||
}
|
||||
|
||||
type PlivoParamMap = Record<string, string[]>;
|
||||
|
||||
function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
|
||||
const map: PlivoParamMap = {};
|
||||
for (const [key, value] of sp.entries()) {
|
||||
if (!map[key]) map[key] = [];
|
||||
map[key].push(value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function sortedQueryString(params: PlivoParamMap): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of Object.keys(params).sort()) {
|
||||
const values = [...params[key]].sort();
|
||||
for (const value of values) {
|
||||
parts.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
return parts.join("&");
|
||||
}
|
||||
|
||||
function sortedParamsString(params: PlivoParamMap): string {
|
||||
const parts: string[] = [];
|
||||
for (const key of Object.keys(params).sort()) {
|
||||
const values = [...params[key]].sort();
|
||||
for (const value of values) {
|
||||
parts.push(`${key}${value}`);
|
||||
}
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function constructPlivoV3BaseUrl(params: {
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
postParams: PlivoParamMap;
|
||||
}): string {
|
||||
const hasPostParams = Object.keys(params.postParams).length > 0;
|
||||
const u = new URL(params.url);
|
||||
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
|
||||
|
||||
const queryMap = toParamMapFromSearchParams(u.searchParams);
|
||||
const queryString = sortedQueryString(queryMap);
|
||||
|
||||
// In the Plivo V3 algorithm, the query portion is always sorted, and if we
|
||||
// have POST params we add a '.' separator after the query string.
|
||||
let baseUrl = baseNoQuery;
|
||||
if (queryString.length > 0 || hasPostParams) {
|
||||
baseUrl = `${baseNoQuery}?${queryString}`;
|
||||
}
|
||||
if (queryString.length > 0 && hasPostParams) {
|
||||
baseUrl = `${baseUrl}.`;
|
||||
}
|
||||
|
||||
if (params.method === "GET") {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
return baseUrl + sortedParamsString(params.postParams);
|
||||
}
|
||||
|
||||
function validatePlivoV3Signature(params: {
|
||||
authToken: string;
|
||||
signatureHeader: string;
|
||||
nonce: string;
|
||||
method: "GET" | "POST";
|
||||
url: string;
|
||||
postParams: PlivoParamMap;
|
||||
}): boolean {
|
||||
const baseUrl = constructPlivoV3BaseUrl({
|
||||
method: params.method,
|
||||
url: params.url,
|
||||
postParams: params.postParams,
|
||||
});
|
||||
|
||||
const hmacBase = `${baseUrl}.${params.nonce}`;
|
||||
const digest = crypto
|
||||
.createHmac("sha256", params.authToken)
|
||||
.update(hmacBase)
|
||||
.digest("base64");
|
||||
const expected = normalizeSignatureBase64(digest);
|
||||
|
||||
// Header can contain multiple signatures separated by commas.
|
||||
const provided = params.signatureHeader
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((s) => normalizeSignatureBase64(s));
|
||||
|
||||
for (const sig of provided) {
|
||||
if (timingSafeEqualString(expected, sig)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
|
||||
*
|
||||
* Header names (case-insensitive; Node provides lower-case keys):
|
||||
* - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
|
||||
* - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
|
||||
*/
|
||||
export function verifyPlivoWebhook(
|
||||
ctx: WebhookContext,
|
||||
authToken: string,
|
||||
options?: {
|
||||
/** Override the public URL origin (host) used for verification */
|
||||
publicUrl?: string;
|
||||
/** Skip verification entirely (only for development) */
|
||||
skipVerification?: boolean;
|
||||
},
|
||||
): PlivoVerificationResult {
|
||||
if (options?.skipVerification) {
|
||||
return { ok: true, reason: "verification skipped (dev mode)" };
|
||||
}
|
||||
|
||||
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
||||
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
|
||||
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
|
||||
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
|
||||
|
||||
const reconstructed = reconstructWebhookUrl(ctx);
|
||||
let verificationUrl = reconstructed;
|
||||
if (options?.publicUrl) {
|
||||
try {
|
||||
const req = new URL(reconstructed);
|
||||
const base = new URL(options.publicUrl);
|
||||
base.pathname = req.pathname;
|
||||
base.search = req.search;
|
||||
verificationUrl = base.toString();
|
||||
} catch {
|
||||
verificationUrl = reconstructed;
|
||||
}
|
||||
}
|
||||
|
||||
if (signatureV3 && nonceV3) {
|
||||
const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
|
||||
const ok = validatePlivoV3Signature({
|
||||
authToken,
|
||||
signatureHeader: signatureV3,
|
||||
nonce: nonceV3,
|
||||
method: ctx.method,
|
||||
url: verificationUrl,
|
||||
postParams,
|
||||
});
|
||||
return ok
|
||||
? { ok: true, version: "v3", verificationUrl }
|
||||
: {
|
||||
ok: false,
|
||||
version: "v3",
|
||||
verificationUrl,
|
||||
reason: "Invalid Plivo V3 signature",
|
||||
};
|
||||
}
|
||||
|
||||
if (signatureV2 && nonceV2) {
|
||||
const ok = validatePlivoV2Signature({
|
||||
authToken,
|
||||
signature: signatureV2,
|
||||
nonce: nonceV2,
|
||||
url: verificationUrl,
|
||||
});
|
||||
return ok
|
||||
? { ok: true, version: "v2", verificationUrl }
|
||||
: {
|
||||
ok: false,
|
||||
version: "v2",
|
||||
verificationUrl,
|
||||
reason: "Invalid Plivo V2 signature",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
reason: "Missing Plivo signature headers (V3 or V2)",
|
||||
verificationUrl,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user