feat: use discord-verify

This commit is contained in:
2025-07-06 13:22:05 -07:00
parent 2e2459d8a5
commit 0279b5ab4b
4 changed files with 123 additions and 49 deletions

View File

@ -22,9 +22,8 @@
"typescript": "5.7.3" "typescript": "5.7.3"
}, },
"dependencies": { "dependencies": {
"discord-verify": "1.2.0",
"fastify": "5.2.1", "fastify": "5.2.1",
"nodemailer": "6.10.0", "nodemailer": "6.10.0"
"secure-json-parse": "4.0.0",
"tweetnacl": "1.0.3"
} }
} }

99
pnpm-lock.yaml generated
View File

@ -8,18 +8,15 @@ importers:
.: .:
dependencies: dependencies:
discord-verify:
specifier: 1.2.0
version: 1.2.0
fastify: fastify:
specifier: 5.2.1 specifier: 5.2.1
version: 5.2.1 version: 5.2.1
nodemailer: nodemailer:
specifier: 6.10.0 specifier: 6.10.0
version: 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: devDependencies:
'@nhcarrigan/eslint-config': '@nhcarrigan/eslint-config':
specifier: 5.1.0 specifier: 5.1.0
@ -438,15 +435,33 @@ packages:
peerDependencies: peerDependencies:
eslint: '>=8.40.0' 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': '@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} 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': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json5@0.0.29': '@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
'@types/mime@1.3.5':
resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
'@types/node@22.13.1': '@types/node@22.13.1':
resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==}
@ -456,6 +471,18 @@ packages:
'@types/normalize-package-data@2.4.4': '@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} 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': '@typescript-eslint/eslint-plugin@8.19.0':
resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==} resolution: {integrity: sha512-NggSaEZCdSrFddbctrVjkVZvFC6KGfKfNK0CU7mNK/iKHGKbzT4Wmgm08dKpcZECBu9f5FypndoMyRHkdqfT1Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -837,6 +864,10 @@ packages:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'} engines: {node: '>=8'}
discord-verify@1.2.0:
resolution: {integrity: sha512-8qlrMROW8DhpzWWzgNq9kpeLDxKanWa4EDVoj/ASVv2nr+dSr4JPmu2tFSydf3hAGI/OIJTnZyD0JulMYIxx4w==}
engines: {node: '>=16'}
doctrine@2.1.0: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -1712,9 +1743,6 @@ packages:
secure-json-parse@3.0.2: secure-json-parse@3.0.2:
resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==} resolution: {integrity: sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==}
secure-json-parse@4.0.0:
resolution: {integrity: sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==}
semver@5.7.2: semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true hasBin: true
@ -1899,9 +1927,6 @@ packages:
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@ -2370,12 +2395,39 @@ snapshots:
- supports-color - supports-color
- typescript - 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/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/json-schema@7.0.15': {}
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
'@types/mime@1.3.5': {}
'@types/node@22.13.1': '@types/node@22.13.1':
dependencies: dependencies:
undici-types: 6.20.0 undici-types: 6.20.0
@ -2386,6 +2438,21 @@ snapshots:
'@types/normalize-package-data@2.4.4': {} '@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)': '@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: dependencies:
'@eslint-community/regexpp': 4.12.1 '@eslint-community/regexpp': 4.12.1
@ -2845,6 +2912,10 @@ snapshots:
dependencies: dependencies:
path-type: 4.0.0 path-type: 4.0.0
discord-verify@1.2.0:
dependencies:
'@types/express': 4.17.23
doctrine@2.1.0: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
@ -3938,8 +4009,6 @@ snapshots:
secure-json-parse@3.0.2: {} secure-json-parse@3.0.2: {}
secure-json-parse@4.0.0: {}
semver@5.7.2: {} semver@5.7.2: {}
semver@6.3.1: {} semver@6.3.1: {}
@ -4141,8 +4210,6 @@ snapshots:
tslib@2.8.1: {} tslib@2.8.1: {}
tweetnacl@1.0.3: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1

View File

@ -3,8 +3,8 @@
* @license Naomi's Public License * @license Naomi's Public License
* @author Naomi Carrigan * @author Naomi Carrigan
*/ */
import secureJson from "secure-json-parse"; import { webcrypto } from "node:crypto";
import nacl from "tweetnacl"; import { verify } from "discord-verify/node";
import { applicationData } from "../config/applicationData.js"; import { applicationData } from "../config/applicationData.js";
import { sendDiscord } from "./discord.js"; import { sendDiscord } from "./discord.js";
import type { Entitlement } from "../interfaces/entitlement.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. * @param request - The Fastify request object containing the webhook data.
* @returns A boolean indicating whether the webhook signature is valid. * @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. // eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify.
request: FastifyRequest<{ Body: Entitlement }>, request: FastifyRequest<{ Body: Entitlement; Headers: {
): boolean => { // 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<boolean> => {
const { application_id: applicationId } = request.body; const { application_id: applicationId } = request.body;
const appData = applicationData[applicationId]; const appData = applicationData[applicationId];
if (appData === undefined) { if (appData === undefined) {
@ -27,29 +32,13 @@ export const validateWebhook = (
} }
const signature = request.headers["x-signature-ed25519"]; const signature = request.headers["x-signature-ed25519"];
const timestamp = request.headers["x-signature-timestamp"]; const timestamp = request.headers["x-signature-timestamp"];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- Later. const rawBody = JSON.stringify(request.body);
const rawBody = secureJson.parse(JSON.stringify(request.body)); const isValid = await verify(
if ( rawBody,
signature === undefined signature,
|| timestamp === undefined timestamp,
|| rawBody === undefined appData.key,
) { webcrypto.subtle,
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"),
); );
if (!isValid) { if (!isValid) {
void sendDiscord( void sendDiscord(

View File

@ -122,10 +122,29 @@ export const instantiateServer = (): void => {
); );
// eslint-disable-next-line @typescript-eslint/naming-convention -- Body must be capitalised for Fastify. // 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", "/entitlement",
{ config: { rawBody: true } },
async(request, response) => { 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)) { if (!validateWebhook(request)) {
await response.status(401).send({ success: false }); await response.status(401).send({ success: false });
void sendDiscord( void sendDiscord(