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

Version 0.2.0 - Add signing functions, end-to-end testing #2

Merged
merged 10 commits into from
Mar 2, 2020
Merged
18 changes: 18 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
sudo: required

language: node_js
node_js: '10'
cache: npm

notifications:
email:
recipients:
- [email protected]
on_success: always
on_failure: always

before_script:
- npm install

script:
- npm test
6 changes: 4 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
}
}
108 changes: 106 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -16,5 +16,8 @@
"dependencies": {
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
"devDependencies": {
"jasmine": "^3.5.0"
}
}
14 changes: 14 additions & 0 deletions resource/webhook-keys.js
Original file line number Diff line number Diff line change
@@ -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==',
}
}
7 changes: 0 additions & 7 deletions resource/webhook-public-keys.js

This file was deleted.

5 changes: 5 additions & 0 deletions spec/init.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
describe('FormSG SDK', () => {
it('should be able to initialise without arguments', () => {
expect(() => require('../index')()).not.toThrow()
})
})
11 changes: 11 additions & 0 deletions spec/support/jasmine.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"spec_dir": "spec",
"spec_files": [
"**/*[sS]pec.js"
],
"helpers": [
"helpers/**/*.js"
],
"stopSpecOnExpectationFailure": false,
"random": true
}
39 changes: 39 additions & 0 deletions spec/webhooks.spec.js
Original file line number Diff line number Diff line change
@@ -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()
})
})
1 change: 1 addition & 0 deletions src/util/stage.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module.exports = {
staging: 'staging',
production: 'production',
test: 'test'
}
57 changes: 49 additions & 8 deletions src/webhooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

/**
Expand Down Expand Up @@ -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,
}
}