diff --git a/package.json b/package.json index 071edfa..319d300 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,8 @@ "typescript": "5.7.3" }, "dependencies": { + "discord-verify": "1.2.0", "fastify": "5.2.1", - "nodemailer": "6.10.0", - "secure-json-parse": "4.0.0", - "tweetnacl": "1.0.3" + "nodemailer": "6.10.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a703b..79d9290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,18 +8,15 @@ importers: .: dependencies: + discord-verify: + specifier: 1.2.0 + version: 1.2.0 fastify: specifier: 5.2.1 version: 5.2.1 nodemailer: specifier: 6.10.0 version: 6.10.0 - secure-json-parse: - specifier: 4.0.0 - version: 4.0.0 - tweetnacl: - specifier: 1.0.3 - version: 1.0.3 devDependencies: '@nhcarrigan/eslint-config': specifier: 5.1.0 @@ -438,15 +435,33 @@ packages: peerDependencies: eslint: '>=8.40.0' + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/express-serve-static-core@4.19.6': + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + + '@types/express@4.17.23': + resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/mime@1.3.5': + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/node@22.13.1': resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} @@ -456,6 +471,18 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@0.17.5': + resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==} + + '@types/serve-static@1.15.8': + resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==} + '@typescript-eslint/eslint-plugin@8.19.0': resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -837,6 +864,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + discord-verify@1.2.0: + resolution: {integrity: sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w==} + engines: {node: '>=16'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1712,9 +1743,6 @@ packages: secure-json-parse@3.0.2: resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} - secure-json-parse@4.0.0: - resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==} - semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -1899,9 +1927,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tweetnacl@1.0.3: - resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} - type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2370,12 +2395,39 @@ snapshots: - supports-color - typescript + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.13.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.13.1 + '@types/estree@1.0.6': {} + '@types/express-serve-static-core@4.19.6': + dependencies: + '@types/node': 22.13.1 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.5 + + '@types/express@4.17.23': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.14.0 + '@types/serve-static': 1.15.8 + + '@types/http-errors@2.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/mime@1.3.5': {} + '@types/node@22.13.1': dependencies: undici-types: 6.20.0 @@ -2386,6 +2438,21 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@0.17.5': + dependencies: + '@types/mime': 1.3.5 + '@types/node': 22.13.1 + + '@types/serve-static@1.15.8': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.13.1 + '@types/send': 0.17.5 + '@typescript-eslint/eslint-plugin@8.19.0(@typescript-eslint/parser@8.19.0(eslint@9.20.0)(typescript@5.7.3))(eslint@9.20.0)(typescript@5.7.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -2845,6 +2912,10 @@ snapshots: dependencies: path-type: 4.0.0 + discord-verify@1.2.0: + dependencies: + '@types/express': 4.17.23 + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -3938,8 +4009,6 @@ snapshots: secure-json-parse@3.0.2: {} - secure-json-parse@4.0.0: {} - semver@5.7.2: {} semver@6.3.1: {} @@ -4141,8 +4210,6 @@ snapshots: tslib@2.8.1: {} - tweetnacl@1.0.3: {} - type-check@0.4.0: dependencies: prelude-ls: 1.2.1 diff --git a/src/modules/validateWebhook.ts b/src/modules/validateWebhook.ts index dc73686..030b640 100644 --- a/src/modules/validateWebhook.ts +++ b/src/modules/validateWebhook.ts @@ -3,8 +3,8 @@ * @license Naomi's Public License * @author Naomi Carrigan */ -import secureJson from "secure-json-parse"; -import nacl from "tweetnacl"; +import { webcrypto } from "node:crypto"; +import { verify } from "discord-verify/node"; import { applicationData } from "../config/applicationData.js"; import { sendDiscord } from "./discord.js"; import type { Entitlement } from "../interfaces/entitlement.js"; @@ -15,10 +15,15 @@ import type { FastifyRequest } from "fastify"; * @param request - The Fastify request object containing the webhook data. * @returns A boolean indicating whether the webhook signature is valid. */ -export const validateWebhook = ( +export const validateWebhook = async( // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. - request: FastifyRequest<{ Body: Entitlement }>, -): boolean => { + request: FastifyRequest<{ Body: Entitlement; Headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-ed25519": string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-timestamp": string; + }; }>, +): Promise => { const { application_id: applicationId } = request.body; const appData = applicationData[applicationId]; if (appData === undefined) { @@ -27,29 +32,13 @@ export const validateWebhook = ( } const signature = request.headers["x-signature-ed25519"]; const timestamp = request.headers["x-signature-timestamp"]; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Later. - const rawBody = secureJson.parse(JSON.stringify(request.body)); - if ( - signature === undefined - || timestamp === undefined - || rawBody === undefined - ) { - void sendDiscord( - `[NOTIFICATION]: Invalid Webhook Signature`, - `Received an entitlement event with a missing signature or timestamp.\nApplication ID: ${applicationId}\nSignature: ${signature}\nTimestamp: ${timestamp}\nRaw Body: ${rawBody}`, - ); - return false; - } - const isValid = nacl.sign.detached.verify( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- Being lazy here, tbh. - Buffer.from(`${timestamp}${rawBody}`), - Buffer.from( - Array.isArray(signature) - ? signature[0] ?? signature.join("") - : signature, - "hex", - ), - Buffer.from(appData.key, "hex"), + const rawBody = JSON.stringify(request.body); + const isValid = await verify( + rawBody, + signature, + timestamp, + appData.key, + webcrypto.subtle, ); if (!isValid) { void sendDiscord( diff --git a/src/server/serve.ts b/src/server/serve.ts index 2435863..e071b1c 100644 --- a/src/server/serve.ts +++ b/src/server/serve.ts @@ -122,10 +122,29 @@ export const instantiateServer = (): void => { ); // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. - server.post<{ Body: Entitlement }>( + server.post<{ Body: Entitlement; Headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-ed25519": string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- Header must be formatted for Fastify. + "x-signature-timestamp": string; + }; }>( "/entitlement", - { config: { rawBody: true } }, async(request, response) => { + const signature = req.headers["x-signature-ed25519"]; + const timestamp = req.headers["x-signature-timestamp"]; + const rawBody = JSON.stringify(req.body); + + const isValid = await verify( + rawBody, + signature, + timestamp, + this.client.publicKey, + crypto.webcrypto.subtle, + ); + + if (!isValid) { + return res.code(401).send("Invalid signature"); + } if (!validateWebhook(request)) { await response.status(401).send({ success: false }); void sendDiscord(