diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..4593a58 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +sudo: required + +language: node_js +node_js: '10' +cache: npm + +notifications: + email: + recipients: + - formsg@data.gov.sg + on_success: always + on_failure: always + +before_script: + - npm install + +script: + - npm test diff --git a/index.js b/index.js index ffbf027..407cc98 100644 --- a/index.js +++ b/index.js @@ -5,11 +5,13 @@ const webhooks = require('./src/webhooks.js') * @param {Object} options * @param {string} [options.mode] If set to 'staging' this will initialise * the SDK for the FormSG staging environment + * @param {string} [options.webhookSecretKey] Optional base64 secret key for signing webhooks */ module.exports = function ({ - mode='production' + mode='production', + webhookSecretKey, } = {}) { return { - webhooks: webhooks({ mode }) + webhooks: webhooks({ mode, webhookSecretKey }) } } diff --git a/package-lock.json b/package-lock.json index 87a0339..33414e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,9 +1,107 @@ { - "name": "formsg-sdk", - "version": "0.1.1", + "name": "@opengovsg/formsg-sdk", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "jasmine": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine/-/jasmine-3.5.0.tgz", + "integrity": "sha512-DYypSryORqzsGoMazemIHUfMkXM7I7easFaxAvNM3Mr6Xz3Fy36TupTrAOxZWN8MVKEU5xECv22J4tUQf3uBzQ==", + "dev": true, + "requires": { + "glob": "^7.1.4", + "jasmine-core": "~3.5.0" + } + }, + "jasmine-core": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-3.5.0.tgz", + "integrity": "sha512-nCeAiw37MIMA9w9IXso7bRaLl+c/ef3wnxsoSAlYrzS+Ot0zTG6nU8G/cIfGkqpkjX2wNaIW9RFG0TwIFnG6bA==", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, "tweetnacl": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz", @@ -13,6 +111,12 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/tweetnacl-util/-/tweetnacl-util-0.15.1.tgz", "integrity": "sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==" + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true } } } diff --git a/package.json b/package.json index 49a7193..5a43b2e 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "@opengovsg/formsg-sdk", - "version": "0.1.1", + "version": "0.2.0", "description": "Node.js SDK for integrating with FormSG", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jasmine" }, "keywords": [ "formsg", @@ -16,5 +16,8 @@ "dependencies": { "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" + }, + "devDependencies": { + "jasmine": "^3.5.0" } } diff --git a/resource/webhook-keys.js b/resource/webhook-keys.js new file mode 100644 index 0000000..8cc0c95 --- /dev/null +++ b/resource/webhook-keys.js @@ -0,0 +1,14 @@ +module.exports = { + staging: { + // staging must never contain secret keys + publicKey: 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=', + }, + production: { + // production must never contain secret keys + publicKey: '3Tt8VduXsjjd4IrpdCd7BAkdZl/vUCstu9UvTX84FWw=', + }, + test: { + publicKey: 'KUY1XT30ar+XreVjsS1w/c3EpDs2oASbF6G3evvaUJM=', + secretKey: '/u+LP57Ib9y5Ytpud56FzuitSC9O6lJ4EOLOFHpsHlYpRjVdPfRqv5et5WOxLXD9zcSkOzagBJsXobd6+9pQkw==', + } +} diff --git a/resource/webhook-public-keys.js b/resource/webhook-public-keys.js deleted file mode 100644 index 5d60647..0000000 --- a/resource/webhook-public-keys.js +++ /dev/null @@ -1,7 +0,0 @@ -const production = '3Tt8VduXsjjd4IrpdCd7BAkdZl/vUCstu9UvTX84FWw=' -const staging = 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' - -module.exports = { - staging, - production, -} diff --git a/spec/init.spec.js b/spec/init.spec.js new file mode 100644 index 0000000..2dcee76 --- /dev/null +++ b/spec/init.spec.js @@ -0,0 +1,5 @@ +describe('FormSG SDK', () => { + it('should be able to initialise without arguments', () => { + expect(() => require('../index')()).not.toThrow() + }) +}) \ No newline at end of file diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..370fc44 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,11 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "helpers/**/*.js" + ], + "stopSpecOnExpectationFailure": false, + "random": true +} diff --git a/spec/webhooks.spec.js b/spec/webhooks.spec.js new file mode 100644 index 0000000..97a6822 --- /dev/null +++ b/spec/webhooks.spec.js @@ -0,0 +1,39 @@ +const webhookSecretKey = require('../resource/webhook-keys').test.secretKey +const webhook = require('../src/webhooks')({ + mode: 'test', + webhookSecretKey +}) + +describe('Webhooks', () => { + const uri = 'https://some-endpoint.com/post' + const submissionId = 'someSubmissionId' + const formId = 'someFormId' + + it('should be signing the signature and generating the X-FormSG-Signature header with the correct format', () => { + const epoch = 1583136171649 + const signature = webhook.generateSignature({ uri, submissionId, formId, epoch }) + + expect(signature).toBe('KMirkrGJLPqu+Na+gdZLUxl9ZDgf2PnNGPnSoG1FuTMRUTiQ6o0jB/GTj1XFjn2s9JtsL5GiCmYROpjJhDyxCw==') + + // X-FormSG-Signature + const header = webhook.constructHeader({ epoch, submissionId, formId, signature }) + + expect(header).toBe(`t=1583136171649,s=someSubmissionId,f=someFormId,v1=KMirkrGJLPqu+Na+gdZLUxl9ZDgf2PnNGPnSoG1FuTMRUTiQ6o0jB/GTj1XFjn2s9JtsL5GiCmYROpjJhDyxCw==`) + }) + + it('should authenticate a signature that was recently generated', () => { + const epoch = Date.now() + const signature = webhook.generateSignature({ uri, submissionId, formId, epoch }) + const header = webhook.constructHeader({ epoch, submissionId, formId, signature }) + + webhook.authenticate(header, uri) + }) + + it('should reject signatures generated more than 5 minutes ago', () => { + const epoch = Date.now() - 5 * 60 * 1000 - 1 + const signature = webhook.generateSignature({ uri, submissionId, formId, epoch }) + const header = webhook.constructHeader({ epoch, submissionId, formId, signature }) + + expect(() => webhook.authenticate(header, uri)).toThrow() + }) +}) diff --git a/src/util/stage.js b/src/util/stage.js index 56fd972..b394d09 100644 --- a/src/util/stage.js +++ b/src/util/stage.js @@ -1,4 +1,5 @@ module.exports = { staging: 'staging', production: 'production', + test: 'test' } diff --git a/src/webhooks.js b/src/webhooks.js index 33dc217..0a1df25 100644 --- a/src/webhooks.js +++ b/src/webhooks.js @@ -2,9 +2,9 @@ const url = require('url') -const { verify } = require('./util/signature') +const { sign, verify } = require('./util/signature') const { parseSignatureHeader } = require('../src/util/parser') -const webhookPublicKeys = require('../resource/webhook-public-keys') +const WEBHOOK_KEYS = require('../resource/webhook-keys') const STAGE = require('./util/stage') /** @@ -63,15 +63,56 @@ const authenticate = webhookPublicKey => (header, uri) => { } /** - * Provider function that accepts configuration + * Generates a signature based on the URI, submission ID and epoch timestamp. + * @param {String} webhookSecretKey The base64 secret key + * @param {String} uri Full URL of the request + * @param {Object} submissionId Submission Mongo ObjectId saved to the database + * @param {Number} epoch Number of milliseconds since Jan 1, 1970 + */ +const generateSignature = (webhookSecretKey) => ({ uri, submissionId, formId, epoch }) => { + const baseString = `${url.parse(uri).href}.${submissionId}.${formId}.${epoch}` + return sign(baseString, webhookSecretKey) +} + +/** + * Constructs the `X-FormSG-Signature` header + * @param {string} epoch Epoch timestamp + * @param {string} submissionId Mongo ObjectId + * @param {string} formId Mongo ObjectId + * @param {string} signature A signature generated by the generateSignature() function + */ +function constructHeader ({ epoch, submissionId, formId, signature }) { + return `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` +} + +/** + * Retrieves the appropriate webhook public key. + * Defaults to production. + * @param {string} [mode] One of 'staging' | 'production' + */ +function getWebhookPublicKey (mode) { + switch (mode) { + case STAGE.staging: + return WEBHOOK_KEYS.staging.publicKey + case STAGE.test: + return WEBHOOK_KEYS.test.publicKey + default: + return WEBHOOK_KEYS.production.publicKey + } +} + +/** + * Provider that accepts configuration * before returning the webhooks module */ -module.exports = function ({ mode }) { - const webhookPublicKey = mode === STAGE.staging ? - webhookPublicKeys.staging : - webhookPublicKeys.production +module.exports = function ({ mode, webhookSecretKey }) { + const webhookPublicKey = getWebhookPublicKey(mode) return { - authenticate: authenticate(webhookPublicKey) + /* Verification functions */ + authenticate: authenticate(webhookPublicKey), + /* Signing functions */ + generateSignature: webhookSecretKey ? generateSignature(webhookSecretKey) : undefined, + constructHeader: webhookSecretKey ? constructHeader : undefined, } }