Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SubtleCryptoProvider and update Webhooks to allow async crypto. #1288

Merged
merged 1 commit into from
Nov 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 157 additions & 63 deletions lib/Webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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') {
Expand Down
15 changes: 15 additions & 0 deletions lib/crypto/CryptoProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
6 changes: 6 additions & 0 deletions lib/crypto/NodeCryptoProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
69 changes: 69 additions & 0 deletions lib/crypto/SubtleCryptoProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use strict';

const CryptoProvider = require('./CryptoProvider');

/**
* `CryptoProvider which uses the SubtleCrypto interface of the Web Crypto API.
*
* This only supports asynchronous operations.
*/
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;
22 changes: 22 additions & 0 deletions lib/stripe.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading