From 3335a6925224cdab2ac5ee0775ff79f69cd527f2 Mon Sep 17 00:00:00 2001 From: Dominic Charley-Roy Date: Tue, 9 Nov 2021 14:45:43 -0500 Subject: [PATCH] Add SubtleCryptoProvider and update Webhooks to allow async crypto. --- lib/Webhooks.js | 220 +++++++---- lib/crypto/CryptoProvider.js | 15 + lib/crypto/NodeCryptoProvider.js | 6 + lib/crypto/SubtleCryptoProvider.js | 67 ++++ lib/stripe.js | 22 ++ test/Webhook.spec.js | 457 +++++++++++------------ test/crypto/SubtleCryptoProvider.spec.js | 47 +++ test/crypto/helpers.js | 64 ++-- testUtils/index.js | 4 + types/Webhooks.d.ts | 51 ++- types/crypto/crypto.d.ts | 34 ++ types/test/typescriptTest.ts | 15 + 12 files changed, 682 insertions(+), 320 deletions(-) create mode 100644 lib/crypto/SubtleCryptoProvider.js create mode 100644 test/crypto/SubtleCryptoProvider.spec.js diff --git a/lib/Webhooks.js b/lib/Webhooks.js index 9a2e7b1d92..ffbe2004a9 100644 --- a/lib/Webhooks.js +++ b/lib/Webhooks.js @@ -19,6 +19,25 @@ const Webhook = { return jsonPayload; }, + async constructEventAsync( + payload, + header, + secret, + tolerance, + cryptoProvider + ) { + await this.signature.verifyHeaderAsync( + payload, + header, + secret, + tolerance || Webhook.DEFAULT_TOLERANCE, + cryptoProvider + ); + + const jsonPayload = JSON.parse(payload); + return jsonPayload; + }, + /** * Generates a header to be used for webhook mocking * @@ -62,81 +81,156 @@ const Webhook = { const signature = { EXPECTED_SCHEME: 'v1', - verifyHeader(payload, header, secret, tolerance, cryptoProvider) { - payload = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; + verifyHeader( + encodedPayload, + encodedHeader, + secret, + tolerance, + cryptoProvider + ) { + const { + decodedHeader: header, + decodedPayload: payload, + details, + } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); - // Express's type for `Request#headers` is `string | []string` - // which is because the `set-cookie` header is an array, - // but no other headers are an array (docs: https://nodejs.org/api/http.html#http_message_headers) - // (Express's Request class is an extension of http.IncomingMessage, and doesn't appear to be relevantly modified: https://github.com/expressjs/express/blob/master/lib/request.js#L31) - if (Array.isArray(header)) { - throw new Error( - 'Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.' - ); - } - - header = Buffer.isBuffer(header) ? header.toString('utf8') : header; + cryptoProvider = cryptoProvider || getNodeCryptoProvider(); + const expectedSignature = cryptoProvider.computeHMACSignature( + makeHMACContent(payload, details), + secret + ); - const details = parseHeader(header, this.EXPECTED_SCHEME); + validateComputedSignature( + payload, + header, + details, + expectedSignature, + tolerance + ); - if (!details || details.timestamp === -1) { - throw new StripeSignatureVerificationError({ - message: 'Unable to extract timestamp and signatures from header', - detail: { - header, - payload, - }, - }); - } + return true; + }, - if (!details.signatures.length) { - throw new StripeSignatureVerificationError({ - message: 'No signatures found with expected scheme', - detail: { - header, - payload, - }, - }); - } + async verifyHeaderAsync( + encodedPayload, + encodedHeader, + secret, + tolerance, + cryptoProvider + ) { + const { + decodedHeader: header, + decodedPayload: payload, + details, + } = parseEventDetails(encodedPayload, encodedHeader, this.EXPECTED_SCHEME); cryptoProvider = cryptoProvider || getNodeCryptoProvider(); - const expectedSignature = cryptoProvider.computeHMACSignature( - `${details.timestamp}.${payload}`, + + const expectedSignature = await cryptoProvider.computeHMACSignatureAsync( + makeHMACContent(payload, details), secret ); - const signatureFound = !!details.signatures.filter( - utils.secureCompare.bind(utils, expectedSignature) - ).length; - - if (!signatureFound) { - throw new StripeSignatureVerificationError({ - message: - 'No signatures found matching the expected signature for payload.' + - ' Are you passing the raw request body you received from Stripe?' + - ' https://github.com/stripe/stripe-node#webhook-signing', - detail: { - header, - payload, - }, - }); - } + return validateComputedSignature( + payload, + header, + details, + expectedSignature, + tolerance + ); + }, +}; - const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp; +function makeHMACContent(payload, details) { + return `${details.timestamp}.${payload}`; +} - if (tolerance > 0 && timestampAge > tolerance) { - throw new StripeSignatureVerificationError({ - message: 'Timestamp outside the tolerance zone', - detail: { - header, - payload, - }, - }); - } +function parseEventDetails(encodedPayload, encodedHeader, expectedScheme) { + const decodedPayload = Buffer.isBuffer(encodedPayload) + ? encodedPayload.toString('utf8') + : encodedPayload; + + // Express's type for `Request#headers` is `string | []string` + // which is because the `set-cookie` header is an array, + // but no other headers are an array (docs: https://nodejs.org/api/http.html#http_message_headers) + // (Express's Request class is an extension of http.IncomingMessage, and doesn't appear to be relevantly modified: https://github.com/expressjs/express/blob/master/lib/request.js#L31) + if (Array.isArray(encodedHeader)) { + throw new Error( + 'Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.' + ); + } - return true; - }, -}; + const decodedHeader = Buffer.isBuffer(encodedHeader) + ? encodedHeader.toString('utf8') + : encodedHeader; + + const details = parseHeader(decodedHeader, expectedScheme); + + if (!details || details.timestamp === -1) { + throw new StripeSignatureVerificationError({ + message: 'Unable to extract timestamp and signatures from header', + detail: { + decodedHeader, + decodedPayload, + }, + }); + } + + if (!details.signatures.length) { + throw new StripeSignatureVerificationError({ + message: 'No signatures found with expected scheme', + detail: { + decodedHeader, + decodedPayload, + }, + }); + } + + return { + decodedPayload, + decodedHeader, + details, + }; +} + +function validateComputedSignature( + payload, + header, + details, + expectedSignature, + tolerance +) { + const signatureFound = !!details.signatures.filter( + utils.secureCompare.bind(utils, expectedSignature) + ).length; + + if (!signatureFound) { + throw new StripeSignatureVerificationError({ + message: + 'No signatures found matching the expected signature for payload.' + + ' Are you passing the raw request body you received from Stripe?' + + ' https://github.com/stripe/stripe-node#webhook-signing', + detail: { + header, + payload, + }, + }); + } + + const timestampAge = Math.floor(Date.now() / 1000) - details.timestamp; + + if (tolerance > 0 && timestampAge > tolerance) { + throw new StripeSignatureVerificationError({ + message: 'Timestamp outside the tolerance zone', + detail: { + header, + payload, + }, + }); + } + + return true; +} function parseHeader(header, scheme) { if (typeof header !== 'string') { diff --git a/lib/crypto/CryptoProvider.js b/lib/crypto/CryptoProvider.js index 8c9015a4a5..8f038d06ca 100644 --- a/lib/crypto/CryptoProvider.js +++ b/lib/crypto/CryptoProvider.js @@ -16,6 +16,21 @@ class CryptoProvider { computeHMACSignature(payload, secret) { throw new Error('computeHMACSignature not implemented.'); } + + /** + * Asynchronous version of `computeHMACSignature`. Some implementations may + * only allow support async signature computation. + * + * Computes a SHA-256 HMAC given a secret and a payload (encoded in UTF-8). + * The output HMAC should be encoded in hexadecimal. + * + * Sample values for implementations: + * - computeHMACSignature('', 'test_secret') => 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd' + * - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43 + */ + computeHMACSignatureAsync(payload, secret) { + throw new Error('computeHMACSignatureAsync not implemented.'); + } } module.exports = CryptoProvider; diff --git a/lib/crypto/NodeCryptoProvider.js b/lib/crypto/NodeCryptoProvider.js index dfbee490ec..83c2037ea2 100644 --- a/lib/crypto/NodeCryptoProvider.js +++ b/lib/crypto/NodeCryptoProvider.js @@ -15,6 +15,12 @@ class NodeCryptoProvider extends CryptoProvider { .update(payload, 'utf8') .digest('hex'); } + + /** @override */ + async computeHMACSignatureAsync(payload, secret) { + const signature = await this.computeHMACSignature(payload, secret); + return signature; + } } module.exports = NodeCryptoProvider; diff --git a/lib/crypto/SubtleCryptoProvider.js b/lib/crypto/SubtleCryptoProvider.js new file mode 100644 index 0000000000..5023e7cef7 --- /dev/null +++ b/lib/crypto/SubtleCryptoProvider.js @@ -0,0 +1,67 @@ +'use strict'; + +const CryptoProvider = require('./CryptoProvider'); + +/** + * `CryptoProvider which uses the SubtleCrypto interface of the Web Crypto API. + */ +class SubtleCryptoProvider extends CryptoProvider { + constructor(subtleCrypto) { + super(); + + // If no subtle crypto is interface, default to the global namespace. This + // is to allow custom interfaces (eg. using the Node webcrypto interface in + // tests). + this.subtleCrypto = subtleCrypto || crypto.subtle; + } + + /** @override */ + computeHMACSignature(payload, secret) { + throw new Error( + 'SubtleCryptoProvider cannot be used in a synchronous context.' + ); + } + + /** @override */ + async computeHMACSignatureAsync(payload, secret) { + const encoder = new TextEncoder('utf-8'); + + const key = await this.subtleCrypto.importKey( + 'raw', + encoder.encode(secret), + { + name: 'HMAC', + hash: {name: 'SHA-256'}, + }, + false, + ['sign'] + ); + + const signatureBuffer = await this.subtleCrypto.sign( + 'hmac', + key, + encoder.encode(payload) + ); + + // crypto.subtle returns the signature in base64 format. This must be + // encoded in hex to match the CryptoProvider contract. We map each byte in + // the buffer to its corresponding hex octet and then combine into a string. + const signatureBytes = new Uint8Array(signatureBuffer); + const signatureHexCodes = new Array(signatureBytes.length); + + for (let i = 0; i < signatureBytes.length; i++) { + signatureHexCodes[i] = byteHexMapping[signatureBytes[i]]; + } + + return signatureHexCodes.join(''); + } +} + +// Cached mapping of byte to hex representation. We do this once to avoid re- +// computing every time we need to convert the result of a signature to hex. +const byteHexMapping = new Array(256); +for (let i = 0; i < byteHexMapping.length; i++) { + byteHexMapping[i] = i.toString(16).padStart(2, '0'); +} + +module.exports = SubtleCryptoProvider; diff --git a/lib/stripe.js b/lib/stripe.js index 9c4966a39c..f928ee5a51 100644 --- a/lib/stripe.js +++ b/lib/stripe.js @@ -154,6 +154,28 @@ Stripe.createFetchHttpClient = (fetchFn) => { return new FetchHttpClient(fetchFn); }; +/** + * Create a CryptoProvider which uses the built-in Node crypto libraries for + * its crypto operations. + */ +Stripe.createNodeCryptoProvider = () => { + const NodeCryptoProvider = require('./crypto/NodeCryptoProvider'); + return new NodeCryptoProvider(); +}; + +/** + * Creates a CryptoProvider which uses the Subtle Crypto API from the Web + * Crypto API spec for its crypto operations. + * + * A SubtleCrypto interface can optionally be passed in as a parameter. If none + * is passed, will default to the default `crypto.subtle` object in the global + * scope. + */ +Stripe.createSubtleCryptoProvider = (subtleCrypto) => { + const SubtleCryptoProvider = require('./crypto/SubtleCryptoProvider'); + return new SubtleCryptoProvider(subtleCrypto); +}; + Stripe.prototype = { /** * @deprecated will be removed in a future major version. Use the config object instead: diff --git a/test/Webhook.spec.js b/test/Webhook.spec.js index 0668171a0c..6acbec4104 100644 --- a/test/Webhook.spec.js +++ b/test/Webhook.spec.js @@ -1,5 +1,6 @@ 'use strict'; +const {StripeSignatureVerificationError} = require('../lib/Error'); const {getSpyableStripe, FakeCryptoProvider} = require('../testUtils'); const stripe = getSpyableStripe(); const expect = require('chai').expect; @@ -30,291 +31,283 @@ describe('Webhooks', () => { }); }); - describe('.constructEvent', () => { - it('should return an Event instance from a valid JSON payload and valid signature header', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, - }); - - const event = stripe.webhooks.constructEvent( - EVENT_PAYLOAD_STRING, - header, - SECRET - ); - - expect(event.id).to.equal(EVENT_PAYLOAD.id); - }); + const makeConstructEventTests = (constructEventFn) => { + return () => { + it('should return an Event instance from a valid JSON payload and valid signature header', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + }); - it('should raise a JSON error from invalid JSON payload', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: '} I am not valid JSON; 123][', - secret: SECRET, - }); - expect(() => { - stripe.webhooks.constructEvent( - '} I am not valid JSON; 123][', - header, - SECRET - ); - }).to.throw(/Unexpected token/); - expect(() => { - stripe.webhooks.constructEvent( - '} I am not valid JSON; 123][', + const event = await constructEventFn( + EVENT_PAYLOAD_STRING, header, SECRET ); - }).to.throw(/Unexpected token/); - }); - - it('should raise a SignatureVerificationError from a valid JSON payload and an invalid signature header', () => { - const header = 'bad_header'; - - expect(() => { - stripe.webhooks.constructEvent(EVENT_PAYLOAD_STRING, header, SECRET); - }).to.throw(/Unable to extract timestamp and signatures from header/); - }); - it('should error if you pass a signature which is an array, even though our types say you can', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, + expect(event.id).to.equal(EVENT_PAYLOAD.id); }); - expect(() => { - stripe.webhooks.constructEvent(EVENT_PAYLOAD_STRING, [header], SECRET); - }).to.throw( - 'Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.' - ); - }); - - it('should invoke a custom CryptoProvider', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, - signature: 'fake signature', + it('should raise a JSON error from invalid JSON payload', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: '} I am not valid JSON; 123][', + secret: SECRET, + }); + await expect( + constructEventFn('} I am not valid JSON; 123][', header, SECRET) + ).to.be.rejectedWith(/Unexpected token/); + await expect( + constructEventFn('} I am not valid JSON; 123][', header, SECRET) + ).to.be.rejectedWith(/Unexpected token/); }); - const event = stripe.webhooks.constructEvent( - EVENT_PAYLOAD_STRING, - header, - SECRET, - undefined, - new FakeCryptoProvider() - ); - - expect(event.id).to.equal(EVENT_PAYLOAD.id); - }); - }); - - describe('.verifySignatureHeader', () => { - it('should raise a SignatureVerificationError when the header does not have the expected format', () => { - const header = "I'm not even a real signature header"; - - const expectedMessage = /Unable to extract timestamp and signatures from header/; - - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET - ); - }).to.throw(expectedMessage); + it('should raise a SignatureVerificationError from a valid JSON payload and an invalid signature header', async () => { + const header = 'bad_header'; - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - null, - SECRET + await expect( + constructEventFn(EVENT_PAYLOAD_STRING, header, SECRET) + ).to.be.rejectedWith( + /Unable to extract timestamp and signatures from header/ ); - }).to.throw(expectedMessage); + }); - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - undefined, - SECRET - ); - }).to.throw(expectedMessage); + it('should error if you pass a signature which is an array, even though our types say you can', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + }); - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - '', - SECRET + await expect( + constructEventFn(EVENT_PAYLOAD_STRING, [header], SECRET) + ).to.be.rejectedWith( + 'Unexpected: An array was passed as a header, which should not be possible for the stripe-signature header.' ); - }).to.throw(expectedMessage); - }); - - it('should raise a SignatureVerificationError when there are no signatures with the expected scheme', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, - scheme: 'v0', }); - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET - ); - }).to.throw(/No signatures found with expected scheme/); - }); - - it('should raise a SignatureVerificationError when there are no valid signatures for the payload', () => { - const header = stripe.webhooks.generateTestHeaderString({ - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, - signature: 'bad_signature', - }); + it('should invoke a custom CryptoProvider', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + signature: 'fake signature', + }); - expect(() => { - stripe.webhooks.signature.verifyHeader( + const event = await constructEventFn( EVENT_PAYLOAD_STRING, header, - SECRET + SECRET, + undefined, + new FakeCryptoProvider() ); - }).to.throw( - /No signatures found matching the expected signature for payload/ - ); - }); - it('should raise a SignatureVerificationError when the timestamp is not within the tolerance', () => { - const header = stripe.webhooks.generateTestHeaderString({ - timestamp: Date.now() / 1000 - 15, - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, + expect(event.id).to.equal(EVENT_PAYLOAD.id); + }); + }; + }; + + describe( + '.constructEvent', + makeConstructEventTests(async (...args) => { + const result = await stripe.webhooks.constructEvent(...args); + return result; + }) + ); + + describe( + '.constructEventAsync', + makeConstructEventTests((...args) => + stripe.webhooks.constructEventAsync(...args) + ) + ); + + const makeVerifySignatureHeaderTests = (verifyHeaderFn) => { + return () => { + it('should raise a SignatureVerificationError when the header does not have the expected format', async () => { + const header = "I'm not even a real signature header"; + + const expectedMessage = /Unable to extract timestamp and signatures from header/; + + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET) + ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, null, SECRET) + ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, undefined, SECRET) + ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); + + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, '', SECRET) + ).to.be.rejectedWith(StripeSignatureVerificationError, expectedMessage); }); - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET, - 10 - ); - }).to.throw(/Timestamp outside the tolerance zone/); - }); - - it( - 'should return true when the header contains a valid signature and ' + - 'the timestamp is within the tolerance', - () => { + it('should raise a SignatureVerificationError when there are no signatures with the expected scheme', async () => { const header = stripe.webhooks.generateTestHeaderString({ - timestamp: Date.now() / 1000, payload: EVENT_PAYLOAD_STRING, secret: SECRET, + scheme: 'v0', }); - expect( - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET, - 10 - ) - ).to.equal(true); - } - ); - - it('should return true when the header contains at least one valid signature', () => { - let header = stripe.webhooks.generateTestHeaderString({ - timestamp: Date.now() / 1000, - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET) + ).to.be.rejectedWith( + StripeSignatureVerificationError, + /No signatures found with expected scheme/ + ); }); - header += ',v1=potato'; + it('should raise a SignatureVerificationError when there are no valid signatures for the payload', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + signature: 'bad_signature', + }); - expect( - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET, - 10 - ) - ).to.equal(true); - }); + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET) + ).to.be.rejectedWith( + StripeSignatureVerificationError, + /No signatures found matching the expected signature for payload/ + ); + }); - it( - 'should return true when the header contains a valid signature ' + - 'and the timestamp is off but no tolerance is provided', - () => { + it('should raise a SignatureVerificationError when the timestamp is not within the tolerance', async () => { const header = stripe.webhooks.generateTestHeaderString({ - timestamp: 12345, + timestamp: Date.now() / 1000 - 15, payload: EVENT_PAYLOAD_STRING, secret: SECRET, }); - expect( - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET - ) - ).to.equal(true); - } - ); - - it('should accept Buffer instances for the payload and header', () => { - const header = stripe.webhooks.generateTestHeaderString({ - timestamp: Date.now() / 1000, - payload: EVENT_PAYLOAD_STRING, - secret: SECRET, + await expect( + verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET, 10) + ).to.be.rejectedWith( + StripeSignatureVerificationError, + /Timestamp outside the tolerance zone/ + ); }); - expect( - stripe.webhooks.signature.verifyHeader( - Buffer.from(EVENT_PAYLOAD_STRING), - Buffer.from(header), - SECRET, - 10 - ) - ).to.equal(true); - }); - - describe('custom CryptoProvider', () => { - const cryptoProvider = new FakeCryptoProvider(); + it( + 'should return true when the header contains a valid signature and ' + + 'the timestamp is within the tolerance', + async () => { + const header = stripe.webhooks.generateTestHeaderString({ + timestamp: Date.now() / 1000, + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + }); + + expect( + await verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET, 10) + ).to.equal(true); + } + ); - it('should use the provider to compute a signature (mismatch)', () => { - const header = stripe.webhooks.generateTestHeaderString({ + it('should return true when the header contains at least one valid signature', async () => { + let header = stripe.webhooks.generateTestHeaderString({ + timestamp: Date.now() / 1000, payload: EVENT_PAYLOAD_STRING, secret: SECRET, - signature: 'different fake signature', - timestamp: 123, }); - expect(() => { - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, - SECRET, - undefined, - cryptoProvider - ); - }).to.throw( - /No signatures found matching the expected signature for payload/ - ); + header += ',v1=potato'; + + expect( + await verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET, 10) + ).to.equal(true); }); - it('should use the provider to compute a signature (success)', () => { + + it( + 'should return true when the header contains a valid signature ' + + 'and the timestamp is off but no tolerance is provided', + async () => { + const header = stripe.webhooks.generateTestHeaderString({ + timestamp: 12345, + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + }); + + expect( + await verifyHeaderFn(EVENT_PAYLOAD_STRING, header, SECRET) + ).to.equal(true); + } + ); + + it('should accept Buffer instances for the payload and header', async () => { const header = stripe.webhooks.generateTestHeaderString({ + timestamp: Date.now() / 1000, payload: EVENT_PAYLOAD_STRING, secret: SECRET, - signature: 'fake signature', - timestamp: 123, }); expect( - stripe.webhooks.signature.verifyHeader( - EVENT_PAYLOAD_STRING, - header, + await verifyHeaderFn( + Buffer.from(EVENT_PAYLOAD_STRING), + Buffer.from(header), SECRET, - undefined, - cryptoProvider + 10 ) ).to.equal(true); }); - }); - }); + + describe('custom CryptoProvider', () => { + const cryptoProvider = new FakeCryptoProvider(); + + it('should use the provider to compute a signature (mismatch)', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + signature: 'different fake signature', + timestamp: 123, + }); + + await expect( + verifyHeaderFn( + EVENT_PAYLOAD_STRING, + header, + SECRET, + undefined, + cryptoProvider + ) + ).to.be.rejectedWith( + /No signatures found matching the expected signature for payload/ + ); + }); + it('should use the provider to compute a signature (success)', async () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + signature: 'fake signature', + timestamp: 123, + }); + + expect( + await verifyHeaderFn( + EVENT_PAYLOAD_STRING, + header, + SECRET, + undefined, + cryptoProvider + ) + ).to.equal(true); + }); + }); + }; + }; + + describe( + '.verifySignatureHeader', + makeVerifySignatureHeaderTests(async (...args) => { + const result = await stripe.webhooks.signature.verifyHeader(...args); + return result; + }) + ); + + describe( + '.verifySignatureHeaderAsync', + makeVerifySignatureHeaderTests((...args) => + stripe.webhooks.signature.verifyHeaderAsync(...args) + ) + ); }); diff --git a/test/crypto/SubtleCryptoProvider.spec.js b/test/crypto/SubtleCryptoProvider.spec.js new file mode 100644 index 0000000000..2fc067170d --- /dev/null +++ b/test/crypto/SubtleCryptoProvider.spec.js @@ -0,0 +1,47 @@ +'use strict'; + +const SubtleCryptoProvider = require('../../lib/crypto/SubtleCryptoProvider'); +const webcrypto = require('crypto').webcrypto; +const expect = require('chai').expect; + +// webcrypto is only available on Node 15+. +if (!webcrypto || !webcrypto.subtle) { + console.log( + `Skipping SubtleCryptoProvider tests. No 'webcrypto.subtle' available for ${process.version}.` + ); + return; +} + +const {createCryptoProviderTestSuite} = require('./helpers'); + +describe('SubtleCryptoProvider', () => { + const provider = new SubtleCryptoProvider(webcrypto.subtle); + + createCryptoProviderTestSuite(provider, true); + + it('does not support sync calls', () => { + expect(() => { + provider.computeHMACSignature('payload', 'secret'); + }).to.throw(/SubtleCryptoProvider cannot be used in a synchronous context/); + }); + + it('handles hex conversion properly (test boundaries + middle values)', async () => { + const fakeCryptoInterface = { + importKey: () => Promise.resolve({}), + sign: () => { + const buffer = new ArrayBuffer(3); + const view = new Uint8Array(buffer); + view[0] = 0; + view[1] = 255; + view[2] = 128; + return Promise.resolve(buffer); + }, + }; + + const signature = await new SubtleCryptoProvider( + fakeCryptoInterface + ).computeHMACSignatureAsync('payload', 'secret'); + + expect(signature).to.equal('00ff80'); + }); +}); diff --git a/test/crypto/helpers.js b/test/crypto/helpers.js index 2252972067..3c634323aa 100644 --- a/test/crypto/helpers.js +++ b/test/crypto/helpers.js @@ -8,33 +8,51 @@ const SECRET = 'test_secret'; * Test runner which runs a common set of tests for a given CryptoProvider to * make sure it satisfies the expected contract. */ -const createCryptoProviderTestSuite = (cryptoProvider) => { +const createCryptoProviderTestSuite = (cryptoProvider, isAsyncOnly = false) => { + const testCases = [ + { + caseName: 'empty payload', + payload: '', + expectation: + 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd', + }, + { + caseName: 'sample payload', + payload: JSON.stringify({obj1: 'hello', obj2: 'world'}), + expectation: + 'bebb1a643997f419b315ddba19e6f5411e1ce7f810ba6d3617ce72823092f363', + }, + { + caseName: 'payload with utf-8', + payload: '\ud83d\ude00', + expectation: + '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43', + }, + ]; + describe('CryptoProviderTestSuite', () => { - describe('computeHMACSignature', () => { - it('empty payload', () => { - expect(cryptoProvider.computeHMACSignature('', SECRET)).to.equal( - 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd' - ); + if (!isAsyncOnly) { + describe('computeHMACSignature', () => { + for (const testCase of testCases) { + it(testCase.caseName, () => { + expect( + cryptoProvider.computeHMACSignature(testCase.payload, SECRET) + ).to.equal(testCase.expectation); + }); + } }); + } - it('sample payload', () => { - expect( - cryptoProvider.computeHMACSignature( - JSON.stringify({obj1: 'hello', obj2: 'world'}), + describe('computeHMACSignatureAsync', () => { + for (const testCase of testCases) { + it(testCase.caseName, async () => { + const signature = await cryptoProvider.computeHMACSignatureAsync( + testCase.payload, SECRET - ) - ).to.equal( - 'bebb1a643997f419b315ddba19e6f5411e1ce7f810ba6d3617ce72823092f363' - ); - }); - - it('payload with utf-8', () => { - expect( - cryptoProvider.computeHMACSignature('\ud83d\ude00', SECRET) - ).to.equal( - '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43' - ); - }); + ); + expect(signature).to.equal(testCase.expectation); + }); + } }); }); }; diff --git a/testUtils/index.js b/testUtils/index.js index 0611b0122d..c05dc6f47f 100644 --- a/testUtils/index.js +++ b/testUtils/index.js @@ -226,5 +226,9 @@ const utils = (module.exports = { computeHMACSignature(payload, secret) { return 'fake signature'; } + + computeHMACSignatureAsync(payload, secret) { + return Promise.resolve('fake signature'); + } }, }); diff --git a/types/Webhooks.d.ts b/types/Webhooks.d.ts index 1ef2a303fb..9c9087e907 100644 --- a/types/Webhooks.d.ts +++ b/types/Webhooks.d.ts @@ -43,6 +43,47 @@ declare module 'stripe' { cryptoProvider?: CryptoProvider ): Stripe.Event; + /** + * Asynchronously constructs and verifies the signature of an Event from + * the provided details. + * + * @throws Stripe.errors.StripeSignatureVerificationError + */ + constructEventAsync( + /** + * Raw text body payload received from Stripe. + */ + payload: string | Buffer, + + /** + * Value of the `stripe-signature` header from Stripe. + * Typically a string. + * + * Note that this is typed to accept an array of strings + * so that it works seamlessly with express's types, + * but will throw if an array is passed in practice + * since express should never return this header as an array, + * only a string. + */ + header: string | Buffer | Array, + + /** + * Your Webhook Signing Secret for this endpoint (e.g., 'whsec_...'). + * You can get this [in your dashboard](https://dashboard.stripe.com/webhooks). + */ + secret: string, + + /** + * Seconds of tolerance on timestamps. + */ + tolerance?: number, + + /** + * Optional CryptoProvider to use for computing HMAC signatures. + */ + cryptoProvider?: CryptoProvider + ): Promise; + /** * Generates a header to be used for webhook mocking */ @@ -85,14 +126,20 @@ declare module 'stripe' { export class Signature { EXPECTED_SCHEME: 'v1'; - _computeSignature(payload: string, secret: string): string; verifyHeader( payload: string, header: string, secret: string, tolerance?: number, cryptoProvider?: CryptoProvider - ): void; + ): boolean; + verifyHeaderAsync( + payload: string, + header: string, + secret: string, + tolerance?: number, + cryptoProvider?: CryptoProvider + ): Promise; parseHeader( header: string, scheme?: string diff --git a/types/crypto/crypto.d.ts b/types/crypto/crypto.d.ts index 9a988fcbde..a806137d52 100644 --- a/types/crypto/crypto.d.ts +++ b/types/crypto/crypto.d.ts @@ -3,6 +3,10 @@ declare module 'stripe' { /** * Interface encapsulating the various crypto computations used by the library, * allowing pluggable underlying crypto implementations. + * + * Implementations can choose which methods they want to implement (eg. a + * CryptoProvider can be used which only implements the asynchronous + * versions of each crypto computation). */ export interface CryptoProvider { /** @@ -14,6 +18,36 @@ declare module 'stripe' { * - computeHMACSignature('\ud83d\ude00', 'test_secret') => '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43 */ computeHMACSignature: (payload: string, secret: string) => string; + + /** + * Asynchrnously computes a SHA-256 HMAC with a given secret and a payload + * (encoded in UTF-8). The output HMAC should be encoded in hexadecimal + * and respect the contract of computeHMACSignature. + */ + computeHMACSignatureAsync: ( + payload: string, + secret: string + ) => Promise; } + + /** + * Creates a CryptoProvider which uses the Node built-in `crypto` package. + * + * This supports both synchronous and asynchronous operations. + */ + export const createNodeCryptoProvider: () => CryptoProvider; + + /** + * Creates a CryptoProvider which uses the SubtleCrypto API from the Web + * Crypto API for its crypto computations. + * + * This only supports asynchronous operations. + * + * An optional SubtleCrypto object can be passed in. If none is provided, + * defaults to the `crypto.subtle` object in the global scope. + */ + export const createSubtleCryptoProvider: ( + subtleCrypto?: WindowOrWorkerGlobalScope['crypto']['subtle'] + ) => CryptoProvider; } } diff --git a/types/test/typescriptTest.ts b/types/test/typescriptTest.ts index f1d92de730..408ee3e1a7 100644 --- a/types/test/typescriptTest.ts +++ b/types/test/typescriptTest.ts @@ -249,3 +249,18 @@ async (): Promise => { const jsonResponse: object = await response.toJSON(); }; + +// Tests asynchronous webhook processing. +async (): Promise => { + const cryptoProvider = Stripe.createSubtleCryptoProvider(); + + const event = await stripe.webhooks.constructEventAsync( + 'body', + 'signature', + 'secret', + undefined, + cryptoProvider + ); + + const event2 = await stripe.events.retrieve(event.id); +};