diff --git a/README.md b/README.md index 8ed89f2..13a8647 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,22 @@ validator.validate(message, function (err, message) { }); ``` +The `validate` method also supports promises, if no callback is passed a promise +is returned: + +```javascript +var MessageValidator = require('sns-validator'), + validator = new MessageValidator(); + +validator.validate(message) + then(function (message) { + // message has been validated and its signature checked. + }) + .catch(function (err) { + // Your message could not be validated. + }); +``` + ## Installation The SNS Message Validator relies on the Node crypto module and is only designed diff --git a/index.js b/index.js index 6f2fcd5..7e235dd 100644 --- a/index.js +++ b/index.js @@ -97,35 +97,37 @@ var validateUrl = function (urlToValidate, hostPattern) { && hostPattern.test(parsed.host); }; -var getCertificate = function (certUrl, cb) { - if (certCache.hasOwnProperty(certUrl)) { - cb(null, certCache[certUrl]); - return; - } +var getCertificate = function (certUrl) { + return new Promise(function (resolve, reject) { + if (certCache.hasOwnProperty(certUrl)) { + resolve(certCache[certUrl]); + return; + } - https.get(certUrl, function (res) { - var chunks = []; + https.get(certUrl, function (res) { + var chunks = []; - if(res.statusCode !== 200){ - return cb(new Error('Certificate could not be retrieved')); - } + if(res.statusCode !== 200){ + reject(new Error('Certificate could not be retrieved')); + return; + } - res - .on('data', function (data) { - chunks.push(data.toString()); - }) - .on('end', function () { - certCache[certUrl] = chunks.join(''); - cb(null, certCache[certUrl]); - }); - }).on('error', cb) + res + .on('data', function (data) { + chunks.push(data.toString()); + }) + .on('end', function () { + certCache[certUrl] = chunks.join(''); + resolve(certCache[certUrl]); + }); + }).on('error', reject); + }); }; -var validateSignature = function (message, cb, encoding) { +var validateSignature = function (message, encoding) { if (message['SignatureVersion'] !== '1') { - cb(new Error('The signature version ' + return Promise.reject(new Error('The signature version ' + message['SignatureVersion'] + ' is not supported.')); - return; } var signableKeys = []; @@ -143,21 +145,18 @@ var validateSignature = function (message, cb, encoding) { } } - getCertificate(message['SigningCertURL'], function (err, certificate) { - if (err) { - cb(err); - return; - } - try { - if (verifier.verify(certificate, message['Signature'], 'base64')) { - cb(null, message); - } else { - cb(new Error('The message signature is invalid.')); + return getCertificate(message['SigningCertURL']) + .then(function (certificate) { + try { + if (verifier.verify(certificate, message['Signature'], 'base64')) { + return Promise.resolve(message); + } else { + return Promise.reject(new Error('The message signature is invalid.')); + } + } catch (e) { + return Promise.reject(new Error('The message signature is invalid.')); } - } catch (e) { - cb(e); - } - }); + }) }; /** @@ -186,31 +185,57 @@ function MessageValidator(hostPattern, encoding) { * Validates a message's signature and passes it to the provided callback. * * @param {Object} hash - * @param {validationCallback} cb + * @param {validationCallback} cb - Optional callback, if not passed a Promise is returned. + * @returns {Promise} - If no callback is passed, a Promise is returned. */ MessageValidator.prototype.validate = function (hash, cb) { if (typeof hash === 'string') { try { hash = JSON.parse(hash); } catch (err) { - cb(err); - return; + if (cb) { + cb(err); + return; + } + return Promise.reject(err); } } hash = convertLambdaMessage(hash); if (!validateMessageStructure(hash)) { - cb(new Error('Message missing required keys.')); - return; + var err = new Error('Message missing required keys.'); + if (cb) { + cb(err); + return; + } + return Promise.reject(err); } if (!validateUrl(hash['SigningCertURL'], this.hostPattern)) { - cb(new Error('The certificate is located on an invalid domain.')); + var err = new Error('The certificate is located on an invalid domain.'); + if (cb) { + cb(err); + return; + } + + return Promise.reject(err); + } + + var result = validateSignature(hash, this.encoding); + + if (cb) { + result + .then(function (message) { + cb(null, message); + }) + .catch(function (err) { + cb(err); + }); return; } - validateSignature(hash, cb, this.encoding); + return result; }; module.exports = MessageValidator; diff --git a/test/validator.js b/test/validator.js index 8c7051f..97734cf 100644 --- a/test/validator.js +++ b/test/validator.js @@ -84,8 +84,8 @@ describe('Message Validator', function () { = signer.sign(certHash.serviceKey, 'base64'); } - MessageValidator.__set__('getCertificate', function (url, cb) { - cb(null, certHash.certificate); + MessageValidator.__set__('getCertificate', function (url) { + return Promise.resolve(certHash.certificate); }); done(); }); @@ -112,6 +112,18 @@ describe('Message Validator', function () { } }); }); + + it('[promise] should return a promise that resolves to the validated message', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(validMessage) + .then(function (message) { + expect(message).to.equal(validMessage); + done(); + }) + .catch(function (err) { + done(err) + }) + }); }); describe('message validation', function () { @@ -132,6 +144,23 @@ describe('Message Validator', function () { }); }); + it('[promise] should reject hashes without all required keys', function (done) { + (new MessageValidator) + .validate(invalidMessage) + .then(function () { + done(new Error('The validator should not have accepted this message.')); + }) + .catch(function (err) { + try { + expect(err.message) + .to.equal('Message missing required keys.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should accept Lambda payloads with improper "Url" casing', function (done) { (new MessageValidator(/^localhost:56789$/)) .validate(validLambdaMessage, function (err, message) { @@ -149,6 +178,23 @@ describe('Message Validator', function () { }); }); + it('[promise] should accept Lambda payloads with improper "Url" casing', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(validLambdaMessage) + .then(function (message) { + try { + expect(message.Message) + .to.equal('A Lambda message for you!'); + done(); + } catch (e) { + done(e); + } + }) + .catch(function (err) { + return done(new Error('The validator should have accepted this message.')); + }); + }); + it('should reject hashes residing on an invalid domain', function (done) { (new MessageValidator) .validate(validMessage, function (err, message) { @@ -166,6 +212,23 @@ describe('Message Validator', function () { }); }); + it('[promise] should reject hashes residing on an invalid domain', function (done) { + (new MessageValidator) + .validate(validMessage) + .then(function () { + done(new Error('The validator should not have accepted this message.')); + }) + .catch(function (err) { + try { + expect(err.message) + .to.equal('The certificate is located on an invalid domain.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should reject hashes with an invalid signature type', function (done) { (new MessageValidator) .validate(_.extend({}, validMessage, { @@ -186,6 +249,26 @@ describe('Message Validator', function () { }); }); + it('[promise] should reject hashes with an invalid signature type', function (done) { + (new MessageValidator) + .validate(_.extend({}, validMessage, { + SignatureVersion: '2', + SigningCertURL: validCertUrl + })) + .then(function () { + done(new Error('The validator should not have accepted this message.')); + }) + .catch(function (err) { + try { + expect(err.message) + .to.equal('The signature version 2 is not supported.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should attempt to verify the signature of well-structured messages', function (done) { (new MessageValidator(/^localhost:56789$/)) .validate(_.extend({}, validMessage, { @@ -206,15 +289,51 @@ describe('Message Validator', function () { }); }); + it('[promise] should attempt to verify the signature of well-structured messages', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(_.extend({}, validMessage, { + Signature: (new Buffer('NOT A VALID SIGNATURE')) + .toString('base64') + })) + .then(function () { + done(new Error('The validator should not have accepted this message.')); + }) + .catch(function (err) { + try { + expect(err.message) + .to.equal('The message signature is invalid.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should accept a valid message', function (done) { (new MessageValidator(/^localhost:56789$/)) .validate(validMessage, done); }); + it('[promise] should accept a valid message', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(validMessage) + .then(function () { + done() + }); + }); + it('should accept valid messages as JSON strings', function (done) { (new MessageValidator(/^localhost:56789$/)) .validate(JSON.stringify(validMessage), done); }); + + it('[promise] should accept valid messages as JSON strings', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(JSON.stringify(validMessage)) + .then(function () { + done() + }); + }); }); describe('subscription control message validation', function () { @@ -237,10 +356,37 @@ describe('Message Validator', function () { }); }); + it('[promise] should reject subscribe hashes without additional keys', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(_.extend({}, validMessage, { + Type: 'SubscriptionConfirmation' + })) + .then(function () { + done(new Error('The validator should not have accepted this message.')); + }) + .catch(function (err) { + try { + expect(err.message) + .to.equal('Message missing required keys.'); + done(); + } catch (e) { + done(e); + } + }); + }); + it('should accept a valid subscription control message', function (done) { (new MessageValidator(/^localhost:56789$/)) .validate(validSubscriptionControlMessage, done); }); + + it('[promise] should accept a valid subscription control message', function (done) { + (new MessageValidator(/^localhost:56789$/)) + .validate(validSubscriptionControlMessage) + .then(function () { + done(); + }); + }); }); describe('UTF8 message validation', function () { @@ -248,6 +394,14 @@ describe('Message Validator', function () { (new MessageValidator(/^localhost:56789$/, 'utf8')) .validate(utf8Message, done); }); + + it('[promise] should accept a valid UTF8 message', function (done) { + (new MessageValidator(/^localhost:56789$/, 'utf8')) + .validate(utf8Message) + .then(function () { + done(); + }); + }); }); describe('invalid signing cert', function () { @@ -265,5 +419,24 @@ describe('Message Validator', function () { done(); }); }); + + it('[promise] should catch any errors thrown during verification', function (done) { + var verifier = { + update: sandbox.spy(), + verify: sandbox.stub().throws() + }; + sandbox.stub(crypto, 'createVerify').returns(verifier); + + (new MessageValidator(/^localhost:56789$/, 'utf8')) + .validate(utf8Message) + .then(function (result) { + expect(result).to.be.undefined; + done(); + }) + .catch(function (err) { + expect(err).not.to.be.undefined; + done(); + }) + }); }); });