Skip to content

Commit

Permalink
Add SubtleCryptoProvider and update Webhooks to allow async crypto.
Browse files Browse the repository at this point in the history
  • Loading branch information
dcr-stripe committed Nov 9, 2021
1 parent 3811c5d commit 3335a69
Show file tree
Hide file tree
Showing 12 changed files with 682 additions and 320 deletions.
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;
67 changes: 67 additions & 0 deletions lib/crypto/SubtleCryptoProvider.js
Original file line number Diff line number Diff line change
@@ -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;
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

0 comments on commit 3335a69

Please sign in to comment.