From 5bf947fda76b527726f901344dfd7050466054de Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Tue, 27 Aug 2024 16:57:43 -0700 Subject: [PATCH 1/6] docs(README): `verifyRequest()` and `fetchVerificationKeys()` --- README.md | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8c7fc9b..3ebc0d0 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,50 @@ ## Usage -### `verify(rawBody, signature, keyId, options)` +### Verify a request + +```js +import { verifyRequestByKeyId } from "@copilot-extensions/preview-sdk"; + +const payloadIsVerified = await verifyRequestByKeyId( + request.body, + signature, + key, + { + token: process.env.GITHUB_TOKEN, + } +); +// true or false +``` + +## API + +### `async fetchVerificationKeys(options)` + +Fetches public keys for verifying copilot extension requests [from GitHub's API](https://api.github.com/meta/public_keys/copilot_api) +and returns them as an array. The request can be made without authentication, with a token, or with a custom [octokit request](https://github.com/octokit/request.js) instance. + +```js +import { fetchVerificationKeys } from "@copilot-extensions/preview-sdk"; + +// fetch without authentication +const [current] = await fetchVerificationKeys(); + +// with token +const [current] = await fetchVerificationKeys({ token: "ghp_1234" }); + +// with custom octokit request instance +const [current] = await fetchVerificationKeys({ request });) +``` + +### `async verifyRequestPayload(rawBody, signature, keyId)` + +Verify the request payload using the provided signature and key. Note that the raw body as received by GitHub must be passed, before any parsing. ```js import { verify } from "@copilot-extensions/preview-sdk"; -const payloadIsVerified = await verify(request.body, signature, keyId, { - token, -}); +const payloadIsVerified = await verify(request.body, signature, key); // true or false ``` From c94aeb61e2a398e2a98564fd8b18e4ba32bfb11f Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:59:59 -0700 Subject: [PATCH 2/6] test: `verifyRequestByKeyId()`, `verifyRequest()`, `fetchVerificationKeys()` --- test/integration.test.js | 97 +++++++++++++++++++++++++++++++++------- 1 file changed, 81 insertions(+), 16 deletions(-) diff --git a/test/integration.test.js b/test/integration.test.js index e918268..2484371 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -4,7 +4,11 @@ import assert from "node:assert/strict"; import { request as defaultRequest } from "@octokit/request"; import { MockAgent } from "undici"; -import { verify } from "../index.js"; +import { + fetchVerificationKeys, + verifyRequest, + verifyRequestByKeyId, +} from "../index.js"; const RAW_BODY = `{"copilot_thread_id":"9a1cc23a-ab73-498b-87a5-96c94cb7e3f3","messages":[{"role":"user","content":"@gr2m hi","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"@gr2m test","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"Current Date and Time (UTC): 2024-08-26 19:43:13\\nUser's Current URL: https://github.com/gr2m/sandbox\\nCurrent User's Login: gr2m\\n","name":"_session","copilot_references":[],"copilot_confirmations":null},{"role":"user","content":"","copilot_references":[{"type":"github.repository","data":{"type":"repository","id":102985470,"name":"sandbox","ownerLogin":"gr2m","ownerType":"User","readmePath":"README.md","description":"@gr2m's little sandbox to play","commitOID":"9b04fffccbb818b2e317394463731b66f1ec5e89","ref":"refs/heads/main","refInfo":{"name":"main","type":"branch"},"visibility":"public","languages":[{"name":"JavaScript","percent":100}]},"id":"gr2m/sandbox","is_implicit":false,"metadata":{"display_name":"gr2m/sandbox","display_icon":"","display_url":""}}],"copilot_confirmations":null},{"role":"user","content":"test","copilot_references":[],"copilot_confirmations":[]}],"stop":null,"top_p":0,"temperature":0,"max_tokens":0,"presence_penalty":0,"frequency_penalty":0,"copilot_skills":null,"agent":"gr2m"}`; const KEY_ID = @@ -20,10 +24,10 @@ const SIGNATURE = "MEYCIQC8aEmkYA/4EQrXEOi2OL9nfpbnrCxkMc6HrH7b6SogKgIhAIYBThcpzkCCswiV1+pOaPI+zFQF9ShG61puoKs9rJjq"; test("smoke", (t) => { - assert.equal(typeof verify, "function"); + assert.equal(typeof verifyRequestByKeyId, "function"); }); -test("minimal usage", async (t) => { +test("verifyRequestByKeyId()", async (t) => { const mockAgent = new MockAgent(); function fetchMock(url, opts) { opts ||= {}; @@ -54,67 +58,128 @@ test("minimal usage", async (t) => { "content-type": "application/json", "x-request-id": "", }, - }, + } ); const testRequest = defaultRequest.defaults({ request: { fetch: fetchMock }, }); - const result = await verify(RAW_BODY, SIGNATURE, KEY_ID, { + const result = await verifyRequestByKeyId(RAW_BODY, SIGNATURE, KEY_ID, { request: testRequest, }); assert.deepEqual(result, true); }); -test("invalid arguments", (t) => { - assert.rejects(verify(RAW_BODY, SIGNATURE), { +test("verifyRequestByKeyId() - invalid arguments", (t) => { + assert.rejects(verifyRequestByKeyId(RAW_BODY, SIGNATURE), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid keyId", }); - assert.rejects(verify("", SIGNATURE, KEY_ID), { + assert.rejects(verifyRequestByKeyId("", SIGNATURE, KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid payload", }); - assert.rejects(verify(1, SIGNATURE, KEY_ID), { + assert.rejects(verifyRequestByKeyId(1, SIGNATURE, KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid payload", }); - assert.rejects(verify(undefined, SIGNATURE, KEY_ID), { + assert.rejects(verifyRequestByKeyId(undefined, SIGNATURE, KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid payload", }); - assert.rejects(verify(RAW_BODY, "", KEY_ID), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, "", KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid signature", }); - assert.rejects(verify(RAW_BODY, 1, KEY_ID), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, 1, KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid signature", }); - assert.rejects(verify(RAW_BODY, undefined, KEY_ID), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, undefined, KEY_ID), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid signature", }); - assert.rejects(verify(RAW_BODY, SIGNATURE, ""), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, SIGNATURE, ""), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid keyId", }); - assert.rejects(verify(RAW_BODY, SIGNATURE, 1), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, SIGNATURE, 1), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid keyId", }); - assert.rejects(verify(RAW_BODY, SIGNATURE, undefined), { + assert.rejects(verifyRequestByKeyId(RAW_BODY, SIGNATURE, undefined), { name: "Error", message: "[@copilot-extensions/preview-sdk] Invalid keyId", }); }); + +test("verifyRequest() - valid", async (t) => { + const result = await verifyRequest(RAW_BODY, SIGNATURE, CURRENT_PUBLIC_KEY); + assert.deepEqual(result, true); +}); + +test("verifyRequest() - invalid", async (t) => { + const result = await verifyRequest(RAW_BODY, SIGNATURE, "invalid-key"); + assert.deepEqual(result, false); +}); + +test("fetchVerificationKeys()", async (t) => { + const mockAgent = new MockAgent(); + function fetchMock(url, opts) { + opts ||= {}; + opts.dispatcher = mockAgent; + return fetch(url, opts); + } + + const publicKeys = [ + { + key: "", + key_identifier: "", + is_current: true, + }, + { + key: "", + key_identifier: "", + is_current: true, + }, + ]; + + mockAgent.disableNetConnect(); + const mockPool = mockAgent.get("https://api.github.com"); + mockPool + .intercept({ + method: "get", + path: `/meta/public_keys/copilot_api`, + }) + .reply( + 200, + { + public_keys: publicKeys, + }, + { + headers: { + "content-type": "application/json", + "x-request-id": "", + }, + } + ); + const testRequest = defaultRequest.defaults({ + request: { fetch: fetchMock }, + }); + + const result = await fetchVerificationKeys({ + request: testRequest, + }); + + assert.deepEqual(result, publicKeys); +}); From a38934fb1a6628c91e9fb2db4de8334e910af21e Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:00:09 -0700 Subject: [PATCH 3/6] test(types): `verifyRequestByKeyId()`, `verifyRequest()`, `fetchVerificationKeys()` --- index.test-d.ts | 52 ++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 9 deletions(-) diff --git a/index.test-d.ts b/index.test-d.ts index 69423d3..3596d63 100644 --- a/index.test-d.ts +++ b/index.test-d.ts @@ -1,32 +1,66 @@ import { expectType } from "tsd"; import { request } from "@octokit/request"; -import { verify } from "./index.js"; +import { + fetchVerificationKeys, + verifyRequest, + verifyRequestByKeyId, + type VerificationPublicKey, +} from "./index.js"; const rawBody = ""; const signature = ""; const keyId = ""; +const key = "" const token = ""; -export async function verifyTest() { - const result = await verify(rawBody, signature, keyId); +export async function verifyRequestByKeyIdTest() { + const result = await verifyRequestByKeyId(rawBody, signature, keyId); expectType(result); // @ts-expect-error - first 3 arguments are required - verify(rawBody, signature); + verifyRequestByKeyId(rawBody, signature); // @ts-expect-error - rawBody must be a string - await verify(1, signature, keyId); + await verifyRequestByKeyId(1, signature, keyId); // @ts-expect-error - signature must be a string - await verify(rawBody, 1, keyId); + await verifyRequestByKeyId(rawBody, 1, keyId); // @ts-expect-error - keyId must be a string - await verify(rawBody, signature, 1); + await verifyRequestByKeyId(rawBody, signature, 1); // accepts a token argument - await verify(rawBody, signature, keyId, { token }); + await verifyRequestByKeyId(rawBody, signature, keyId, { token }); // accepts a request argument - await verify(rawBody, signature, keyId, { request }); + await verifyRequestByKeyId(rawBody, signature, keyId, { request }); } + +export async function verifyRequestTest() { + const result = await verifyRequest(rawBody, signature, key); + expectType(result); + + // @ts-expect-error - first 3 arguments are required + verifyRequest(rawBody, signature); + + // @ts-expect-error - rawBody must be a string + await verifyRequest(1, signature, key); + + // @ts-expect-error - signature must be a string + await verifyRequest(rawBody, 1, key); + + // @ts-expect-error - key must be a string + await verifyRequest(rawBody, signature, 1); +} + +export async function fetchVerificationKeysTest() { + const result = await fetchVerificationKeys(); + expectType(result); + + // accepts a token argument + await fetchVerificationKeys({ token }); + + // accepts a request argument + await fetchVerificationKeys({ request }); +} \ No newline at end of file From df23ca2968cffaae927aecc39e35977305566f0c Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:00:19 -0700 Subject: [PATCH 4/6] feat: `verifyRequestByKeyId()`, `verifyRequest()`, `fetchVerificationKeys()` --- index.d.ts | 25 +++++++++++++++++-- index.js | 72 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/index.d.ts b/index.d.ts index 18f8180..ba08dcc 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,8 +5,27 @@ type RequestOptions = { request?: RequestInterface; token?: string; }; +export type VerificationPublicKey = { + key_identifier: string; + key: string; + is_current: boolean; +}; + +interface VerifyRequestInterface { + ( + rawBody: string, + signature: string, + key: string + ): Promise; +} + +interface FetchVerificationKeysInterface { + ( + requestOptions?: RequestOptions, + ): Promise; +} -interface VerifyInterface { +interface VerifyRequestByKeyIdInterface { ( rawBody: string, signature: string, @@ -15,4 +34,6 @@ interface VerifyInterface { ): Promise; } -export declare const verify: VerifyInterface; +export declare const verifyRequest: VerifyRequestInterface; +export declare const fetchVerificationKeys: FetchVerificationKeysInterface; +export declare const verifyRequestByKeyId: VerifyRequestByKeyIdInterface; diff --git a/index.js b/index.js index 7b8e50c..7fac892 100644 --- a/index.js +++ b/index.js @@ -5,48 +5,70 @@ import { createVerify } from "node:crypto"; import { request as defaultRequest } from "@octokit/request"; import { RequestError } from "@octokit/request-error"; -/** @type {import('.').VerifyInterface} */ -export async function verify( - rawBody, - signature, - keyId, - { token = "", request = defaultRequest } = { request: defaultRequest }, -) { +/** @type {import('.').VerifyRequestByKeyIdInterface} */ +export async function verifyRequest(rawBody, signature, key) { // verify arguments assertValidString(rawBody, "Invalid payload"); assertValidString(signature, "Invalid signature"); - assertValidString(keyId, "Invalid keyId"); + assertValidString(key, "Invalid key"); - // receive valid public keys from GitHub - const requestOptions = request.endpoint("GET /meta/public_keys/copilot_api", { + const verify = createVerify("SHA256").update(rawBody); + + // verify signature + try { + return verify.verify(key, signature, "base64"); + } catch { + return false; + } +} + +/** @type {import('.').FetchVerificationKeysInterface} */ +export async function fetchVerificationKeys( + { token = "", request = defaultRequest } = { request: defaultRequest } +) { + const { data } = await request("GET /meta/public_keys/copilot_api", { headers: token ? { Authorization: `token ${token}`, } : {}, }); - const response = await request(requestOptions); - const { data: keys } = response; + + return data.public_keys; +} + +/** @type {import('.').VerifyRequestByKeyIdInterface} */ +export async function verifyRequestByKeyId( + rawBody, + signature, + keyId, + requestOptions +) { + // verify arguments + assertValidString(rawBody, "Invalid payload"); + assertValidString(signature, "Invalid signature"); + assertValidString(keyId, "Invalid keyId"); + + // receive valid public keys from GitHub + const keys = await fetchVerificationKeys(requestOptions); // verify provided key Id - const publicKey = keys.public_keys.find( - (key) => key.key_identifier === keyId, - ); + const publicKey = keys.find((key) => key.key_identifier === keyId); + if (!publicKey) { - throw new RequestError( - "[@copilot-extensions/preview-sdk] No public key found matching key identifier", - 404, + const keyNotFoundError = Object.assign( + new Error( + "[@copilot-extensions/preview-sdk] No public key found matching key identifier" + ), { - request: requestOptions, - response, - }, + keyId, + keys, + } ); + throw keyNotFoundError; } - const verify = createVerify("SHA256").update(rawBody); - - // verify signature - return verify.verify(publicKey.key, signature, "base64"); + return verifyRequest(rawBody, signature, publicKey.key); } function assertValidString(value, message) { From 34257a84bd09ebb6d186e4b52a87f10dc46af442 Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 28 Aug 2024 11:07:27 -0700 Subject: [PATCH 5/6] docs(README): add `verifyRequestByKeyId()` to API section --- README.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/README.md b/README.md index 3ebc0d0..da9b323 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,28 @@ const payloadIsVerified = await verifyRequestByKeyId( ## API +### `async verifyRequestByKeyId(rawBody, signature, keyId, options)` + +Verify the request payload using the provided signature and key ID. The method will request the public key from GitHub's API for the given keyId and then verify the payload. + +The `options` argument is optional. It can contain a `token` to authenticate the request to GitHub's API, or a custom `request` instance to use for the request. + +```js +import { verifyRequestByKeyId } from "@copilot-extensions/preview-sdk"; + +const payloadIsVerified = await verifyRequestByKeyId( + request.body, + signature, + key +); + +// with token +await verifyRequestByKeyId(request.body, signature, key, { token: "ghp_1234" }); + +// with custom octokit request instance +await verifyRequestByKeyId(request.body, signature, key, { request }); +``` + ### `async fetchVerificationKeys(options)` Fetches public keys for verifying copilot extension requests [from GitHub's API](https://api.github.com/meta/public_keys/copilot_api) From d10abdb7e878ee3813ca82489375565ec58f452b Mon Sep 17 00:00:00 2001 From: Gregor Martynus <39992+gr2m@users.noreply.github.com> Date: Wed, 28 Aug 2024 11:08:32 -0700 Subject: [PATCH 6/6] docs(README): fix method name --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index da9b323..b098763 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,11 @@ Verify the request payload using the provided signature and key. Note that the r ```js import { verify } from "@copilot-extensions/preview-sdk"; -const payloadIsVerified = await verify(request.body, signature, key); +const payloadIsVerified = await verifyRequestPayload( + request.body, + signature, + key +); // true or false ```