diff --git a/lib/Webhooks.js b/lib/Webhooks.js index ca362e5911..9a2e7b1d92 100644 --- a/lib/Webhooks.js +++ b/lib/Webhooks.js @@ -1,19 +1,18 @@ 'use strict'; -const crypto = require('crypto'); - const utils = require('./utils'); const {StripeError, StripeSignatureVerificationError} = require('./Error'); const Webhook = { DEFAULT_TOLERANCE: 300, // 5 minutes - constructEvent(payload, header, secret, tolerance) { + constructEvent(payload, header, secret, tolerance, cryptoProvider) { this.signature.verifyHeader( payload, header, secret, - tolerance || Webhook.DEFAULT_TOLERANCE + tolerance || Webhook.DEFAULT_TOLERANCE, + cryptoProvider ); const jsonPayload = JSON.parse(payload); @@ -29,6 +28,7 @@ const Webhook = { * @property {string} secret - Stripe webhook secret 'whsec_...' * @property {string} scheme - Version of API to hit. Defaults to 'v1'. * @property {string} signature - Computed webhook signature + * @property {CryptoProvider} cryptoProvider - Crypto provider to use for computing the signature if none was provided. Defaults to NodeCryptoProvider. */ generateTestHeaderString: function(opts) { if (!opts) { @@ -41,9 +41,11 @@ const Webhook = { Math.floor(opts.timestamp) || Math.floor(Date.now() / 1000); opts.scheme = opts.scheme || signature.EXPECTED_SCHEME; + opts.cryptoProvider = opts.cryptoProvider || getNodeCryptoProvider(); + opts.signature = opts.signature || - signature._computeSignature( + opts.cryptoProvider.computeHMACSignature( opts.timestamp + '.' + opts.payload, opts.secret ); @@ -60,14 +62,7 @@ const Webhook = { const signature = { EXPECTED_SCHEME: 'v1', - _computeSignature: (payload, secret) => { - return crypto - .createHmac('sha256', secret) - .update(payload, 'utf8') - .digest('hex'); - }, - - verifyHeader(payload, header, secret, tolerance) { + verifyHeader(payload, header, secret, tolerance, cryptoProvider) { payload = Buffer.isBuffer(payload) ? payload.toString('utf8') : payload; // Express's type for `Request#headers` is `string | []string` @@ -104,7 +99,8 @@ const signature = { }); } - const expectedSignature = this._computeSignature( + cryptoProvider = cryptoProvider || getNodeCryptoProvider(); + const expectedSignature = cryptoProvider.computeHMACSignature( `${details.timestamp}.${payload}`, secret ); @@ -168,6 +164,20 @@ function parseHeader(header, scheme) { ); } +let webhooksNodeCryptoProviderInstance = null; + +/** + * Lazily instantiate a NodeCryptoProvider instance. This is a stateless object + * so a singleton can be used here. + */ +function getNodeCryptoProvider() { + if (!webhooksNodeCryptoProviderInstance) { + const NodeCryptoProvider = require('./crypto/NodeCryptoProvider'); + webhooksNodeCryptoProviderInstance = new NodeCryptoProvider(); + } + return webhooksNodeCryptoProviderInstance; +} + Webhook.signature = signature; module.exports = Webhook; diff --git a/lib/crypto/CryptoProvider.js b/lib/crypto/CryptoProvider.js new file mode 100644 index 0000000000..8c9015a4a5 --- /dev/null +++ b/lib/crypto/CryptoProvider.js @@ -0,0 +1,21 @@ +'use strict'; + +/** + * Interface encapsulating the various crypto computations used by the library, + * allowing pluggable underlying crypto implementations. + */ +class CryptoProvider { + /** + * 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 + */ + computeHMACSignature(payload, secret) { + throw new Error('computeHMACSignature not implemented.'); + } +} + +module.exports = CryptoProvider; diff --git a/lib/crypto/NodeCryptoProvider.js b/lib/crypto/NodeCryptoProvider.js new file mode 100644 index 0000000000..dfbee490ec --- /dev/null +++ b/lib/crypto/NodeCryptoProvider.js @@ -0,0 +1,20 @@ +'use strict'; + +const crypto = require('crypto'); + +const CryptoProvider = require('./CryptoProvider'); + +/** + * `CryptoProvider which uses the Node `crypto` package for its computations. + */ +class NodeCryptoProvider extends CryptoProvider { + /** @override */ + computeHMACSignature(payload, secret) { + return crypto + .createHmac('sha256', secret) + .update(payload, 'utf8') + .digest('hex'); + } +} + +module.exports = NodeCryptoProvider; diff --git a/lib/stripe.js b/lib/stripe.js index 19ae1b0738..9cefc1e77b 100644 --- a/lib/stripe.js +++ b/lib/stripe.js @@ -54,6 +54,9 @@ const {HttpClient, HttpClientResponse} = require('./net/HttpClient'); Stripe.HttpClient = HttpClient; Stripe.HttpClientResponse = HttpClientResponse; +const CryptoProvider = require('./crypto/CryptoProvider'); +Stripe.CryptoProvider = CryptoProvider; + function Stripe(key, config = {}) { if (!(this instanceof Stripe)) { return new Stripe(key, config); diff --git a/test/Webhook.spec.js b/test/Webhook.spec.js index 5d4dcd5004..0668171a0c 100644 --- a/test/Webhook.spec.js +++ b/test/Webhook.spec.js @@ -1,6 +1,7 @@ 'use strict'; -const stripe = require('../testUtils').getSpyableStripe(); +const {getSpyableStripe, FakeCryptoProvider} = require('../testUtils'); +const stripe = getSpyableStripe(); const expect = require('chai').expect; const EVENT_PAYLOAD = { @@ -86,6 +87,24 @@ describe('Webhooks', () => { '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', + }); + + const event = stripe.webhooks.constructEvent( + EVENT_PAYLOAD_STRING, + header, + SECRET, + undefined, + new FakeCryptoProvider() + ); + + expect(event.id).to.equal(EVENT_PAYLOAD.id); + }); }); describe('.verifySignatureHeader', () => { @@ -254,5 +273,48 @@ describe('Webhooks', () => { ) ).to.equal(true); }); + + describe('custom CryptoProvider', () => { + const cryptoProvider = new FakeCryptoProvider(); + + it('should use the provider to compute a signature (mismatch)', () => { + const header = stripe.webhooks.generateTestHeaderString({ + 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/ + ); + }); + it('should use the provider to compute a signature (success)', () => { + const header = stripe.webhooks.generateTestHeaderString({ + payload: EVENT_PAYLOAD_STRING, + secret: SECRET, + signature: 'fake signature', + timestamp: 123, + }); + + expect( + stripe.webhooks.signature.verifyHeader( + EVENT_PAYLOAD_STRING, + header, + SECRET, + undefined, + cryptoProvider + ) + ).to.equal(true); + }); + }); }); }); diff --git a/test/crypto/NodeCryptoProvider.spec.js b/test/crypto/NodeCryptoProvider.spec.js new file mode 100644 index 0000000000..5b3e1f49a0 --- /dev/null +++ b/test/crypto/NodeCryptoProvider.spec.js @@ -0,0 +1,9 @@ +'use strict'; + +const NodeCryptoProvider = require('../../lib/crypto/NodeCryptoProvider'); + +const {createCryptoProviderTestSuite} = require('./helpers'); + +describe('NodeCryptoProvider', () => { + createCryptoProviderTestSuite(new NodeCryptoProvider()); +}); diff --git a/test/crypto/helpers.js b/test/crypto/helpers.js new file mode 100644 index 0000000000..2252972067 --- /dev/null +++ b/test/crypto/helpers.js @@ -0,0 +1,42 @@ +'use strict'; + +const expect = require('chai').expect; + +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) => { + describe('CryptoProviderTestSuite', () => { + describe('computeHMACSignature', () => { + it('empty payload', () => { + expect(cryptoProvider.computeHMACSignature('', SECRET)).to.equal( + 'f7f9bd47fb987337b5796fdc1fdb9ba221d0d5396814bfcaf9521f43fd8927fd' + ); + }); + + it('sample payload', () => { + expect( + cryptoProvider.computeHMACSignature( + JSON.stringify({obj1: 'hello', obj2: 'world'}), + SECRET + ) + ).to.equal( + 'bebb1a643997f419b315ddba19e6f5411e1ce7f810ba6d3617ce72823092f363' + ); + }); + + it('payload with utf-8', () => { + expect( + cryptoProvider.computeHMACSignature('\ud83d\ude00', SECRET) + ).to.equal( + '837da296d05c4fe31f61d5d7ead035099d9585a5bcde87de952012a78f0b0c43' + ); + }); + }); + }); +}; + +module.exports = {createCryptoProviderTestSuite}; diff --git a/testUtils/index.js b/testUtils/index.js index 145fc2f4d0..2aa034591c 100644 --- a/testUtils/index.js +++ b/testUtils/index.js @@ -8,6 +8,7 @@ require('chai').use(require('chai-as-promised')); const http = require('http'); +const CryptoProvider = require('../lib/crypto/CryptoProvider'); const ResourceNamespace = require('../lib/ResourceNamespace').ResourceNamespace; const testingHttpAgent = new http.Agent({keepAlive: false}); @@ -210,4 +211,10 @@ const utils = (module.exports = { return false; } }, + + FakeCryptoProvider: class extends CryptoProvider { + computeHMACSignature(payload, secret) { + return 'fake signature'; + } + }, }); diff --git a/types/2020-08-27/index.d.ts b/types/2020-08-27/index.d.ts index 94dd90936e..dd3f1ebcf3 100644 --- a/types/2020-08-27/index.d.ts +++ b/types/2020-08-27/index.d.ts @@ -1,6 +1,7 @@ // File generated from our OpenAPI spec /// +/// /// /// /// diff --git a/types/Webhooks.d.ts b/types/Webhooks.d.ts index ab713531a0..1ef2a303fb 100644 --- a/types/Webhooks.d.ts +++ b/types/Webhooks.d.ts @@ -35,7 +35,12 @@ declare module 'stripe' { /** * Seconds of tolerance on timestamps. */ - tolerance?: number + tolerance?: number, + + /** + * Optional CryptoProvider to use for computing HMAC signatures. + */ + cryptoProvider?: CryptoProvider ): Stripe.Event; /** @@ -66,6 +71,12 @@ declare module 'stripe' { * Computed webhook signature. */ signature?: string; + + /** + * Optional CryptoProvider to use for computing HMAC signatures, if no + * signature is given. + */ + cryptoProvider?: CryptoProvider; }): string; signature: Signature; @@ -79,7 +90,8 @@ declare module 'stripe' { payload: string, header: string, secret: string, - tolerance?: number + tolerance?: number, + cryptoProvider?: CryptoProvider ): void; parseHeader( header: string, diff --git a/types/crypto/crypto.d.ts b/types/crypto/crypto.d.ts new file mode 100644 index 0000000000..9a988fcbde --- /dev/null +++ b/types/crypto/crypto.d.ts @@ -0,0 +1,19 @@ +declare module 'stripe' { + namespace Stripe { + /** + * Interface encapsulating the various crypto computations used by the library, + * allowing pluggable underlying crypto implementations. + */ + export interface CryptoProvider { + /** + * 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 + */ + computeHMACSignature: (payload: string, secret: string) => string; + } + } +}