mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
feat(connect-protocol): add pinned webhook verification
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a19254d59b
commit
91f53375b9
2 files changed
+64
-1
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
import { createHmac, generateKeyPairSync, randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ConnectProtocolError,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
relayEnvelopeSchema,
|
||||
signRequestEnvelope,
|
||||
verifyRelaySignature,
|
||||
verifyProviderWebhook,
|
||||
verifyRequestEnvelope,
|
||||
type ReplayStore,
|
||||
} from "./index.js";
|
||||
@@ -76,6 +77,27 @@ describe("connect protocol", () => {
|
||||
})).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies the pinned webhook profile", () => {
|
||||
const rawBody = Buffer.from('{"type":"deployment.created"}');
|
||||
const secret = "vercel-webhook-secret";
|
||||
const signature = createRelaySignature({ body: rawBody, relaySecret: Buffer.from(secret) }).slice(3);
|
||||
expect(verifyProviderWebhook({
|
||||
profile: { profile: "vercel@1", scheme: "hmac-sha256", signatureHeader: "x-vercel-signature", encoding: "hex", prefix: "" },
|
||||
rawBody,
|
||||
headers: { "X-Vercel-Signature": signature },
|
||||
secret,
|
||||
})).toBe("verified");
|
||||
});
|
||||
|
||||
it("rejects webhook signature downgrade attempts", () => {
|
||||
const rawBody = Buffer.from("payload");
|
||||
const secret = "secret";
|
||||
const sha1 = createHmac("sha1", secret).update(rawBody).digest("hex");
|
||||
const profile = { profile: "provider@1", scheme: "hmac-sha256" as const, signatureHeader: "x-signature", encoding: "hex" as const, prefix: "" };
|
||||
expect(verifyProviderWebhook({ profile, rawBody, headers: {}, secret })).toBe("rejected");
|
||||
expect(verifyProviderWebhook({ profile, rawBody, headers: { "x-signature": sha1, "x-paperclip-verifier-profile": "none" }, secret })).toBe("rejected");
|
||||
});
|
||||
|
||||
it("verifies an EdDSA request once and rejects its replay", async () => {
|
||||
const { privateKey, publicKey } = generateKeyPairSync("ed25519");
|
||||
const store = replayStore();
|
||||
|
||||
@@ -84,6 +84,47 @@ export const relayEnvelopeSchema = z.object({
|
||||
|
||||
export type RelayEnvelope = z.infer<typeof relayEnvelopeSchema>;
|
||||
|
||||
export const webhookVerifierProfileSchema = z.object({
|
||||
profile: z.string().min(1),
|
||||
scheme: z.enum(["hmac-sha256", "hmac-sha1", "static-token", "none"]),
|
||||
signatureHeader: z.string().min(1).transform((value) => value.toLowerCase()),
|
||||
encoding: z.enum(["hex", "base64"]).default("hex"),
|
||||
prefix: z.string().default(""),
|
||||
});
|
||||
|
||||
export type WebhookVerifierProfile = z.infer<typeof webhookVerifierProfileSchema>;
|
||||
|
||||
function constantTimeEqual(expected: string, actual: string): boolean {
|
||||
const expectedBytes = Buffer.from(expected, "utf8");
|
||||
const actualBytes = Buffer.from(actual, "utf8");
|
||||
return expectedBytes.length === actualBytes.length && timingSafeEqual(expectedBytes, actualBytes);
|
||||
}
|
||||
|
||||
export function verifyProviderWebhook(input: {
|
||||
profile: WebhookVerifierProfile;
|
||||
rawBody: Uint8Array;
|
||||
headers: Record<string, string | string[] | undefined>;
|
||||
secret?: Uint8Array | string;
|
||||
}): "verified" | "unsigned" | "rejected" {
|
||||
const profile = webhookVerifierProfileSchema.parse(input.profile);
|
||||
if (profile.scheme === "none") return "unsigned";
|
||||
if (input.secret === undefined) return "rejected";
|
||||
|
||||
const headers = new Map(Object.entries(input.headers).map(([key, value]) => [key.toLowerCase(), value]));
|
||||
const headerValue = headers.get(profile.signatureHeader);
|
||||
const actual = Array.isArray(headerValue) ? headerValue[0] : headerValue;
|
||||
if (!actual) return "rejected";
|
||||
|
||||
if (profile.scheme === "static-token") {
|
||||
const expected = typeof input.secret === "string" ? input.secret : Buffer.from(input.secret).toString("utf8");
|
||||
return constantTimeEqual(`${profile.prefix}${expected}`, actual) ? "verified" : "rejected";
|
||||
}
|
||||
|
||||
const algorithm = profile.scheme === "hmac-sha256" ? "sha256" : "sha1";
|
||||
const digest = createHmac(algorithm, input.secret).update(input.rawBody).digest(profile.encoding);
|
||||
return constantTimeEqual(`${profile.prefix}${digest}`, actual) ? "verified" : "rejected";
|
||||
}
|
||||
|
||||
export const requestEnvelopePayloadSchema = z.object({
|
||||
iss: opaqueId("in"),
|
||||
aud: z.literal(CONNECT_SERVICE_AUDIENCE),
|
||||
|
||||
Reference in new issue
Block a user