diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..1632975 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,13 @@ +{ + "root": true, + "env": { + "node": true, + "es2021": true + }, + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "extends": ["eslint:recommended", "plugin:node/recommended", "prettier"], + "ignorePatterns": ["docs"] +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b7c2699 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: push + +jobs: + lint-and-test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: ['18.x', '20.x'] + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js v${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + cache-dependency-path: 'package.json' + - name: Install npm dependencies + run: npm install + - name: Run lint + run: npm run lint + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfe6ad8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# npm +/node_modules +/package-lock.json + +# JSDoc +/docs diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..7a2e17a --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "proseWrap": "always", + "semi": false, + "singleQuote": true +} diff --git a/.release-it.json b/.release-it.json new file mode 100644 index 0000000..b06260f --- /dev/null +++ b/.release-it.json @@ -0,0 +1,11 @@ +{ + "git": { + "commitMessage": "v${version}", + "tagName": "v${version}", + "tagAnnotation": "v${version}" + }, + "github": { + "release": true, + "releaseName": "v${version}" + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f8797a3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2023 Truepic + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ebb4e3 --- /dev/null +++ b/README.md @@ -0,0 +1,218 @@ +

+ Truepic Webhook Verifier for Node.js +

+ +

+ Verify webhooks from Truepic Vision or Lens in your Node.js app +

+ +This module verifies + +- the integrity of the data being received, +- the authenticity of the sender (Truepic), +- the authenticity of the receiver (you), and +- the time between the request being sent and received to prevent replay + attacks. + +If you're not using Node.js, this also serves as a reference implementation with +thorough documentation to make the translation into another language as painless +as possible. + +## Installation + +```bash +npm install @truepic/webhook-verifier +``` + +## Usage + +The `@truepic/webhook-verifier` module exports a default function that should be +imported to begin: + +```js +import verifyTruepicWebhook from '@truepic/webhook-verifier' +``` + +This `verifyTruepicWebhook` function (or whatever you imported it as) is then +called with the following arguments: + +```js +verifyTruepicWebhook({ + url: 'The full URL that received the request and is registered with Truepic.', + secret: "The shared secret that's registered with Truepic.", + header: 'The value of the `truepic-signature` header from the request.', + body: 'The raw body (unparsed JSON) from the request.', + leewayMinutes: + 'The number of minutes allowed between the request being sent and received. Defaults to `5`.', +}) +``` + +A boolean `true` is returned if the webhook is verified in all of the ways +described above. Otherwise, if anything fails to check out, a +`TruepicWebhookVerifierError` is thrown with a message describing why (as much +as possible). + +You should place this function call at the beginning of your webhook route +handler. Exactly how this is done depends on the web framework that you're +using. Below are a few examples for popular web frameworks that should be easy +to adapt if you're using a different one. + +### Example: Express.js + +```js +import verifyTruepicWebhook from '@truepic/webhook-verifier' +import express from 'express' +import { env } from 'node:process' + +const app = express() + +app.post( + '/truepic/webhook', + // This is important! We need the raw request body for `verifyTruepicWebhook`. + express.raw({ + type: 'application/json', + }), + (req, res, next) => { + try { + verifyTruepicWebhook({ + url: env.TRUEPIC_WEBHOOK_URL, + secret: env.TRUEPIC_WEBHOOK_SECRET, + header: req.header('truepic-signature'), + body: req.body.toString(), + }) + } catch (error) { + // The request cannot be verified. We're simply logging a warning here, + // but you can handle however makes sense. + console.warn(error) + + // Return OK so a (potential) bad actor doesn't gain any insight. + return res.sendStatus(200) + } + + // Process the webhook now that it's verified... + + res.sendStatus(200) + }, +) + +// The rest of your app... +``` + +### Example: Fastify + +```bash +npm install fastify-raw-body +``` + +```js +import verifyTruepicWebhook from '@truepic/webhook-verifier' +import Fastify from 'fastify' +import { env } from 'node:process' + +const app = Fastify({ + logger: true, +}) + +// This is important! We need the raw request body for `verifyTruepicWebhook`. +await app.register(import('fastify-raw-body')) + +app.post('/truepic/webhook', async (request) => { + try { + verifyTruepicWebhook({ + url: env.TRUEPIC_WEBHOOK_URL, + secret: env.TRUEPIC_WEBHOOK_SECRET, + header: request.headers['truepic-signature'], + body: request.rawBody, + }) + } catch (error) { + // The request cannot be verified. We're simply logging a warning here, + // but you can handle however makes sense. + request.log.warn(error) + + // Return OK so a (potential) bad actor doesn't gain any insight. + return {} + } + + // Process the webhook now that it's verified... + + return {} +}) + +// The rest of your app... +``` + +## Development + +### Prerequisites + +The only prerequisite is a compatible version of Node.js (see `engines.node` in +[`package.json`](package.json)). + +### Dependencies + +Install dependencies with npm: + +```bash +npm install +``` + +### Tests + +The built-in Node.js [test runner](https://nodejs.org/docs/latest/api/test.html) +and [assertions module](https://nodejs.org/docs/latest/api/assert.html) is used +for testing. + +To run the tests: + +```bash +npm test +``` + +During development, it's recommended to run the tests automatically on file +change: + +```bash +npm test -- --watch +``` + +### Docs + +[JSDoc](https://jsdoc.app/) is used to document the code. + +To generate the docs as HTML to the (git-ignored) `docs` directory: + +```bash +npm run docs +``` + +### Code Style & Linting + +[Prettier](https://prettier.io/) is setup to enforce a consistent code style. +It's highly recommended to +[add an integration to your editor](https://prettier.io/docs/en/editors.html) +that automatically formats on save. + +[ESLint](https://eslint.org/) is setup with the +["recommended" rules](https://eslint.org/docs/latest/rules/) to enforce a level +of code quality. It's also highly recommended to +[add an integration to your editor](https://eslint.org/docs/latest/use/integrations#editors) +that automatically formats on save. + +To run via the command line: + +```bash +npm run lint +``` + +### Releasing + +When the `development` branch is ready for release, +[Release It!](https://github.com/release-it/release-it) is used to orchestrate +the release process: + +```bash +npm run release +``` + +Once the release process is complete, merge the `development` branch into the +`main` branch, which should always reflect the latest release. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f531fbc --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@truepic/webhook-verifier", + "version": "1.0.0", + "type": "module", + "description": "Verify webhooks from Truepic Vision or Lens in your Node.js app", + "homepage": "https://github.com/TRUEPIC/webhook-verifier-nodejs#readme", + "bugs": "https://github.com/TRUEPIC/webhook-verifier-nodejs/issues", + "license": "MIT", + "main": "./src/main.js", + "repository": "TRUEPIC/webhook-verifier-nodejs", + "scripts": { + "docs": "jsdoc src --recurse --destination docs", + "lint": "npm run lint:format && npm run lint:quality", + "lint:format": "prettier --check .", + "lint:format:fix": "prettier --write .", + "lint:quality": "eslint .", + "lint:quality:fix": "eslint --fix .", + "release": "release-it --only-version", + "test": "node --test" + }, + "devDependencies": { + "eslint": "^8.55.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-node": "^11.1.0", + "jsdoc": "^4.0.2", + "prettier": "3.1.0", + "release-it": "^17.0.0" + }, + "engines": { + "node": ">=18" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/error.js b/src/error.js new file mode 100644 index 0000000..3abdae3 --- /dev/null +++ b/src/error.js @@ -0,0 +1,22 @@ +/** + * The custom error thrown when verification fails. + * + * @memberof module:@truepic/webhook-verifier + * @extends Error + */ +class TruepicWebhookVerifierError extends Error { + /** + * Create an error for a failed verification. + * + * @param {string} message The description of what failed. + */ + constructor(message) { + super(message) + + Error.captureStackTrace(this, this.constructor) + + this.name = this.constructor.name + } +} + +export default TruepicWebhookVerifierError diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..42a8ef0 --- /dev/null +++ b/src/main.js @@ -0,0 +1,168 @@ +import { createHmac, timingSafeEqual } from 'node:crypto' +import TruepicWebhookVerifierError from './error.js' + +/** + * Parse the `truepic-signature` header into timestamp and signature values. + * + * The header value looks like this: + * + * t=1634066973,s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU + * + * The `t` value is the timestamp of when the request was sent (in seconds), and + * the `s` value is the signature of the request. + * + * @private + * @param {string} header The value of the `truepic-signature` header from the request. + * @throws {TruepicWebhookVerifierError} If parsing fails. + * @returns {Object} The parsed `timestamp` and `signature` values. + */ +function parseHeader(header) { + if (!header?.length) { + throw new TruepicWebhookVerifierError('Header is missing or empty') + } + + // Split the the header value on the comma (`,`). This should leave two parts: + // - t=1634066973 + // - s=6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU + const [timestampParts, signatureParts] = header.split(',') + + if (!timestampParts?.length || !signatureParts?.length) { + throw new TruepicWebhookVerifierError( + 'Header cannot be parsed into timestamp and signature', + ) + } + + // Split the timestamp (`t`) on the equals (`=`). This should leave two parts: + // - t + // - 1634066973 + let [t, timestamp] = timestampParts.split('=') + + if (t !== 't' || !timestamp?.length) { + throw new TruepicWebhookVerifierError('Timestamp is missing or empty') + } + + // Cast and verify that the timestamp value is a number. + timestamp = Number(timestamp) + + if (isNaN(timestamp)) { + throw new TruepicWebhookVerifierError('Timestamp is not a number') + } + + // Split the signature (`s`) on the equals (`=`). This should leave two parts: + // - s + // - 6FBEiVZ8EO79dk5XllfnG18b83ZvLt2kdxcE8FJ/BwU + const [s, signature] = signatureParts.split('=') + + if (s !== 's' || !signature?.length) { + throw new TruepicWebhookVerifierError('Signature is missing or empty') + } + + return { timestamp, signature } +} + +/** + * Verify the timestamp to ensure the request is recent and not a potentially + * delayed replay attack. Some leeway is required in case the clocks on either + * end of the request aren't quite in sync. + * + * @private + * @param {Object} options + * @param {number} options.timestamp The timestamp parsed from the `truepic-signature` request header. + * @param {number} options.leewayMinutes The number of minutes allowed between the request being sent and received. + * @throws {TruepicWebhookVerifierError} If verification fails. + * @returns {true} If verification succeeds. + */ +function verifyTimestamp({ timestamp, leewayMinutes }) { + const diff = Math.abs(Date.now() - timestamp * 1000) + const diffMinutes = Math.ceil(diff / (1000 * 60)) + + if (diffMinutes > leewayMinutes) { + throw new TruepicWebhookVerifierError( + 'Timestamp is not within allowed window', + ) + } + + return true +} + +/** + * Verify the signature to ensure the integrity of the data being received, the + * authenticity of the sender (Truepic), and the authenticity of the receiver + * (you). + * + * @private + * @param {Object} options + * @param {string} options.url The full URL that received the request and is registered with Truepic. + * @param {string} options.secret The shared secret that's registered with Truepic. + * @param {string} options.body The raw body (unparsed JSON) from the request. + * @param {number} options.timestamp The timestamp parsed from the `truepic-signature` request header. + * @param {string} options.signature The signature parsed from the `truepic-signature` request header. + * @throws {TruepicWebhookVerifierError} If verification fails. + * @returns {true} If verification succeeds. + */ +function verifySignature({ url, secret, body, timestamp, signature }) { + // Rebuild the signature (SHA-256, base64-encoded HMAC digest) with a secret + // that only Truepic and the intended receiver are privy to. + const comparisonSignature = createHmac('sha256', secret) + + // Concatenate the full URL that received the request, timestamp parsed from + // the header, and raw body (unparsed JSON) from the request using a comma + // (`,`). It's important to use the raw body before it's parsed as JSON, as + // different languages/frameworks can parse/stringify JSON in subtly different + // ways, which can result in a different signature. + comparisonSignature.update([url, timestamp, body].join(',')) + + // Compare with a constant-time algorithm to prevent a timing attack. + const isEqual = timingSafeEqual( + Buffer.from(comparisonSignature.digest('base64'), 'base64'), + Buffer.from(signature, 'base64'), + ) + + if (!isEqual) { + throw new TruepicWebhookVerifierError('Signature is not valid') + } + + return true +} + +/** + * Verify a webhook from Truepic Vision or Lens. + * + * @memberof module:@truepic/webhook-verifier + * @param {Object} options + * @param {string} options.url The full URL that received the request and is registered with Truepic. + * @param {string} options.secret The shared secret that's registered with Truepic. + * @param {string} options.header The value of the `truepic-signature` header from the request. + * @param {string} options.body The raw body (unparsed JSON) from the request. + * @param {number} [options.leewayMinutes=5] The number of minutes allowed between the request being sent and received. + * @throws {TruepicWebhookVerifierError} If verification fails. + * @returns {true} If verification succeeds. + */ +function verifyTruepicWebhook({ + url, + secret, + header, + body, + leewayMinutes = 5, +}) { + const { timestamp, signature } = parseHeader(header) + + verifyTimestamp({ + timestamp, + leewayMinutes, + }) + + verifySignature({ + url, + secret, + body, + timestamp, + signature, + }) + + return true +} + +/** @module @truepic/webhook-verifier */ +export default verifyTruepicWebhook +export { TruepicWebhookVerifierError } diff --git a/src/main.test.js b/src/main.test.js new file mode 100644 index 0000000..90a56a6 --- /dev/null +++ b/src/main.test.js @@ -0,0 +1,214 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import verifyTruepicWebhook, { TruepicWebhookVerifierError } from './main.js' + +describe('verifyTruepicWebhook', () => { + // Successful values. + const url = 'http://localhost:3001/webhook' + const secret = 'secret' + const header = 't=1698259719,s=S9lwmAyba6aYa/Ts2jlJ6venPhSvlGjd0QdNsvi8iq8=' + const body = + '{"type":"captures.created","data":{"id":"dd4b8e37-0e2e-47de-91d1-b3eb00aa9d36","type":"PHOTO","status":"WAITING","custom_data":null,"uploaded_by_ip_address":"::1","file_size":2878119,"file_hash":"fVEXbAR0bs0EqIYtJoCRUz067zCJWGp6yW+xwKMHPtw=","created_at":"2023-10-25T18:48:39.479Z","updated_at":"2023-10-25T18:48:39.479Z","processed_at":null,"url":"http://localhost:4566/lens-captures-development/dd4b8e37-0e2e-47de-91d1-b3eb00aa9d36.jpg?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=abc%2F20231025%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20231025T184839Z&X-Amz-Expires=36000&X-Amz-Signature=857b33be14592b1618093293d665a41c1043a5cb5c0fef31951a5390bcc8be03&X-Amz-SignedHeaders=host&x-id=GetObject"}}' + const leewayMinutes = 999999999 + + it('returns `true` if verification is successful', () => { + assert.strictEqual( + verifyTruepicWebhook({ + url, + secret, + header, + body, + leewayMinutes, + }), + true, + ) + }) + + describe('throws a `TruepicWebhookVerifierError`', () => { + it('if the `header` is missing', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: null, + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Header is missing or empty'), + ) + }) + + it('if the `header` is empty', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: '', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Header is missing or empty'), + ) + }) + + it('if the `header` cannot be parsed into timestamp and signature', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 'bad', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError( + 'Header cannot be parsed into timestamp and signature', + ), + ) + }) + + it('if the `header` is missing the timestamp (`t`)', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 'b=bad,s=test', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Timestamp is missing or empty'), + ) + }) + + it('if the `header` timestamp (`t`) is empty', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 't=,s=test', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Timestamp is missing or empty'), + ) + }) + + it('if the `header` timestamp (`t`) is not a number', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 't=bad,s=test', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Timestamp is not a number'), + ) + }) + + it('if the `header` is missing the signature (`s`)', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 't=123,b=bad', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is missing or empty'), + ) + }) + + it('if the `header` signature (`s`) is empty', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: 't=123,s=', + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is missing or empty'), + ) + }) + + it('if the timestamp is not within allowed window', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header, + body, + leewayMinutes: 5, + }), + new TruepicWebhookVerifierError( + 'Timestamp is not within allowed window', + ), + ) + }) + + it('if the `url` is not where the request was sent', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url: 'http://bad/webhook', + secret, + header, + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is not valid'), + ) + }) + + it('if the `timestamp` is not what was signed', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header: header.replace('t=1698259719', 't=1698259718'), + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is not valid'), + ) + }) + + it('if the `body` is not what was signed', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret, + header, + body: '{"bad":"webhook"}', + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is not valid'), + ) + }) + + it('if the `secret` is not what was used to sign', () => { + assert.throws( + () => + verifyTruepicWebhook({ + url, + secret: 'bad', + header, + body, + leewayMinutes, + }), + new TruepicWebhookVerifierError('Signature is not valid'), + ) + }) + }) +})