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;
+ }
+ }
+}