├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── spec ├── crypto-v3.spec.ts ├── crypto.spec.ts ├── init.spec.ts ├── keys.spec.ts ├── resources │ ├── crypto-data-20200322.ts │ ├── crypto-data-20200604.ts │ ├── crypto-data-20221114.ts │ ├── crypto-v3-data-20231207.ts │ └── ogp.svg ├── util.spec.ts ├── verification │ ├── utils.spec.ts │ └── verification.spec.ts └── webhooks.spec.ts ├── src ├── crypto-base.ts ├── crypto-v3.ts ├── crypto.ts ├── errors.ts ├── index.ts ├── resource │ ├── signing-keys.ts │ └── verification-keys.ts ├── types.ts ├── util │ ├── crypto.ts │ ├── parser.ts │ ├── publicKey.ts │ ├── signature.ts │ ├── stage.ts │ ├── validate.ts │ └── webhooks.ts ├── verification │ ├── index.ts │ └── utils.ts └── webhooks.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "overrides": [ 12 | { 13 | "files": ["*.ts"], 14 | "parser": "@typescript-eslint/parser", 15 | "parserOptions": { 16 | "sourceType": "module", 17 | "ecmaFeatures": { 18 | "modules": true 19 | }, 20 | "project": "tsconfig.json" 21 | }, 22 | "plugins": [ 23 | "@typescript-eslint", 24 | "import", 25 | "simple-import-sort", 26 | "typesafe" 27 | ], 28 | "extends": ["plugin:@typescript-eslint/recommended"], 29 | "rules": { 30 | // Rules for auto sort of imports 31 | "simple-import-sort/imports": [ 32 | "error", 33 | { 34 | "groups": [ 35 | // Side effect imports. 36 | ["^\\u0000"], 37 | // Packages. 38 | // Things that start with a letter (or digit or underscore), or 39 | // `@` followed by a letter. 40 | ["^@?\\w"], 41 | // Root imports 42 | ["^(src)(/.*|$)"], 43 | ["^(tests)(/.*|$)"], 44 | // Parent imports. Put `..` last. 45 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 46 | // Other relative imports. Put same-folder imports and `.` last. 47 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"] 48 | ] 49 | } 50 | ], 51 | "sort-imports": "off", 52 | "import/order": "off", 53 | "import/first": "error", 54 | "import/newline-after-import": "error", 55 | "import/no-duplicates": "error", 56 | "@typescript-eslint/no-floating-promises": 2, 57 | "@typescript-eslint/no-unused-vars": 2, 58 | // Disabled pending refactoring of two helper functions 59 | // "typesafe/no-throw-sync-func": "error" 60 | } 61 | }, 62 | { "files": ["*.spec.ts"], "extends": ["plugin:jest/recommended"] }, 63 | { 64 | "files": ["*.ts", "*.js"], 65 | "excludedFiles": ["**/*.spec.ts", "**/.spec.js", "**/__tests__/**/*.ts"], 66 | "rules": { 67 | "@typescript-eslint/no-non-null-assertion": "error" 68 | } 69 | } 70 | ], 71 | "rules": { 72 | "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], 73 | "no-console": "warn" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | pull_request: 5 | types: [opened, reopened] 6 | jobs: 7 | build: 8 | name: build 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 18 16 | - name: Cache Node.js modules 17 | uses: actions/cache@v4 18 | with: 19 | # npm cache files are stored in `~/.npm` on Linux/macOS 20 | path: ~/.npm 21 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.OS }}-node- 24 | ${{ runner.OS }}- 25 | - run: npm ci 26 | - run: npx lockfile-lint --type npm --path package-lock.json --validate-https --allowed-hosts npm 27 | - run: npm run build 28 | test: 29 | name: test 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v2 33 | - name: Use Node.js 34 | uses: actions/setup-node@v1 35 | with: 36 | node-version: 18 37 | - name: Cache Node.js modules 38 | uses: actions/cache@v4 39 | with: 40 | # npm cache files are stored in `~/.npm` on Linux/macOS 41 | path: ~/.npm 42 | key: ${{ runner.OS }}-node-${{ hashFiles('**/package-lock.json') }} 43 | restore-keys: | 44 | ${{ runner.OS }}-node- 45 | ${{ runner.OS }}- 46 | - run: npm ci 47 | - run: npm run test-ci 48 | env: 49 | NODE_OPTIONS: '--max-old-space-size=8192' 50 | - name: Submit test coverage to Coveralls 51 | uses: coverallsapp/github-action@v1.1.2 52 | with: 53 | github-token: ${{ secrets.GITHUB_TOKEN }} 54 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | coverage 3 | spec 4 | .prettierrc.js 5 | .travis.yml 6 | jest.config.js 7 | tsconfig.json 8 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | trailingComma: 'es5', 4 | singleQuote: true, 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Changelog 2 | 3 | All notable changes to this project will be documented in this file. Dates are displayed in UTC. 4 | 5 | Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog). 6 | 7 | #### [v0.13.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.12.0...v0.13.0) 8 | 9 | - add address to field type [`#111`](https://github.com/opengovsg/formsg-javascript-sdk/pull/111) 10 | 11 | #### [v0.12.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.12.0-alpha.1...v0.12.0) 12 | 13 | > 9 September 2024 14 | 15 | - build(deps): bump axios from 1.6.4 to 1.7.4 [`#110`](https://github.com/opengovsg/formsg-javascript-sdk/pull/110) 16 | - chore: update readme for paymentContent [`#105`](https://github.com/opengovsg/formsg-javascript-sdk/pull/105) 17 | - fix: package.json & package-lock.json to reduce vulnerabilities [`#103`](https://github.com/opengovsg/formsg-javascript-sdk/pull/103) 18 | 19 | #### [v0.12.0-alpha.1](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.11.0...v0.12.0-alpha.1) 20 | 21 | > 15 December 2023 22 | 23 | - build: bump version to 0.12.0-alpha.1 [`#102`](https://github.com/opengovsg/formsg-javascript-sdk/pull/102) 24 | - feat: mrf crypto [`#101`](https://github.com/opengovsg/formsg-javascript-sdk/pull/101) 25 | 26 | #### [v0.11.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.10.0...v0.11.0) 27 | 28 | > 6 December 2023 29 | 30 | - build: bump version to 0.11.0 [`#100`](https://github.com/opengovsg/formsg-javascript-sdk/pull/100) 31 | - Create LICENSE [`#95`](https://github.com/opengovsg/formsg-javascript-sdk/pull/95) 32 | - chore: upgrade axios to 1.6.2 [`#93`](https://github.com/opengovsg/formsg-javascript-sdk/pull/93) 33 | - build(deps-dev): bump @babel/traverse from 7.16.3 to 7.23.3 [`#99`](https://github.com/opengovsg/formsg-javascript-sdk/pull/99) 34 | - build(deps-dev): bump word-wrap from 1.2.3 to 1.2.5 [`#97`](https://github.com/opengovsg/formsg-javascript-sdk/pull/97) 35 | - Update ci.yml [`#96`](https://github.com/opengovsg/formsg-javascript-sdk/pull/96) 36 | - chore(readme): add wording for period inclusion of unstable fields [`#94`](https://github.com/opengovsg/formsg-javascript-sdk/pull/94) 37 | - Update README.md to include existence of sister SDks [`#90`](https://github.com/opengovsg/formsg-javascript-sdk/pull/90) 38 | - fix: upgrade axios from 0.24.0 to 0.25.0 [`#81`](https://github.com/opengovsg/formsg-javascript-sdk/pull/81) 39 | - build(deps): bump json5 from 1.0.1 to 1.0.2 [`#88`](https://github.com/opengovsg/formsg-javascript-sdk/pull/88) 40 | - build: release v0.10.0 [`#87`](https://github.com/opengovsg/formsg-javascript-sdk/pull/87) 41 | - docs: add changelog and version script [`f207c3e`](https://github.com/opengovsg/formsg-javascript-sdk/commit/f207c3e42cb81347e1fbb9a080f506d6373b54df) 42 | 43 | #### [v0.10.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.9.0...v0.10.0) 44 | 45 | > 16 December 2022 46 | 47 | - fix: allow empty field titles in decrypted responses [`#86`](https://github.com/opengovsg/formsg-javascript-sdk/pull/86) 48 | - chore: run npm audit fix and update relevant deps [`#80`](https://github.com/opengovsg/formsg-javascript-sdk/pull/80) 49 | - chore(deps): Upgrade vulnerable axios version [`#78`](https://github.com/opengovsg/formsg-javascript-sdk/pull/78) 50 | - chore: merge v0.9.0 into develop [`#76`](https://github.com/opengovsg/formsg-javascript-sdk/pull/76) 51 | - fix: run npm audit fix so typescript packages are the correct versions [`2bdbf89`](https://github.com/opengovsg/formsg-javascript-sdk/commit/2bdbf897614833cc50605554ddaa82ea567b8a09) 52 | - chore: bump version to v0.10.0 [`e094041`](https://github.com/opengovsg/formsg-javascript-sdk/commit/e0940414e529012afc03c54165f82e9f5ce16b12) 53 | 54 | #### [v0.9.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.8.2...v0.9.0) 55 | 56 | > 16 December 2022 57 | 58 | - build: add separate keypairs for dev environment [`#73`](https://github.com/opengovsg/formsg-javascript-sdk/pull/73) 59 | - docs: specify version for attachment functionality [`#72`](https://github.com/opengovsg/formsg-javascript-sdk/pull/72) 60 | - build(ci): migrate to GitHub Actions [`#70`](https://github.com/opengovsg/formsg-javascript-sdk/pull/70) 61 | - refactor(crypto): minor refactor of decryptWithAttachments [`#69`](https://github.com/opengovsg/formsg-javascript-sdk/pull/69) 62 | - feat: Adding eslint functionality for the repository [`#66`](https://github.com/opengovsg/formsg-javascript-sdk/pull/66) 63 | - build(deps): bump lodash from 4.17.19 to 4.17.21 [`#64`](https://github.com/opengovsg/formsg-javascript-sdk/pull/64) 64 | - add downloadAndDecryptAttachments for downloading and decrypting attachments [`#62`](https://github.com/opengovsg/formsg-javascript-sdk/pull/62) 65 | - build(deps): bump y18n from 4.0.0 to 4.0.1 [`#59`](https://github.com/opengovsg/formsg-javascript-sdk/pull/59) 66 | - Update README.md [`#61`](https://github.com/opengovsg/formsg-javascript-sdk/pull/61) 67 | - build: merge Release 0.8.4 back to develop branch [`#58`](https://github.com/opengovsg/formsg-javascript-sdk/pull/58) 68 | - build: release v0.8.4-beta.0 [`#56`](https://github.com/opengovsg/formsg-javascript-sdk/pull/56) 69 | - Revert "build(npm): update repo entry (#43)" [`#55`](https://github.com/opengovsg/formsg-javascript-sdk/pull/55) 70 | - fix: update .npmignore to ignore misc unneeded files [`#54`](https://github.com/opengovsg/formsg-javascript-sdk/pull/54) 71 | - ref: use mode init parameter again [`#52`](https://github.com/opengovsg/formsg-javascript-sdk/pull/52) 72 | - chore: Update JSDoc and print warning message if deprecated mode parameter is used [`#51`](https://github.com/opengovsg/formsg-javascript-sdk/pull/51) 73 | - fix(FormField): account for table responses [`#44`](https://github.com/opengovsg/formsg-javascript-sdk/pull/44) 74 | - build(npm): update repo entry [`#43`](https://github.com/opengovsg/formsg-javascript-sdk/pull/43) 75 | - feat: add more webhook tests to check for undefined params [`#41`](https://github.com/opengovsg/formsg-javascript-sdk/pull/41) 76 | - Bump lodash from 4.17.15 to 4.17.19 [`#42`](https://github.com/opengovsg/formsg-javascript-sdk/pull/42) 77 | - fix: JSON body parser required for demo code to work [`#40`](https://github.com/opengovsg/formsg-javascript-sdk/pull/40) 78 | - refactor: update verification HOF to Verification class [`#38`](https://github.com/opengovsg/formsg-javascript-sdk/pull/38) 79 | - refactor: update crypto HOF to Crypto class [`#36`](https://github.com/opengovsg/formsg-javascript-sdk/pull/36) 80 | - refactor: update webhooks HOF to Webhooks class [`#34`](https://github.com/opengovsg/formsg-javascript-sdk/pull/34) 81 | - docs: fix code snippet typos [`#37`](https://github.com/opengovsg/formsg-javascript-sdk/pull/37) 82 | - feat: add publicKey param to package initialization parameters [`#33`](https://github.com/opengovsg/formsg-javascript-sdk/pull/33) 83 | - chore: add `test-ci` script for Travis to run instead of `test` [`#32`](https://github.com/opengovsg/formsg-javascript-sdk/pull/32) 84 | - Release 0.8.3 - Allow tolerance for clock drift when authenticating webhooks [`#47`](https://github.com/opengovsg/formsg-javascript-sdk/pull/47) 85 | - fix(epoch): allow for possible clock drift [`a3ce917`](https://github.com/opengovsg/formsg-javascript-sdk/commit/a3ce917c6c38a387edcfad06ae5176b1f92b0a46) 86 | - fix(epoch): allow for possible clock drift [`5ba253d`](https://github.com/opengovsg/formsg-javascript-sdk/commit/5ba253d61d04d3082f4d752ecfc9efd3639acc0f) 87 | - chore: bump version to 0.9.0 [`32111b1`](https://github.com/opengovsg/formsg-javascript-sdk/commit/32111b1a2c85955a33d478dbc06966dda7261e33) 88 | 89 | #### [v0.8.2](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.4.1...v0.8.2) 90 | 91 | > 4 June 2020 92 | 93 | - Release v0.8.2 - Patch for encrypting non-UTF8 characters [`#35`](https://github.com/opengovsg/formsg-javascript-sdk/pull/35) 94 | - Release v0.8.1 - Export types [`#29`](https://github.com/opengovsg/formsg-javascript-sdk/pull/29) 95 | - Fix: Export declared types in the package [`#28`](https://github.com/opengovsg/formsg-javascript-sdk/pull/28) 96 | - Replace tweetnacl-util with StableLib [`#27`](https://github.com/opengovsg/formsg-javascript-sdk/pull/27) 97 | - v0.8.0 - Update decrypt signature [`#25`](https://github.com/opengovsg/formsg-javascript-sdk/pull/25) 98 | - Test and build coverage badges with TravsiCI and Coveralls.io [`#23`](https://github.com/opengovsg/formsg-javascript-sdk/pull/23) 99 | - chore: update README for increased clarity on decrypt [`#22`](https://github.com/opengovsg/formsg-javascript-sdk/pull/22) 100 | - v0.7.0 - Verification module for SDK [`#19`](https://github.com/opengovsg/formsg-javascript-sdk/pull/19) 101 | - fix: use req.get() in README sample code [`#21`](https://github.com/opengovsg/formsg-javascript-sdk/pull/21) 102 | - Enforce minimum level of test coverage [`#20`](https://github.com/opengovsg/formsg-javascript-sdk/pull/20) 103 | - Update encrypt/decryptFile function signature from blob to UInt8Array [`#18`](https://github.com/opengovsg/formsg-javascript-sdk/pull/18) 104 | - Utility functions for attachments [`#17`](https://github.com/opengovsg/formsg-javascript-sdk/pull/17) 105 | - Replace Joi with custom validation [`#16`](https://github.com/opengovsg/formsg-javascript-sdk/pull/16) 106 | - Link GitHub to package.json [`#15`](https://github.com/opengovsg/formsg-javascript-sdk/pull/15) 107 | - Fix broken links in readme [`#14`](https://github.com/opengovsg/formsg-javascript-sdk/pull/14) 108 | - Extend crypto module for verified fields and migrate to TypeScript [`#12`](https://github.com/opengovsg/formsg-javascript-sdk/pull/12) 109 | - Patch readme sample code [`#11`](https://github.com/opengovsg/formsg-javascript-sdk/pull/11) 110 | 111 | #### [v0.4.1](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.4.0...v0.4.1) 112 | 113 | > 30 March 2020 114 | 115 | - Release v0.4.1 - Remove dangling reference to source in require [`#10`](https://github.com/opengovsg/formsg-javascript-sdk/pull/10) 116 | 117 | #### [v0.4.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.3.2...v0.4.0) 118 | 119 | > 30 March 2020 120 | 121 | - Pre-publish transpile pipeline with Babel [`#9`](https://github.com/opengovsg/formsg-javascript-sdk/pull/9) 122 | 123 | #### [v0.3.2](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.3.1...v0.3.2) 124 | 125 | > 30 March 2020 126 | 127 | - Avoid default arguments in destructuring [`#8`](https://github.com/opengovsg/formsg-javascript-sdk/pull/8) 128 | 129 | #### [v0.3.1](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.3.0...v0.3.1) 130 | 131 | > 30 March 2020 132 | 133 | - Destructure options in the function body [`#7`](https://github.com/opengovsg/formsg-javascript-sdk/pull/7) 134 | 135 | #### [v0.3.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.2.0...v0.3.0) 136 | 137 | > 24 March 2020 138 | 139 | - Release 0.3.0 [`#5`](https://github.com/opengovsg/formsg-javascript-sdk/pull/5) 140 | - Add cryptographic functions to SDK [`#4`](https://github.com/opengovsg/formsg-javascript-sdk/pull/4) 141 | - Update readme to clarify that this repository is the SDK and not the FormSG system. [`#3`](https://github.com/opengovsg/formsg-javascript-sdk/pull/3) 142 | 143 | #### [v0.2.0](https://github.com/opengovsg/formsg-javascript-sdk/compare/v0.1.1...v0.2.0) 144 | 145 | > 2 March 2020 146 | 147 | - Version 0.2.0 - Add signing functions, end-to-end testing [`#2`](https://github.com/opengovsg/formsg-javascript-sdk/pull/2) 148 | 149 | #### v0.1.1 150 | 151 | > 2 March 2020 152 | 153 | - Version 0.1.1 - Readme, configuration bugfix [`#1`](https://github.com/opengovsg/formsg-javascript-sdk/pull/1) 154 | - Add signature authentication [`a23a6ff`](https://github.com/opengovsg/formsg-javascript-sdk/commit/a23a6ffee1aaa65f805207b3f7ed17ed05590010) 155 | - gitignore [`1b288fa`](https://github.com/opengovsg/formsg-javascript-sdk/commit/1b288fa9cff6df70efb09bf477570159516605e1) 156 | - install dependencies [`d8fc078`](https://github.com/opengovsg/formsg-javascript-sdk/commit/d8fc078b29ffb0043adbb105fe4a39abed5a8cf9) 157 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Open Government Products 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | _Please note that this is an SDK for webhooks integration, and_ **_not_** _the FormSG system._ 2 | 3 | [![Build Status](https://travis-ci.com/opengovsg/formsg-javascript-sdk.svg?branch=master)](https://travis-ci.com/opengovsg/formsg-javascript-sdk) 4 | [![Coverage Status](https://coveralls.io/repos/github/opengovsg/formsg-javascript-sdk/badge.svg?branch=master)](https://coveralls.io/github/opengovsg/formsg-javascript-sdk?branch=master) 5 | 6 | # FormSG Javascript SDK 7 | 8 | This SDK provides convenient utilities for verifying FormSG webhooks and decrypting submissions in JavaScript and Node.js. 9 | 10 | Not using Javascript? Check out our sister SDKs: 11 | - [formsg-python-sdk](https://github.com/opengovsg/formsg-python-sdk) 12 | - [formsg-ruby-sdk](https://github.com/opengovsg/formsg-ruby-sdk) 13 | 14 | ## Installation 15 | 16 | Install the package with 17 | 18 | ```bash 19 | npm install @opengovsg/formsg-sdk --save 20 | ``` 21 | 22 | ## Configuration 23 | 24 | ```javascript 25 | const formsg = require('@opengovsg/formsg-sdk')({ 26 | mode: 'production', 27 | }) 28 | ``` 29 | 30 | | Option | Default | Description | 31 | | ------ | ------------ | --------------------------------------------------------------- | 32 | | mode | 'production' | Set to 'staging' if integrating against FormSG staging servers. | 33 | 34 | ## Usage 35 | 36 | ### Webhook Authentication and Decrypting Submissions 37 | 38 | ```javascript 39 | // This example uses Express to receive webhooks 40 | const express = require('express') 41 | const app = express() 42 | 43 | // Instantiating formsg-sdk without parameters default to using the package's 44 | // production public signing key. 45 | const formsg = require('@opengovsg/formsg-sdk')() 46 | 47 | // This is where your domain is hosted, and should match 48 | // the URI supplied to FormSG in the form dashboard 49 | const POST_URI = 'https://my-domain.com/submissions' 50 | 51 | // Your form's secret key downloaded from FormSG upon form creation 52 | const formSecretKey = process.env.FORM_SECRET_KEY 53 | 54 | // Set to true if you need to download and decrypt attachments from submissions 55 | const HAS_ATTACHMENTS = false 56 | 57 | app.post( 58 | '/submissions', 59 | // Endpoint authentication by verifying signatures 60 | function (req, res, next) { 61 | try { 62 | formsg.webhooks.authenticate(req.get('X-FormSG-Signature'), POST_URI) 63 | // Continue processing the POST body 64 | return next() 65 | } catch (e) { 66 | return res.status(401).send({ message: 'Unauthorized' }) 67 | } 68 | }, 69 | // Parse JSON from raw request body 70 | express.json(), 71 | // Decrypt the submission 72 | async function (req, res, next) { 73 | // If `verifiedContent` is provided in `req.body.data`, the return object 74 | // will include a verified key. 75 | const submission = HAS_ATTACHMENTS 76 | ? await formsg.crypto.decryptWithAttachments(formSecretKey, req.body.data) 77 | : formsg.crypto.decrypt(formSecretKey, req.body.data) 78 | 79 | // If the decryption failed, submission will be `null`. 80 | if (submission) { 81 | // Continue processing the submission 82 | } else { 83 | // Could not decrypt the submission 84 | } 85 | } 86 | ) 87 | 88 | app.listen(8080, () => console.log('Running on port 8080')) 89 | ``` 90 | 91 | ## End-to-end Encryption 92 | 93 | FormSG uses _end-to-end encryption_ with _elliptic curve cryptography_ to protect submission data and ensure only intended recipients are able to view form submissions. As such, FormSG servers are unable to access the data. 94 | 95 | The underlying cryptosystem is `x25519-xsalsa20-poly1305` which is implemented by the [tweetnacl-js](https://github.com/dchest/tweetnacl-js) library. Its source code has been [audited](https://cure53.de/tweetnacl.pdf)) by [Cure53](https://cure53.de/). 96 | 97 | ### Format of Submission Response 98 | 99 | | Key | Type | Description | 100 | | ---------------------- | ---------------------- | -------------------------------------------------------------------------------------------------------- | 101 | | formId | string | Unique form identifier. | 102 | | submissionId | string | Unique response identifier, displayed as 'Response ID' to form respondents | 103 | | encryptedContent | string | The encrypted submission in base64. | 104 | | created | string | Creation timestamp. | 105 | | attachmentDownloadUrls | Record | (Optional) Records containing field IDs and URLs where encrypted uploaded attachments can be downloaded. | 106 | | paymentContent | Record | (Optional) Records containing payment details for forms with payments[1] | 107 | 108 | [1] Forms with the deprecated Fixed Payment Type is not supported 109 | 110 | ### Format of Decrypted Submissions 111 | 112 | `formsg.crypto.decrypt(formSecretKey: string, decryptParams: DecryptParams)` 113 | takes in `decryptParams` as the second argument, and returns an an object with 114 | the shape 115 | 116 |
117 | {
118 |   responses: FormField[]
119 |   verified?: Record<string, any>
120 | }
121 | 
122 | 123 | encryptedContent: EncryptedContent 124 | version: number 125 | verifiedContent?: EncryptedContent 126 | 127 | The `decryptParams.encryptedContent` field decrypts into an array of `FormField` objects, which will be assigned to the `responses` key of the returned object. 128 | 129 | Furthermore, if `decryptParams.verifiedContent` exists, the function will 130 | decrypt and open the signed decrypted content with the package's own 131 | `signingPublicKey` in 132 | [`signing-keys.ts`](https://github.com/opengovsg/formsg-javascript-sdk/tree/master/src/resource/signing-keys.ts). 133 | The resulting decrypted verifiedContent will be assigned to the `verified` key 134 | of the returned object. 135 | 136 | > **Note:**
137 | > If any errors occur, either from the failure to decrypt either `encryptedContent` or `verifiedContent`, or the failure to authenticate the decrypted signed message in `verifiedContent`, `null` will be returned. 138 | 139 | Note that due to end-to-end encryption, FormSG servers are unable to verify the data format. 140 | 141 | However, the `decrypt` function exposed by this library [validates](https://github.com/opengovsg/formsg-javascript-sdk/blob/master/src/util/validate.ts) the decrypted content and will **return `null` if the 142 | decrypted content does not contain all of the fields displayed in the schema below.** 143 | 144 | | Key | Type | Description | 145 | | ----------- | -------- | -------------------------------------------------------------------------------------------------------- | 146 | | question | string | The question listed on the form | 147 | | answer | string | The submitter's answer to the question on form. Either this key or `answerArray` must exist. | 148 | | answerArray | string[] | The submitter's answer to the question on form. Either this key or `answer` must exist. | 149 | | fieldType | string | The type of field for the question. | 150 | | \_id | string | A unique identifier of the form field. WARNING: Changes when new fields are created/removed in the form. | 151 | 152 | > **Note:**
153 | > Additional internal fields may be included in webhooks from time to time, which will then be published as part of our official schema once it is stable for public consumption. If you are applying your own validation, you should account for this e.g. by not rejecting the webhook if there are additional fields included. 154 | 155 | The full schema can be viewed in 156 | [`validate.ts`](https://github.com/opengovsg/formsg-javascript-sdk/tree/master/src/util/validate.ts). 157 | 158 | If the decrypted content is the correct shape, then: 159 | 160 | 1. the decrypted content (from `decryptParams.encryptedContent`) will be set as the value of the `responses` key. 161 | 2. if `decryptParams.verifiedContent` exists, then an attempt to 162 | decrypted the verified content will be called, and the result set as the 163 | value of `verified` key. There is no shape validation for the decrypted 164 | verified content. **If the verification fails, `null` is returned, even if 165 | `decryptParams.encryptedContent` was successfully decrypted.** 166 | 167 | ### Processing Attachments 168 | 169 | `formsg.crypto.decryptWithAttachments(formSecretKey: string, decryptParams: DecryptParams)` (available from version 0.9.0 onwards) behaves similarly except it will return a `Promise`. 170 | 171 | `DecryptedContentAndAttachments` is an object containing two fields: 172 | 173 | - `content`: the standard form decrypted responses (same as the return type of `formsg.crypto.decrypt`) 174 | - `attachments`: A `Record` containing a map of field ids of the attachment fields to a object containing the original user supplied filename and a `Uint8Array` containing the contents of the uploaded file. 175 | 176 | If the contents of any file fails to decrypt or there is a mismatch between the attachments and submission (e.g. the submission doesn't contain the original file name), then `null` will be returned. 177 | 178 | Attachments are downloaded using S3 pre-signed URLs, with a expiry time of _one hour_. You must call `decryptWithAttachments` within this time window, or else the URL to the encrypted files will become invalid. 179 | 180 | Attachments are end-to-end encrypted in the same way as normal form submissions, so any eavesdropper will not be able to view form attachments without your secret key. 181 | 182 | _Warning:_ We do not have the ability to scan any attachments for malicious content (e.g. spyware or viruses), so careful handling is needed. 183 | 184 | ### Processing Local address fields 185 | 186 | Address Field is a compound field with 6 inputs in the answerArray. It will always follow the ordinance of [`blockNumber`, `streetName`, `buildingName`, `levelNumber`, `unitNumber`, `postalCode`] 187 | 188 | ### Format of Payment Content 189 | 190 | These fields will be available if the submission is a payment submission, otherwise, the value will be an empty `{}`. 191 | 192 | | Key | Type | Description | 193 | | -------------- | ---------------- | ------------------------------------------------ | 194 | | type | 'payment_charge' | Payment event associated with this webhook | 195 | | status | string | Status of the payment intent | 196 | | payer | string | The email associated with this email | 197 | | url | string | The url of the proof of payment for this payment | 198 | | paymentIntent | string | The payment intent associated with this payment | 199 | | amount | string | The amount charged to the user | 200 | | productService | string | The product or service name of the payment | 201 | | dateTime | string | The time of which this payment was transacted | 202 | | transactionFee | string | The fees charged for this transaction | 203 | 204 | 205 | 206 | ## Verifying Signatures Manually 207 | 208 | You can use the following information to create a custom solution, although we recommend using this SDK. 209 | 210 | The `X-FormSG-Signature` header contains the following information: 211 | 212 | - Epoch timestamp prefixed by `t=` 213 | - The FormSG submission ID prefixed by `s=` 214 | - The FormSG form ID, prefixed by `f=` 215 | - The signature scheme, prefixed by `v1=`. Currently this is the only signature scheme. 216 | 217 | ```text 218 | X-FormSG-Signature: t=1582558358788, 219 | s=5e53ec96b10ee1010e00380b, 220 | f=5e4b8e3d1f61f00036c9937d, 221 | v1=rUAgQ9krNZspCrQtfSvRfjME6Nq4+I80apGXnCsNrwPbcq44SBNglWtA1MkpC/VhWtDeJfuV89uV2Aqi42UQBA== 222 | ``` 223 | 224 | Note that newlines have been added for clarity, but a real signature will be all in one line. 225 | 226 | ### Steps 227 | 228 | #### Step 1 - Extract the key-value pairs from the header 229 | 230 | Extract the the timestamp, signature, submission ID and form ID from the header, by using the `,` character as 231 | a separator to get a list of elements, before splitting each element to get a key-value pair. 232 | 233 | #### Step 2 - Prepare the basestring 234 | 235 | This is achieved by concatenating the following strings with the `.` fullstop as the delimiter. 236 | 237 | - The [href](https://nodejs.org/api/url.html#url_url_href) of the URI 238 | - The submission ID 239 | - The form ID 240 | - The epoch timestamp 241 | 242 | ```text 243 | https://my-domain.com/submissions.5e53ec96b10ee1010e00380b.5e4b8e3d1f61f00036c9937d.1582558358788 244 | ``` 245 | 246 | #### Step 3 - Verify the signature 247 | 248 | The signature is signed with [ed25519](http://ed25519.cr.yp.to/). 249 | 250 | Verify that the `v1` signature is valid using a library of your choice (we use [tweetnacl-js](https://github.com/dchest/tweetnacl-js)). 251 | 252 | | FormSG environment | Public Key in base64 | 253 | | ------------------ | ---------------------------------------------- | 254 | | production | '3Tt8VduXsjjd4IrpdCd7BAkdZl/vUCstu9UvTX84FWw=' | 255 | | staging | 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' | 256 | 257 | #### Step 4 - Protect against replay attacks 258 | 259 | If the signature is valid, compute the difference between the current timestamp and the received epoch, 260 | and decide if the difference is within your tolerance. We use a tolerance of 5 minutes. 261 | 262 | #### Additional Checks 263 | 264 | - Check that request is for an expected form by verifying the form ID 265 | - Check that the submission ID is new, and that your system has not received it before 266 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { '^.+\\.ts?$': 'ts-jest' }, 3 | testEnvironment: 'node', 4 | testRegex: '/spec/.*\\.(test|spec)?\\.(ts|tsx)$', 5 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 6 | collectCoverage: true, 7 | coverageThreshold: { 8 | global: { 9 | statements: 85, 10 | functions: 80, 11 | }, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@opengovsg/formsg-sdk", 3 | "version": "0.13.0", 4 | "repository": { 5 | "type": "git", 6 | "url": "https://github.com/opengovsg/formsg-javascript-sdk.git" 7 | }, 8 | "description": "Node.js SDK for integrating with FormSG", 9 | "main": "./dist/index.js", 10 | "types": "./dist/index.d.ts", 11 | "scripts": { 12 | "test": "NODE_OPTIONS=\"--max-old-space-size=8192\" jest", 13 | "test-ci": "jest --coverage", 14 | "test-watch": "jest --watch", 15 | "build": "tsc", 16 | "prepare": "npm run build", 17 | "version": "auto-changelog -p && git add CHANGELOG.md" 18 | }, 19 | "keywords": [ 20 | "formsg", 21 | "webhook", 22 | "decrypt" 23 | ], 24 | "author": "Open Government Products (FormSG)", 25 | "license": "MIT", 26 | "dependencies": { 27 | "axios": "^1.6.4", 28 | "tweetnacl": "^1.0.3", 29 | "tweetnacl-util": "^0.15.1" 30 | }, 31 | "devDependencies": { 32 | "@babel/cli": "^7.8.4", 33 | "@babel/core": "^7.9.0", 34 | "@babel/preset-env": "^7.9.0", 35 | "@types/jest": "^29.5.8", 36 | "@types/node": "^18.18.9", 37 | "@typescript-eslint/eslint-plugin": "^4.25.0", 38 | "auto-changelog": "^2.4.0", 39 | "coveralls": "^3.1.1", 40 | "eslint-config-prettier": "^8.3.0", 41 | "eslint-plugin-import": "^2.23.3", 42 | "eslint-plugin-jest": "^24.3.6", 43 | "eslint-plugin-prettier": "^3.4.0", 44 | "eslint-plugin-simple-import-sort": "^7.0.0", 45 | "eslint-plugin-typesafe": "^0.5.2", 46 | "jest": "^29.7.0", 47 | "jest-mock-axios": "^4.7.3", 48 | "ts-jest": "^29.1.1", 49 | "typescript": "^4.9.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /spec/crypto-v3.spec.ts: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios' 2 | import { 3 | plaintext, 4 | ciphertext, 5 | formPublicKey, 6 | formSecretKey, 7 | submissionSecretKey 8 | } from './resources/crypto-v3-data-20231207' 9 | import CryptoV3 from '../src/crypto-v3' 10 | 11 | const INTERNAL_TEST_VERSION = 3 12 | 13 | const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg')) 14 | 15 | jest.mock('axios', () => mockAxios) 16 | 17 | describe('CryptoV3', function () { 18 | afterEach(() => mockAxios.reset()) 19 | 20 | const crypto = new CryptoV3() 21 | 22 | it('should generate a keypair', () => { 23 | const keypair = crypto.generate() 24 | expect(keypair).toHaveProperty('secretKey') 25 | expect(keypair).toHaveProperty('publicKey') 26 | }) 27 | 28 | it('should generate a keypair that is valid', () => { 29 | const { publicKey, secretKey } = crypto.generate() 30 | expect(crypto.valid(publicKey, secretKey)).toBe(true) 31 | }) 32 | 33 | it('should validate an existing keypair', () => { 34 | expect(crypto.valid(formPublicKey, formSecretKey)).toBe(true) 35 | }) 36 | 37 | it('should invalidate unassociated keypairs', () => { 38 | // Act 39 | const { secretKey } = crypto.generate() 40 | const { publicKey } = crypto.generate() 41 | 42 | // Assert 43 | expect(crypto.valid(publicKey, secretKey)).toBe(false) 44 | }) 45 | 46 | it('should return null on unsuccessful decryption from form secret key', () => { 47 | expect( 48 | crypto.decrypt('random', { 49 | ...ciphertext, 50 | version: INTERNAL_TEST_VERSION, 51 | }) 52 | ).toBe(null) 53 | }) 54 | 55 | it('should return null when successfully decrypted content from form secret key does not fit FormFieldV3 type shape', () => { 56 | // Arrange 57 | const { publicKey, secretKey } = crypto.generate() 58 | const malformedContent = 'just a string, not an object with FormField shape' 59 | const malformedEncrypt = crypto.encrypt(malformedContent, publicKey) 60 | 61 | // Assert 62 | // Using correct secret key, but the decrypted object should not fit the 63 | // expected shape and thus return null. 64 | expect( 65 | crypto.decrypt(secretKey, { 66 | ...malformedEncrypt, 67 | version: INTERNAL_TEST_VERSION, 68 | }) 69 | ).toBe(null) 70 | }) 71 | 72 | it('should be able to encrypt and decrypt submissions from 2023-12-07 end-to-end successfully from the form private key', () => { 73 | // Arrange 74 | const { publicKey, secretKey } = crypto.generate() 75 | 76 | // Act 77 | const ciphertext = crypto.encrypt(plaintext, publicKey) 78 | const decrypted = crypto.decrypt(secretKey, { 79 | ...ciphertext, 80 | version: INTERNAL_TEST_VERSION, 81 | }) 82 | // Assert 83 | expect(decrypted).toHaveProperty('responses', plaintext) 84 | }) 85 | 86 | it('should be able to decrypt submissions from 2023-12-07 from the submission private key', () => { 87 | // Act 88 | const decrypted = crypto.decryptFromSubmissionKey(submissionSecretKey, { 89 | encryptedContent: ciphertext.encryptedContent, 90 | version: INTERNAL_TEST_VERSION, 91 | }) 92 | // Assert 93 | expect(decrypted).toHaveProperty('responses', plaintext) 94 | }) 95 | 96 | it('should be able to encrypt and decrypt files end-to-end', async () => { 97 | // Arrange 98 | const { publicKey, secretKey } = crypto.generate() 99 | 100 | // Act 101 | // Encrypt 102 | const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) 103 | expect(encrypted).toHaveProperty('submissionPublicKey') 104 | expect(encrypted).toHaveProperty('nonce') 105 | expect(encrypted).toHaveProperty('binary') 106 | 107 | // Decrypt 108 | const decrypted = await crypto.decryptFile(secretKey, encrypted) 109 | 110 | if (!decrypted) { 111 | throw new Error('File should be able to decrypt successfully.') 112 | } 113 | 114 | // Compare 115 | expect(testFileBuffer).toEqual(decrypted) 116 | }) 117 | 118 | it('should return null if file could not be decrypted', async () => { 119 | const { publicKey, secretKey } = crypto.generate() 120 | 121 | const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) 122 | // Rewrite binary with invalid Uint8Array. 123 | encrypted.binary = new Uint8Array([1, 2]) 124 | 125 | const decrypted = await crypto.decryptFile(secretKey, encrypted) 126 | 127 | expect(decrypted).toBeNull() 128 | }) 129 | }) 130 | -------------------------------------------------------------------------------- /spec/crypto.spec.ts: -------------------------------------------------------------------------------- 1 | import mockAxios from 'jest-mock-axios' 2 | import Crypto from '../src/crypto' 3 | import { SIGNING_KEYS } from '../src/resource/signing-keys' 4 | 5 | import { 6 | encodeBase64, 7 | } from 'tweetnacl-util' 8 | 9 | import { 10 | plaintext, 11 | ciphertext, 12 | formSecretKey, 13 | formPublicKey, 14 | } from './resources/crypto-data-20200322' 15 | import { plaintextMultiLang } from './resources/crypto-data-20200604' 16 | import { MissingPublicKeyError } from '../src/errors' 17 | import { plaintextEmptyTitles } from './resources/crypto-data-20221114' 18 | 19 | const INTERNAL_TEST_VERSION = 1 20 | 21 | const encryptionPublicKey = SIGNING_KEYS.test.publicKey 22 | const signingSecretKey = SIGNING_KEYS.test.secretKey 23 | const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg')) 24 | 25 | jest.mock('axios', () => mockAxios) 26 | 27 | describe('Crypto', function () { 28 | afterEach(() => mockAxios.reset()) 29 | 30 | const crypto = new Crypto({ signingPublicKey: encryptionPublicKey }) 31 | 32 | const mockVerifiedContent = { 33 | uinFin: 'S12345679Z', 34 | somethingElse: 99, 35 | } 36 | 37 | it('should generate a keypair', () => { 38 | const keypair = crypto.generate() 39 | expect(keypair).toHaveProperty('secretKey') 40 | expect(keypair).toHaveProperty('publicKey') 41 | }) 42 | 43 | it('should generate a keypair that is valid', () => { 44 | const { publicKey, secretKey } = crypto.generate() 45 | expect(crypto.valid(publicKey, secretKey)).toBe(true) 46 | }) 47 | 48 | it('should validate an existing keypair', () => { 49 | expect(crypto.valid(formPublicKey, formSecretKey)).toBe(true) 50 | }) 51 | 52 | it('should invalidate unassociated keypairs', () => { 53 | // Act 54 | const { secretKey } = crypto.generate() 55 | const { publicKey } = crypto.generate() 56 | 57 | // Assert 58 | expect(crypto.valid(publicKey, secretKey)).toBe(false) 59 | }) 60 | 61 | it('should decrypt the submission ciphertext from 2020-03-22 successfully', () => { 62 | // Act 63 | const decrypted = crypto.decrypt(formSecretKey, { 64 | encryptedContent: ciphertext, 65 | version: INTERNAL_TEST_VERSION, 66 | }) 67 | 68 | // Assert 69 | expect(decrypted).toHaveProperty('responses', plaintext) 70 | }) 71 | 72 | it('should return null on unsuccessful decryption', () => { 73 | expect( 74 | crypto.decrypt('random', { 75 | encryptedContent: ciphertext, 76 | version: INTERNAL_TEST_VERSION, 77 | }) 78 | ).toBe(null) 79 | }) 80 | 81 | it('should return null when successfully decrypted content does not fit FormField type shape', () => { 82 | // Arrange 83 | const { publicKey, secretKey } = crypto.generate() 84 | const malformedContent = 'just a string, not an object with FormField shape' 85 | const malformedEncrypt = crypto.encrypt(malformedContent, publicKey) 86 | 87 | // Assert 88 | // Using correct secret key, but the decrypted object should not fit the 89 | // expected shape and thus return null. 90 | expect( 91 | crypto.decrypt(secretKey, { 92 | encryptedContent: malformedEncrypt, 93 | version: INTERNAL_TEST_VERSION, 94 | }) 95 | ).toBe(null) 96 | }) 97 | 98 | it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', () => { 99 | // Arrange 100 | const { publicKey, secretKey } = crypto.generate() 101 | 102 | // Act 103 | const ciphertext = crypto.encrypt(plaintext, publicKey) 104 | const decrypted = crypto.decrypt(secretKey, { 105 | encryptedContent: ciphertext, 106 | version: INTERNAL_TEST_VERSION, 107 | }) 108 | // Assert 109 | expect(decrypted).toHaveProperty('responses', plaintext) 110 | }) 111 | 112 | it('should be able to encrypt and decrypt multi-language submission from 2020-06-04 end-to-end successfully', () => { 113 | // Arrange 114 | const { publicKey, secretKey } = crypto.generate() 115 | 116 | // Act 117 | const ciphertext = crypto.encrypt(plaintextMultiLang, publicKey) 118 | const decrypted = crypto.decrypt(secretKey, { 119 | encryptedContent: ciphertext, 120 | version: INTERNAL_TEST_VERSION, 121 | }) 122 | // Assert 123 | expect(decrypted).toHaveProperty('responses', plaintextMultiLang) 124 | }) 125 | 126 | it('should be able to encrypt and decrypt submissions with empty field titles from 2022-11-14 end-to-end successfully', () => { 127 | // Arrange 128 | const { publicKey, secretKey } = crypto.generate() 129 | 130 | // Act 131 | const ciphertext = crypto.encrypt(plaintextEmptyTitles, publicKey) 132 | const decrypted = crypto.decrypt(secretKey, { 133 | encryptedContent: ciphertext, 134 | version: INTERNAL_TEST_VERSION, 135 | }) 136 | 137 | // Assert 138 | expect(decrypted).toHaveProperty('responses', plaintextEmptyTitles) 139 | }) 140 | 141 | it('should be able to encrypt submissions without signing if signingPrivateKey is missing', () => { 142 | // Arrange 143 | const { publicKey, secretKey } = crypto.generate() 144 | 145 | // Act 146 | // Signing key (last parameter) is omitted. 147 | const ciphertext = crypto.encrypt(plaintext, publicKey) 148 | const decrypted = crypto.decrypt(secretKey, { 149 | encryptedContent: ciphertext, 150 | version: INTERNAL_TEST_VERSION, 151 | }) 152 | 153 | // Assert 154 | expect(decrypted).toHaveProperty('responses', plaintext) 155 | }) 156 | 157 | it('should be able to encrypt and sign submissions if signingPrivateKey is given', () => { 158 | // Arrange 159 | const { publicKey, secretKey } = crypto.generate() 160 | 161 | // Act 162 | // Encrypt content that is not signed. 163 | const ciphertext = crypto.encrypt(plaintext, publicKey) 164 | // Sign and encrypt the desired content. 165 | const signedAndEncryptedText = crypto.encrypt( 166 | mockVerifiedContent, 167 | publicKey, 168 | signingSecretKey 169 | ) 170 | // Decrypt encrypted content along with our signed+encrypted content. 171 | const decrypted = crypto.decrypt(secretKey, { 172 | encryptedContent: ciphertext, 173 | verifiedContent: signedAndEncryptedText, 174 | version: INTERNAL_TEST_VERSION, 175 | }) 176 | 177 | // Assert 178 | expect(decrypted).toHaveProperty('verified', mockVerifiedContent) 179 | expect(decrypted).toHaveProperty('responses', plaintext) 180 | }) 181 | 182 | it('should be able to encrypt and decrypt files end-to-end', async () => { 183 | // Arrange 184 | const { publicKey, secretKey } = crypto.generate() 185 | 186 | // Act 187 | // Encrypt 188 | const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) 189 | expect(encrypted).toHaveProperty('submissionPublicKey') 190 | expect(encrypted).toHaveProperty('nonce') 191 | expect(encrypted).toHaveProperty('binary') 192 | 193 | // Decrypt 194 | const decrypted = await crypto.decryptFile(secretKey, encrypted) 195 | 196 | if (!decrypted) { 197 | throw new Error('File should be able to decrypt successfully.') 198 | } 199 | 200 | // Compare 201 | expect(testFileBuffer).toEqual(decrypted) 202 | }) 203 | 204 | it('should return null if file could not be decrypted', async () => { 205 | const { publicKey, secretKey } = crypto.generate() 206 | 207 | const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) 208 | // Rewrite binary with invalid Uint8Array. 209 | encrypted.binary = new Uint8Array([1, 2]) 210 | 211 | const decrypted = await crypto.decryptFile(secretKey, encrypted) 212 | 213 | expect(decrypted).toBeNull() 214 | }) 215 | 216 | it('should throw error if class was not instantiated with a public signing key while verifying decrypted content ', () => { 217 | // Arrange 218 | const cryptoNoKey = new Crypto() 219 | const { publicKey, secretKey } = cryptoNoKey.generate() 220 | 221 | // Act 222 | // Encrypt content that is not signed. 223 | const ciphertext = cryptoNoKey.encrypt(plaintext, publicKey) 224 | // Sign and encrypt the desired content. 225 | const signedAndEncryptedText = cryptoNoKey.encrypt( 226 | mockVerifiedContent, 227 | publicKey, 228 | signingSecretKey 229 | ) 230 | 231 | // Assert 232 | // Attempt to decrypt encrypted content along with our signed+encrypted 233 | // content should throw an error 234 | expect(() => 235 | cryptoNoKey.decrypt(secretKey, { 236 | encryptedContent: ciphertext, 237 | verifiedContent: signedAndEncryptedText, 238 | version: INTERNAL_TEST_VERSION, 239 | }) 240 | ).toThrow(MissingPublicKeyError) 241 | }) 242 | 243 | it('should return null if decrypting encrypted verified content failed', () => { 244 | // Arrange 245 | const { publicKey, secretKey } = crypto.generate() 246 | // Encrypt content that is not signed. 247 | const ciphertext = crypto.encrypt(plaintext, publicKey) 248 | // Create rubbish verified content 249 | const rubbishVerifiedContent = 'abcdefg' 250 | 251 | // Act + Assert 252 | const decryptResult = crypto.decrypt(secretKey, { 253 | encryptedContent: ciphertext, 254 | verifiedContent: rubbishVerifiedContent, 255 | version: INTERNAL_TEST_VERSION, 256 | }) 257 | expect(decryptResult).toBeNull() 258 | }) 259 | 260 | it('should be able to download and decrypt an attachment successfully', async () => { 261 | // Arrange 262 | const { publicKey, secretKey } = crypto.generate() 263 | 264 | let attachmentPlaintext = plaintext.slice(0) 265 | attachmentPlaintext.push({ 266 | _id: '6e771c946b3c5100240368e5', 267 | question: 'Random file', 268 | fieldType: 'attachment', 269 | answer: 'my-random-file.txt', 270 | }) 271 | 272 | // Encrypt content that is not signed 273 | const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey) 274 | 275 | // Encrypt file 276 | const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey) 277 | const uploadedFile = { 278 | submissionPublicKey: encryptedFile.submissionPublicKey, 279 | nonce: encryptedFile.nonce, 280 | binary: encodeBase64(encryptedFile.binary) 281 | } 282 | 283 | // Act 284 | const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { 285 | encryptedContent: ciphertext, 286 | attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, 287 | version: INTERNAL_TEST_VERSION, 288 | }) 289 | mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) 290 | const decryptedContentWithAttachments = await decryptedFilesPromise 291 | const decryptedFiles = decryptedContentWithAttachments!.attachments 292 | 293 | // Assert 294 | expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) 295 | expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { filename: 'my-random-file.txt', content: testFileBuffer }) 296 | }) 297 | 298 | it('should be able to handle fields without attachmentDownloadUrls', async () => { 299 | // Arrange 300 | const { publicKey, secretKey } = crypto.generate() 301 | 302 | // Encrypt content that is not signed 303 | const ciphertext = crypto.encrypt(plaintext, publicKey) 304 | 305 | // Act 306 | const decryptedContentWithAttachments = await crypto.decryptWithAttachments(secretKey, { 307 | encryptedContent: ciphertext, 308 | version: INTERNAL_TEST_VERSION, 309 | }) 310 | const decryptedFiles = decryptedContentWithAttachments!.attachments 311 | 312 | // Assert 313 | expect(decryptedFiles).toEqual({}) 314 | }) 315 | 316 | it('should be able to handle corrupted encrypted content', async () => { 317 | // Arrange 318 | const { secretKey } = crypto.generate() 319 | 320 | // Act 321 | const decryptedContents = await crypto.decryptWithAttachments(secretKey, { 322 | encryptedContent: 'bad encrypted content', 323 | version: INTERNAL_TEST_VERSION, 324 | }) 325 | 326 | // Assert 327 | expect(decryptedContents).toBe(null) 328 | }) 329 | 330 | it('should be able to handle corrupted download', async () => { 331 | // Arrange 332 | const { publicKey, secretKey } = crypto.generate() 333 | 334 | let attachmentPlaintext = plaintext.slice(0) 335 | attachmentPlaintext.push({ 336 | _id: '6e771c946b3c5100240368e5', 337 | question: 'Random file', 338 | fieldType: 'attachment', 339 | answer: 'my-random-file.txt', 340 | }) 341 | 342 | // Encrypt content that is not signed 343 | const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey) 344 | 345 | // Encrypt file 346 | const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey) 347 | const uploadedFile = { 348 | submissionPublicKey: encryptedFile.submissionPublicKey, 349 | nonce: encryptedFile.nonce, 350 | binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data 351 | } 352 | 353 | // Act 354 | const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { 355 | encryptedContent: ciphertext, 356 | attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, 357 | version: INTERNAL_TEST_VERSION, 358 | }) 359 | mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) 360 | const decryptedContents = await decryptedFilesPromise 361 | 362 | // Assert 363 | expect(decryptedContents).toBe(null) 364 | }) 365 | 366 | it('should be able to handle decrypted submission without corresponding attachment field', async () => { 367 | // Arrange 368 | const { publicKey, secretKey } = crypto.generate() 369 | 370 | // Encrypt content that is not signed 371 | // Note that plaintext doesn't have any attachment fields 372 | const ciphertext = crypto.encrypt(plaintext, publicKey) 373 | 374 | // Encrypt file 375 | const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey) 376 | const uploadedFile = { 377 | submissionPublicKey: encryptedFile.submissionPublicKey, 378 | nonce: encryptedFile.nonce, 379 | binary: encodeBase64(encryptedFile.binary) 380 | } 381 | 382 | // Act 383 | const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { 384 | encryptedContent: ciphertext, 385 | attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, 386 | version: INTERNAL_TEST_VERSION, 387 | }) 388 | const decryptedContents = await decryptedFilesPromise 389 | 390 | // Assert 391 | expect(decryptedContents).toBe(null) 392 | }) 393 | 394 | it('should be able to handle axios errors', async () => { 395 | // Arrange 396 | const { publicKey, secretKey } = crypto.generate() 397 | 398 | let attachmentPlaintext = plaintext.slice(0) 399 | attachmentPlaintext.push({ 400 | _id: '6e771c946b3c5100240368e5', 401 | question: 'Random file', 402 | fieldType: 'attachment', 403 | answer: 'my-random-file.txt', 404 | }) 405 | 406 | // Encrypt content that is not signed 407 | const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey) 408 | 409 | // Act 410 | const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { 411 | encryptedContent: ciphertext, 412 | attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, 413 | version: INTERNAL_TEST_VERSION, 414 | }) 415 | mockAxios.mockResponse({ 416 | data: {}, 417 | status: 404, 418 | statusText: 'Not Found', 419 | }) 420 | const decryptedContents = await decryptedFilesPromise 421 | 422 | // Assert 423 | expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) 424 | expect(decryptedContents).toBe(null) 425 | }) 426 | }) 427 | -------------------------------------------------------------------------------- /spec/init.spec.ts: -------------------------------------------------------------------------------- 1 | import formsg from '../src/index' 2 | import { SIGNING_KEYS } from '../src/resource/signing-keys' 3 | import { VERIFICATION_KEYS } from '../src/resource/verification-keys' 4 | import { 5 | getSigningPublicKey, 6 | getVerificationPublicKey, 7 | } from '../src/util/publicKey' 8 | 9 | describe('FormSG SDK', () => { 10 | describe('Initialisation', () => { 11 | it('should be able to initialise without arguments', () => { 12 | const sdk = formsg() 13 | // Should be autopopulated with production public keys. 14 | expect(sdk.crypto.signingPublicKey).toEqual( 15 | SIGNING_KEYS.production.publicKey 16 | ) 17 | expect(sdk.verification.verificationPublicKey).toEqual( 18 | VERIFICATION_KEYS.production.publicKey 19 | ) 20 | expect(sdk.webhooks.publicKey).toEqual(SIGNING_KEYS.production.publicKey) 21 | }) 22 | 23 | it('should correctly assign given webhook signing key', async () => { 24 | const mockSecretKey = 'mock secret key' 25 | const sdk = formsg({ 26 | webhookSecretKey: mockSecretKey, 27 | }) 28 | 29 | expect(sdk.webhooks.secretKey).toEqual(mockSecretKey) 30 | }) 31 | 32 | it('should be able to initialise with valid verification options', () => { 33 | // Arrange 34 | const TEST_TRANSACTION_EXPIRY = 10000 35 | const sdk = formsg({ 36 | mode: 'test', 37 | verificationOptions: { 38 | secretKey: VERIFICATION_KEYS.test.secretKey, 39 | transactionExpiry: TEST_TRANSACTION_EXPIRY, 40 | }, 41 | }) 42 | 43 | expect(sdk.verification.verificationPublicKey).toEqual( 44 | VERIFICATION_KEYS.test.publicKey 45 | ) 46 | expect(sdk.verification.verificationSecretKey).toEqual( 47 | VERIFICATION_KEYS.test.secretKey 48 | ) 49 | expect(sdk.verification.transactionExpiry).toEqual( 50 | TEST_TRANSACTION_EXPIRY 51 | ) 52 | }) 53 | }) 54 | 55 | describe('Public keys', () => { 56 | it('should get the correct verification public key given a mode', () => { 57 | expect(getVerificationPublicKey('test')).toBe( 58 | VERIFICATION_KEYS.test.publicKey 59 | ) 60 | expect(getVerificationPublicKey('staging')).toBe( 61 | VERIFICATION_KEYS.staging.publicKey 62 | ) 63 | expect(getVerificationPublicKey('development')).toBe( 64 | VERIFICATION_KEYS.development.publicKey 65 | ) 66 | expect(getVerificationPublicKey('production')).toBe( 67 | VERIFICATION_KEYS.production.publicKey 68 | ) 69 | expect(getVerificationPublicKey()).toBe( 70 | VERIFICATION_KEYS.production.publicKey 71 | ) 72 | }) 73 | 74 | it('should get the correct signing key given a mode', () => { 75 | expect(getSigningPublicKey('test')).toBe(SIGNING_KEYS.test.publicKey) 76 | expect(getSigningPublicKey('staging')).toBe( 77 | SIGNING_KEYS.staging.publicKey 78 | ) 79 | expect(getSigningPublicKey('development')).toBe( 80 | SIGNING_KEYS.development.publicKey 81 | ) 82 | expect(getSigningPublicKey('production')).toBe( 83 | SIGNING_KEYS.production.publicKey 84 | ) 85 | expect(getSigningPublicKey()).toBe(SIGNING_KEYS.production.publicKey) 86 | }) 87 | }) 88 | }) 89 | -------------------------------------------------------------------------------- /spec/keys.spec.ts: -------------------------------------------------------------------------------- 1 | import { SIGNING_KEYS } from '../src/resource/signing-keys' 2 | import { VERIFICATION_KEYS } from '../src/resource/verification-keys' 3 | 4 | describe('Key lengths', () => { 5 | // ED25519 key lengths in base64 6 | const PUBLIC_KEY_LENGTH = 44 7 | const SECRET_KEY_LENGTH = 88 8 | 9 | describe('Verification keys', () => { 10 | describe('Public keys', () => { 11 | it('should have the correct length for ED25519 public keys', () => { 12 | expect(VERIFICATION_KEYS.development.publicKey).toHaveLength( 13 | PUBLIC_KEY_LENGTH 14 | ) 15 | expect(VERIFICATION_KEYS.test.publicKey).toHaveLength(PUBLIC_KEY_LENGTH) 16 | expect(VERIFICATION_KEYS.staging.publicKey).toHaveLength( 17 | PUBLIC_KEY_LENGTH 18 | ) 19 | expect(VERIFICATION_KEYS.production.publicKey).toHaveLength( 20 | PUBLIC_KEY_LENGTH 21 | ) 22 | }) 23 | }) 24 | 25 | describe('Secret keys', () => { 26 | it('should have the correct length for ED25519 secret keys in test and dev mode', () => { 27 | expect(VERIFICATION_KEYS.development.secretKey).toHaveLength( 28 | SECRET_KEY_LENGTH 29 | ) 30 | expect(VERIFICATION_KEYS.test.secretKey).toHaveLength(SECRET_KEY_LENGTH) 31 | }) 32 | }) 33 | }) 34 | 35 | describe('Signing keys', () => { 36 | describe('Public keys', () => { 37 | it('should have the correct length for ED25519 public keys', () => { 38 | expect(SIGNING_KEYS.development.publicKey).toHaveLength( 39 | PUBLIC_KEY_LENGTH 40 | ) 41 | expect(SIGNING_KEYS.test.publicKey).toHaveLength(PUBLIC_KEY_LENGTH) 42 | expect(SIGNING_KEYS.staging.publicKey).toHaveLength(PUBLIC_KEY_LENGTH) 43 | expect(SIGNING_KEYS.production.publicKey).toHaveLength( 44 | PUBLIC_KEY_LENGTH 45 | ) 46 | }) 47 | }) 48 | 49 | describe('Secret keys', () => { 50 | it('should have the correct length for ED25519 secret keys in test and dev mode', () => { 51 | expect(SIGNING_KEYS.development.secretKey).toHaveLength( 52 | SECRET_KEY_LENGTH 53 | ) 54 | expect(SIGNING_KEYS.test.secretKey).toHaveLength(SECRET_KEY_LENGTH) 55 | }) 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /spec/resources/crypto-data-20200322.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DO NOT MODIFY THE DATA BELOW. 3 | * 4 | * The below data represents a submission from 2020-03-22. 5 | * It must remain unmodified to maintain strict backwards compatibility. 6 | * 7 | * If changes are necessary, create new test data instead. 8 | */ 9 | 10 | const plaintext = [ 11 | { 12 | _id: '5e7479c786eaf2002488a211', 13 | question: 'Header', 14 | fieldType: 'section', 15 | isHeader: true, 16 | answer: '', 17 | }, 18 | { 19 | _id: '5e7479a086eaf2002488a20e', 20 | question: 'Email', 21 | fieldType: 'email', 22 | answer: 'test@open.gov.sg', 23 | }, 24 | { 25 | _id: '5e771c246b3c5100240368d8', 26 | question: 'Mobile Number', 27 | fieldType: 'mobile', 28 | answer: '+6598765432', 29 | }, 30 | { 31 | _id: '5e7479a386eaf2002488a20f', 32 | question: 'Number', 33 | fieldType: 'number', 34 | answer: '123', 35 | }, 36 | { 37 | _id: '5e771c346b3c5100240368da', 38 | question: 'Decimal', 39 | fieldType: 'decimal', 40 | answer: '0.123', 41 | }, 42 | { 43 | _id: '5e771c516b3c5100240368dc', 44 | question: 'Short Text', 45 | fieldType: 'textfield', 46 | answer: 'Test', 47 | }, 48 | { 49 | _id: '5e771c596b3c5100240368dd', 50 | question: 'Long Text', 51 | fieldType: 'textarea', 52 | answer: 'Long\nText', 53 | }, 54 | { 55 | _id: '5e771c626b3c5100240368de', 56 | question: 'Dropdown', 57 | fieldType: 'dropdown', 58 | answer: 'Option 1', 59 | }, 60 | { 61 | _id: '5e771c666b3c5100240368df', 62 | question: 'Yes/No', 63 | fieldType: 'yes_no', 64 | answer: 'Yes', 65 | }, 66 | { 67 | _id: '5e771c7a6b3c5100240368e0', 68 | question: 'Checkbox', 69 | fieldType: 'checkbox', 70 | answerArray: ['Option 2'], 71 | }, 72 | { 73 | _id: '5e771c8a6b3c5100240368e1', 74 | question: 'Radio', 75 | fieldType: 'radiobutton', 76 | answer: 'Option 1', 77 | }, 78 | { 79 | _id: '5e771c8d6b3c5100240368e2', 80 | question: 'Date', 81 | fieldType: 'date', 82 | answer: '22 Mar 2020', 83 | }, 84 | { 85 | _id: '5e771c906b3c5100240368e3', 86 | question: 'Rating', 87 | fieldType: 'rating', 88 | answer: '5', 89 | }, 90 | { 91 | _id: '5e771c946b3c5100240368e4', 92 | question: 'NRIC', 93 | fieldType: 'nric', 94 | answer: 'S9912345A', 95 | }, 96 | ] 97 | 98 | const ciphertext = 99 | 'RqOjwNXwiVJqvdTrQeD/NiktpI8vzo6CXlBBNihmmwI=;+0cGJwOA42F7DmQO7Kr6tNn9YH/7poDe:2jpehB9uW+63G1EimxOs1tsfR54xxSVZQbFMQaCa8ovVoF/6isBCEl5WLmrE1CRa2c5L2G5rAgCUnhsxQ7jVa//XEKr/m9kGNMbPnVqH62RslAxh30Mzz2he/ssbMazLDBzgQwd8I+pHnrBHQW5BsLqclj7QAMX3fa10Zon0ih4irWQ1o9eYitFllitD+vIcwbBzJyigGvD/t14+2/5imZmJdmJTJXd1ySxDo1X6KjBmph4rzvWtZSUgF7rRfMtAmbyOj87i6CkkNNIN4Dtl3zEuSeLuU2IDF7IHDdAwpmgWM9ejFpwrMFfr8PRovCubuRxCDQ1hV6GOyh60SlKA3oHQMhRQ2yia2vr9yxzHgO+wVUfgoiXQn8tvXFwZmzZd/eWENC1QI+XvP1jt50RIhQ0kMbyhBiaAG9nYlhGO3UHtmGGBoOdW4l4DZaWKRItnw1qgb2oxCvxOIkWNoafq1qJbYJsldOy6a/I2lbwAPL7MzVfgHJDj11dLOBgHGZHia6ZaROXYEiFpkVP8APOO/tgV902nOOlt+w63QNIieCIoGphn9LvOTo6Y6HD8qH6sekEyXCds6jP4RVw5XIN9LGeOWlEKx/VR2rf8b9qzFcYRPzfH5M8I9rpuZkk72ANiTeLRM8C8zWFnitzDlh1B2M+jnjrg+jEYm0ugro7tvHYSmU4tKcGR3mPlDrROjtFf3eBO8+pZKzuQfdA/7kN5YekAzyNjLcixioycrDmjR+BbBKxVrwNlm0hmHLLdU1g42GYpmfUUythDnqwALAOtaZcuj1ObX50h6kmhIl4fAEcXdLKKpoASzafbHnIH0iNX1CefnflLxymDPjjTFqcGSpY1vZf2pxDxUNZPXsd3vbV4KbrYq9v/R5NJ+mW3lxm839aN0pNsMbMetyZTyX8tXofWERxKEZDDXRCoYS0Ijml5h0X58juM13hNtc48iuPyx8oBIy4WophJ+M4CJfsq1wfBK0q+a8P2Tj8odJRQbbFdCoQRRRKbFhk0nT/R3V23cRjMB/Z1DCFmo8ywhUfSlMhrs8f6f3myhmJpzP5MXPJgzRIAytYtUF34j+9fspaYtyQHg4j4f8OBIYrNOCgt6++1bmy0+aI3Y0DoBJ1eLfe9iHUt64cvPbPqmAxX8Jkb1kY+OVwjx7DTxFc5dCzkL/3VA1FAZe7IqfP0v/3fTT6oK7nuy951GUSU9sBynV8Z6zJecYUWbgFZ1u/K8ag6btR2IeRFz7dG+Ffkb8nsGTcNm0l54Q/XbudtfIPN2GTGp1PlgFZ5JERszVrQ8MIrMnHtPvnbtIlqdVzZ0KwR1YQnqtjCoNnI/TzniRZnydE/qJCc5ZwAQDJhl0XRmVes5sp4obxhreSdgGjoyybeFN70rb6uMvsuBlTS5Z6xg7q4eW0e8Xx0PlKYJi8eUsFtzQlN3iJzgFTeLGOlguK2GWnI/Myy5/nan2Np5+eZ2GZhdn6s/NYmiJGLlcbmcXRLOj53O1Z9Oornc+Hq5rz+eZKg4uYNbyFCbvJ9d/wNbRaQva9kETeEfGVosZXotnA2dxYRF4A24Qwjo+yNeRlbyQBf0V0BWY+rfJ+F2JMP4LZ5FNVhd5JI16z7PEgqvlGqm7zkfPmwlTjCzDFXYz749fz9hrzqOCALguEhmMYEsun8mK7IptW77qbKyx2jTu/2OC6pqdWhHB3PliKZXD5EgedpqzHcWQg/s9TloSXy9pE9PEs0j+el+j4yXyQcfrAjODWHSrUXNWSJc1rOM1ochIYJWYHn4pf2Jxuop90+c4DFYp5eih3k8BGy4Etp6L0N7PJ+ugSqZV8L0QYT2sLBwG2cS8FNUGJPoUkUn6R2Bg7bTQ==' 100 | 101 | const formSecretKey = 'H7B0nKJ+E7+naSkQApxGayz1y/lZe4thta4iPp1B+Ns=' 102 | const formPublicKey = 'NKHcx/SuUfBxhXe20yoVTCsDwQTSfrd5MMClCOrd/js=' 103 | const submissionSecretKey = '9h1ys0ZpcPD9pBShwtE4YKXv8882ldjd9sF51YS3Fks=' 104 | const submissionPublicKey = 'RqOjwNXwiVJqvdTrQeD/NiktpI8vzo6CXlBBNihmmwI=' 105 | 106 | export { 107 | plaintext, 108 | ciphertext, 109 | formSecretKey, 110 | formPublicKey, 111 | submissionSecretKey, 112 | submissionPublicKey, 113 | } 114 | -------------------------------------------------------------------------------- /spec/resources/crypto-data-20200604.ts: -------------------------------------------------------------------------------- 1 | const plaintextMultiLang = [ 2 | { 3 | _id: '5e771c8a6b3c5100240368e1', 4 | question: 'Radio', 5 | fieldType: 'radiobutton', 6 | answer: '96%, or more / 96% (விழுக்காடு) அல்லது அதை விட அதிகமா? / ৯৬% বা বেশী / 96% या ज़्यादा / 96%以上', 7 | }, 8 | { 9 | _id: '5e771c8a6b3c5100240368e2', 10 | question: 'Radio', 11 | fieldType: 'radiobutton', 12 | answer: 'less than 96% / 96% (விழுக்காட்டை) விட குறைவா? / ৯৬% চেয়ে কম / 96% से कम / 少于96%', 13 | }, 14 | ] 15 | 16 | export { 17 | plaintextMultiLang, 18 | } 19 | -------------------------------------------------------------------------------- /spec/resources/crypto-data-20221114.ts: -------------------------------------------------------------------------------- 1 | const plaintextEmptyTitles = [ 2 | { 3 | _id: '5e771c8a6b3c5100240368e1', 4 | question: '', 5 | fieldType: 'checkbox', 6 | answerArray: ['Option 1'], 7 | }, 8 | { 9 | _id: '5e771c8a6b3c5100240368e2', 10 | question: '', 11 | fieldType: 'textfield', 12 | answer: 'Test', 13 | }, 14 | ] 15 | 16 | export { 17 | plaintextEmptyTitles, 18 | } 19 | -------------------------------------------------------------------------------- /spec/resources/crypto-v3-data-20231207.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DO NOT MODIFY THE DATA BELOW. 3 | * 4 | * The below data represents a submission from 2023-12-07. 5 | * It must remain unmodified to maintain strict backwards compatibility. 6 | * 7 | * If changes are necessary, create new test data instead. 8 | */ 9 | 10 | const plaintext = { 11 | '5e7479a086eaf2002488a20e': { 12 | fieldType: 'email', 13 | answer: 'test@open.gov.sg', 14 | }, 15 | '5e771c246b3c5100240368d8': { 16 | fieldType: 'mobile', 17 | answer: '+6598765432', 18 | }, 19 | '5e7479a386eaf2002488a20f': { 20 | fieldType: 'number', 21 | answer: '123', 22 | }, 23 | '5e771c8a6b3c5100240368e1': { 24 | fieldType: 'radiobutton', 25 | answer: { value: 'Option 1' }, 26 | }, 27 | '5e771c7a6b3c5100240368e0': { 28 | fieldType: 'checkbox', 29 | answer: { value: ['Option 2'], othersInput: 'Another answer' }, 30 | } 31 | } 32 | 33 | const ciphertext = { 34 | encryptedContent: 35 | 'yUW5li4+IA9q2/n3ZS+5+wrXQ8mKGrFJ1KW9Kf/eRzc=;PgZE8+y8rBvssnqLnqjnnqHDW6PngYKK:eIEuOUQjf1YkQIulZ7bCKXIl6wByg644Ulk/LjhefmLzhkVmXbTxBJVKVG6YgV0ZMcG4JPUuQ+WOW+N1/AOyL/8DJqclX74kG6s0DNXIJixkqNZCnfZapulerR9XXKSfwBjpo1nK25KCg32F/ey2HypPcluGV19hWwgj80mlms7Ya7x1X5wcdttlGrzGEnNH2VEPXjzJZHqiV1TWoQGwxSZ753fpkHUkBeKFA1UkMHS5XYnWyYD48JpfpOAz0L2ti6RHQnQLSKUHscYVfAZt5OyUGqPFmhm2ulWdycNVp8HayQrpqeY8cdu8QsmZRdNCMfMFLahZCm6xKS+8GUrJWgJr64yaZpkxQS45uPb9zxC+G/u4FZhS/YsrjDTuIIwMGS0+qsNr4075yemFFAQHIpbhWZ9QlYrNq2TAolrVezeAw3AQ/nr4sz60dvqRahcse9x8oMxB7jA55OuxH5uk6PcCIAmEi+njr6Lgbcn2mtPMyk7kGcwjNzCL57b51RxJVi0ZqNXrS0FFepvzCK3IOEqKqrKGGK0qGqF4MFsH2wdq4RFkXjLMZk4u9ZWjIRjc', 36 | encryptedSubmissionSecretKey: 37 | 'ywWDxb29guAgVK4yhLmLK19UKzLrfLAl65JzPDCVNz8=;/Q3WNg7Dk/tWBmpdUcST39zG16/Nyn8V:p1YqpiwEtOssq3yZUhZC1SgIYJcfJDmVFmgNwKf8D+YEqDzLaq5GShR7hTtTixtp', 38 | } 39 | 40 | const formPublicKey = 'ySgusViv6xdSIXELuGOq2L3Obp8xorT0Qilv+G4nHnM=' 41 | const formSecretKey = 'Ngx1Kwpe8JXZUof/DCkkVduVmPSN4paqaKj5971Gq5c=' 42 | const submissionPublicKey = '8JCuSlyJZ5N684o9TNdZLijtuORTlD/pbXiFwNf7Fhc=' 43 | const submissionSecretKey = 'bIyKphcx5hiuBaJ4q5cwnXaFNY9Ofe5NQBqTEzf3zYA=' 44 | 45 | export { 46 | plaintext, 47 | ciphertext, 48 | formPublicKey, 49 | formSecretKey, 50 | submissionPublicKey, 51 | submissionSecretKey 52 | } 53 | -------------------------------------------------------------------------------- /spec/resources/ogp.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/util.spec.ts: -------------------------------------------------------------------------------- 1 | import { areAttachmentFieldIdsValid } from '../src/util/crypto' 2 | describe('utils', () => { 3 | describe('areAttachmentFieldIdsValid', () => { 4 | it('should return true when all the fieldIds are within the filenames', () => { 5 | // Arrange 6 | const MOCK_FILENAMES = { 7 | mock: 'file', 8 | alsomock: 'file2', 9 | } 10 | const MOCK_FIELD_IDS = Object.keys(MOCK_FILENAMES) 11 | 12 | // Act 13 | const actual = areAttachmentFieldIdsValid(MOCK_FIELD_IDS, MOCK_FILENAMES) 14 | 15 | // Assert 16 | expect(actual).toBe(true) 17 | }) 18 | 19 | it('should return false when some fieldIds are not within the filenames', () => { 20 | // Arrange 21 | const MOCK_FILENAMES = { 22 | mock: 'file', 23 | alsomock: 'file2', 24 | } 25 | const MOCK_FIELD_IDS = Object.keys(MOCK_FILENAMES).concat('missingField') 26 | 27 | // Act 28 | const actual = areAttachmentFieldIdsValid(MOCK_FIELD_IDS, MOCK_FILENAMES) 29 | 30 | // Assert 31 | expect(actual).toBe(false) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /spec/verification/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | formatToBaseString, 3 | isSignatureTimeValid, 4 | } from '../../src/verification/utils' 5 | 6 | describe('Verification Utils', () => { 7 | const TEST_TRANSACTION_EXPIRY = 10000 8 | const TIME = 1588658696255 9 | const PARAMS = { 10 | transactionId: 'transactionId', 11 | formId: 'formId', 12 | fieldId: 'fieldId', 13 | answer: 'answer', 14 | } 15 | 16 | describe('formatToBaseString', () => { 17 | it('should construct a basestring', () => { 18 | // Act 19 | const baseString = formatToBaseString({ 20 | time: TIME, 21 | ...PARAMS, 22 | }) 23 | 24 | // Assert 25 | const expectedBaseString = `${PARAMS.transactionId}.${PARAMS.formId}.${PARAMS.fieldId}.${PARAMS.answer}.${TIME}` 26 | expect(baseString).toBe(expectedBaseString) 27 | }) 28 | }) 29 | 30 | describe('isSignatureTimeValid', () => { 31 | it('should return true if time is valid', () => { 32 | // Valid time less than the TEST_TRANSACTION EXPIRY 33 | const validTime = TIME + 1 34 | expect( 35 | isSignatureTimeValid(TIME, validTime, TEST_TRANSACTION_EXPIRY) 36 | ).toBe(true) 37 | }) 38 | 39 | it('should return false if time is invalid (expired)', () => { 40 | const expiredTime = TIME + TEST_TRANSACTION_EXPIRY * 2000 41 | expect( 42 | isSignatureTimeValid(TIME, expiredTime, TEST_TRANSACTION_EXPIRY) 43 | ).toBe(false) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /spec/verification/verification.spec.ts: -------------------------------------------------------------------------------- 1 | import { VERIFICATION_KEYS } from '../../src/resource/verification-keys' 2 | import Verification from '../../src/verification' 3 | import { MissingSecretKeyError, MissingPublicKeyError } from '../../src/errors' 4 | 5 | const TEST_PUBLIC_KEY = VERIFICATION_KEYS.test.publicKey 6 | const TEST_SECRET_KEY = VERIFICATION_KEYS.test.secretKey 7 | 8 | const TEST_TRANSACTION_EXPIRY = 10000 9 | const TEST_PARAMS = { 10 | transactionId: 'transactionId', 11 | formId: 'formId', 12 | fieldId: 'fieldId', 13 | answer: 'answer', 14 | } 15 | const TIME = 1588658696255 16 | const VALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=XLF1V4RDu8dEJLq1yK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` 17 | const INVALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=InvalidSignatureyK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` 18 | const DEFORMED_SIGNATURE = `abcdefg` 19 | 20 | const VALID_AUTH_PAYLOAD = { 21 | signatureString: VALID_SIGNATURE, 22 | submissionCreatedAt: TIME + 1, 23 | fieldId: TEST_PARAMS.fieldId, 24 | answer: TEST_PARAMS.answer, 25 | } 26 | 27 | describe('Verification', () => { 28 | describe('Initialization', () => { 29 | it('should not generate signatures if secret key is not provided', () => { 30 | // Arrange 31 | const verification = new Verification({ 32 | // No secret key provided. 33 | transactionExpiry: TEST_TRANSACTION_EXPIRY, 34 | }) 35 | 36 | // Act 37 | expect(() => verification.generateSignature(TEST_PARAMS)).toThrow( 38 | MissingSecretKeyError 39 | ) 40 | }) 41 | 42 | it('should not authenticate if public key is not provided', () => { 43 | const verification = new Verification({ 44 | // No public key provided. 45 | transactionExpiry: TEST_TRANSACTION_EXPIRY, 46 | secretKey: TEST_SECRET_KEY, 47 | }) 48 | 49 | expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( 50 | MissingPublicKeyError 51 | ) 52 | }) 53 | 54 | it('should not authenticate if transaction expiry is not provided', () => { 55 | const verification = new Verification({ 56 | // No transaction expiry provided. 57 | publicKey: TEST_PUBLIC_KEY, 58 | secretKey: TEST_SECRET_KEY, 59 | }) 60 | 61 | expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( 62 | 'Provide a transaction expiry when when initializing the FormSG SDK to use this function.' 63 | ) 64 | }) 65 | }) 66 | 67 | describe('Usage', () => { 68 | const verification = new Verification({ 69 | transactionExpiry: TEST_TRANSACTION_EXPIRY, 70 | secretKey: TEST_SECRET_KEY, 71 | publicKey: TEST_PUBLIC_KEY, 72 | }) 73 | 74 | let now: jest.MockInstance 75 | 76 | beforeAll(() => { 77 | now = jest.spyOn(Date, 'now').mockImplementation(() => { 78 | return TIME 79 | }) 80 | }) 81 | 82 | afterAll(() => { 83 | now.mockRestore() 84 | }) 85 | 86 | it('should generate a signature', () => { 87 | expect(verification.generateSignature(TEST_PARAMS)).toBe(VALID_SIGNATURE) 88 | }) 89 | 90 | it('should successfully authenticate a valid signature', () => { 91 | expect(verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) 92 | }) 93 | 94 | it('should fail to authenticate a valid signature if it is expired', () => { 95 | const payload = { 96 | signatureString: VALID_SIGNATURE, 97 | submissionCreatedAt: TIME + TEST_TRANSACTION_EXPIRY * 2000, 98 | fieldId: TEST_PARAMS.fieldId, 99 | answer: TEST_PARAMS.answer, 100 | } 101 | expect(verification.authenticate(payload)).toBe(false) 102 | }) 103 | 104 | it('should fail to authenticate an invalid signature', () => { 105 | const payload = { 106 | signatureString: INVALID_SIGNATURE, 107 | submissionCreatedAt: TIME + 1, 108 | fieldId: TEST_PARAMS.fieldId, 109 | answer: TEST_PARAMS.answer, 110 | } 111 | expect(verification.authenticate(payload)).toBe(false) 112 | }) 113 | 114 | it('should fail to authenticate a deformed signature', () => { 115 | const payload = { 116 | signatureString: DEFORMED_SIGNATURE, 117 | submissionCreatedAt: TIME + 1, 118 | fieldId: TEST_PARAMS.fieldId, 119 | answer: TEST_PARAMS.answer, 120 | } 121 | expect(verification.authenticate(payload)).toBe(false) 122 | }) 123 | }) 124 | }) 125 | -------------------------------------------------------------------------------- /spec/webhooks.spec.ts: -------------------------------------------------------------------------------- 1 | import Webhooks from '../src/webhooks' 2 | import { SIGNING_KEYS } from '../src/resource/signing-keys' 3 | import { MissingSecretKeyError, WebhookAuthenticateError } from '../src/errors' 4 | 5 | const webhooksPublicKey = SIGNING_KEYS.test.publicKey 6 | const signingSecretKey = SIGNING_KEYS.test.secretKey 7 | 8 | describe('Webhooks', () => { 9 | const uri = 'https://some-endpoint.com/post' 10 | const submissionId = 'someSubmissionId' 11 | const formId = 'someFormId' 12 | 13 | const webhooks = new Webhooks({ 14 | publicKey: webhooksPublicKey, 15 | secretKey: signingSecretKey, 16 | }) 17 | 18 | const webhooksNoSecret = new Webhooks({ 19 | publicKey: webhooksPublicKey, 20 | }) 21 | 22 | /** 23 | * Helper method to generate a test signature. 24 | */ 25 | const generateTestSignature = (epoch: number) => { 26 | return webhooks.generateSignature({ 27 | uri, 28 | submissionId, 29 | formId, 30 | epoch, 31 | }) 32 | } 33 | 34 | /** 35 | * Helper method to construct a test header. 36 | */ 37 | const constructTestHeader = (epoch: number, signature: string) => { 38 | return webhooks.constructHeader({ 39 | epoch, 40 | submissionId, 41 | formId, 42 | signature, 43 | }) 44 | } 45 | 46 | it('should be signing the signature and generating the X-FormSG-Signature header with the correct format', () => { 47 | const epoch = 1583136171649 48 | const signature = generateTestSignature(epoch) 49 | expect(signature).toBe( 50 | 'KMirkrGJLPqu+Na+gdZLUxl9ZDgf2PnNGPnSoG1FuTMRUTiQ6o0jB/GTj1XFjn2s9JtsL5GiCmYROpjJhDyxCw==' 51 | ) 52 | 53 | // X-FormSG-Signature 54 | const header = constructTestHeader(epoch, signature) 55 | expect(header).toBe( 56 | `t=1583136171649,s=someSubmissionId,f=someFormId,v1=KMirkrGJLPqu+Na+gdZLUxl9ZDgf2PnNGPnSoG1FuTMRUTiQ6o0jB/GTj1XFjn2s9JtsL5GiCmYROpjJhDyxCw==` 57 | ) 58 | }) 59 | 60 | it('should authenticate a signature that was recently generated', () => { 61 | const epoch = Date.now() 62 | const signature = generateTestSignature(epoch) 63 | const header = constructTestHeader(epoch, signature) 64 | 65 | const authentiateResult = webhooks.authenticate(header, uri) 66 | expect(authentiateResult).toBe(true) 67 | }) 68 | 69 | it('should reject signatures generated more than 5 minutes ago', () => { 70 | const epoch = Date.now() - 5 * 60 * 1000 - 1 71 | const signature = generateTestSignature(epoch) 72 | const header = constructTestHeader(epoch, signature) 73 | 74 | expect(() => webhooks.authenticate(header, uri)).toThrow( 75 | WebhookAuthenticateError 76 | ) 77 | }) 78 | 79 | it('should reject invalid signature headers', () => { 80 | const invalidHeader = 'invalidHeader' 81 | expect(() => webhooks.authenticate(invalidHeader, uri)).toThrow( 82 | WebhookAuthenticateError 83 | ) 84 | }) 85 | 86 | it('should reject if signature header cannot be verified', () => { 87 | // Create valid header 88 | const epoch = Date.now() 89 | const signature = generateTestSignature(epoch) 90 | const header = constructTestHeader(epoch, signature) 91 | 92 | // Create a new Webhook class with a different publicKey 93 | const webhooksAlt = new Webhooks({ 94 | publicKey: 'ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY=', 95 | }) 96 | 97 | expect(() => webhooksAlt.authenticate(header, uri)).toThrow( 98 | WebhookAuthenticateError 99 | ) 100 | }) 101 | 102 | it('should throw error if generateSignature is called without a secret key in class instantiation', () => { 103 | expect(() => 104 | webhooksNoSecret.generateSignature({ 105 | uri, 106 | submissionId, 107 | formId, 108 | epoch: Date.now(), 109 | }) 110 | ).toThrow(MissingSecretKeyError) 111 | }) 112 | 113 | it('should throw error if generateSignature is called with undefined uri', () => { 114 | expect(() => 115 | webhooks.generateSignature({ 116 | // For TypeScript to accept it since signature only accepts strings, but 117 | // still want to test for undefined behavior. 118 | uri: undefined!, 119 | submissionId, 120 | formId, 121 | epoch: Date.now(), 122 | }) 123 | ).toThrow(TypeError) 124 | }) 125 | 126 | it('should throw error if generateSignature is called with undefined submissionId', () => { 127 | expect(() => 128 | webhooks.generateSignature({ 129 | uri, 130 | // For TypeScript to accept it since signature only accepts strings, but 131 | // still want to test for undefined behavior. 132 | submissionId: undefined!, 133 | formId, 134 | epoch: Date.now(), 135 | }) 136 | ).toThrow(TypeError) 137 | }) 138 | 139 | it('should throw error if generateSignature is called with undefined formId', () => { 140 | expect(() => 141 | webhooks.generateSignature({ 142 | uri, 143 | submissionId, 144 | // For TypeScript to accept it since signature only accepts strings, but 145 | // still want to test for undefined behavior. 146 | formId: undefined!, 147 | epoch: Date.now(), 148 | }) 149 | ).toThrow(TypeError) 150 | }) 151 | 152 | it('should throw error if generateSignature is called with undefined epoch', () => { 153 | expect(() => 154 | webhooks.generateSignature({ 155 | uri, 156 | submissionId, 157 | formId, 158 | // For TypeScript to accept it since signature only accepts strings, but 159 | // still want to test for undefined behavior. 160 | epoch: undefined!, 161 | }) 162 | ).toThrow(TypeError) 163 | }) 164 | 165 | it('should throw error if constructHeader is called without a secret key in class instantiation', () => { 166 | const testEpoch = Date.now() 167 | const validSignature = generateTestSignature(testEpoch) 168 | 169 | expect(() => 170 | webhooksNoSecret.constructHeader({ 171 | formId, 172 | submissionId, 173 | epoch: testEpoch, 174 | signature: validSignature, 175 | }) 176 | ).toThrow(MissingSecretKeyError) 177 | }) 178 | 179 | it('should reject signatures generated more than 5 minutes ago', () => { 180 | const epoch = Date.now() - 5 * 60 * 1000 - 1 // 5min 1s into the past 181 | const signature = webhooks.generateSignature({ 182 | uri, 183 | submissionId, 184 | formId, 185 | epoch, 186 | }) as string 187 | const header = webhooks.constructHeader({ 188 | epoch, 189 | submissionId, 190 | formId, 191 | signature, 192 | }) as string 193 | 194 | expect(() => webhooks.authenticate(header, uri)).toThrow() 195 | }) 196 | 197 | it('should accept signatures generated within 5 minutes', () => { 198 | const epoch = Date.now() - 5 * 60 * 1000 + 1000 // 4min 59s into the past 199 | const signature = webhooks.generateSignature({ 200 | uri, 201 | submissionId, 202 | formId, 203 | epoch, 204 | }) as string 205 | const header = webhooks.constructHeader({ 206 | epoch, 207 | submissionId, 208 | formId, 209 | signature, 210 | }) as string 211 | 212 | expect(() => webhooks.authenticate(header, uri)).not.toThrow() 213 | }) 214 | 215 | it('should authenticate signatures if Form server drifts 4m59s into the future', () => { 216 | const epoch = Date.now() + 5 * 60 * 1000 - 1000 // 4min 59s into the future 217 | const signature = webhooks.generateSignature({ 218 | uri, 219 | submissionId, 220 | formId, 221 | epoch, 222 | }) as string 223 | const header = webhooks.constructHeader({ 224 | epoch, 225 | submissionId, 226 | formId, 227 | signature, 228 | }) as string 229 | 230 | expect(() => webhooks.authenticate(header, uri)).not.toThrow() 231 | }) 232 | 233 | it('should reject signatures if Form server drifts 5m1s into the future', () => { 234 | const epoch = Date.now() + 5 * 60 * 1000 + 1000 // 5min 1s into the future 235 | const signature = webhooks.generateSignature({ 236 | uri, 237 | submissionId, 238 | formId, 239 | epoch, 240 | }) as string 241 | const header = webhooks.constructHeader({ 242 | epoch, 243 | submissionId, 244 | formId, 245 | signature, 246 | }) as string 247 | 248 | expect(() => webhooks.authenticate(header, uri)).toThrow() 249 | }) 250 | }) 251 | -------------------------------------------------------------------------------- /src/crypto-base.ts: -------------------------------------------------------------------------------- 1 | import nacl from 'tweetnacl' 2 | import { decodeBase64, encodeBase64 } from 'tweetnacl-util' 3 | 4 | import { generateKeypair } from './util/crypto' 5 | import { EncryptedFileContent } from './types' 6 | 7 | export default class CryptoBase { 8 | /** 9 | * Generates a new keypair for encryption. 10 | * @returns The generated keypair. 11 | */ 12 | generate = generateKeypair 13 | 14 | /** 15 | * Encrypt given binary file with a unique keypair for each submission. 16 | * @param binary The file to encrypt, should be a blob that is converted to Uint8Array binary 17 | * @param publicKey The base-64 encoded public key 18 | * @returns Promise holding the encrypted file 19 | * @throws error if any of the encrypt methods fail 20 | */ 21 | encryptFile = async ( 22 | binary: Uint8Array, 23 | publicKey: string 24 | ): Promise => { 25 | const fileKeypair = this.generate() 26 | const nonce = nacl.randomBytes(24) 27 | return { 28 | //! NOTE: submissionPublicKey here is a misnomer as a new keypair is generated per file. 29 | // The naming is only retained for backward-compatibility purposes. 30 | submissionPublicKey: fileKeypair.publicKey, 31 | nonce: encodeBase64(nonce), 32 | binary: nacl.box( 33 | binary, 34 | nonce, 35 | decodeBase64(publicKey), 36 | decodeBase64(fileKeypair.secretKey) 37 | ), 38 | } 39 | } 40 | 41 | /** 42 | * Decrypt the given encrypted file content. 43 | * @param secretKey Secret key as a base-64 string 44 | * @param encrypted Object returned from encryptFile function 45 | * @param encrypted.submissionPublicKey The file's public key as a base-64 string 46 | * @param encrypted.nonce The nonce as a base-64 string 47 | * @param encrypted.blob The encrypted file as a Blob object 48 | */ 49 | decryptFile = async ( 50 | secretKey: string, 51 | { 52 | submissionPublicKey: filePublicKey, 53 | nonce, 54 | binary: encryptedBinary, 55 | }: EncryptedFileContent 56 | ): Promise => { 57 | return nacl.box.open( 58 | encryptedBinary, 59 | decodeBase64(nonce), 60 | decodeBase64(filePublicKey), 61 | decodeBase64(secretKey) 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/crypto-v3.ts: -------------------------------------------------------------------------------- 1 | import { 2 | decodeBase64, 3 | decodeUTF8, 4 | encodeBase64, 5 | encodeUTF8, 6 | } from 'tweetnacl-util' 7 | 8 | import { decryptContent, encryptMessage, generateKeypair } from './util/crypto' 9 | import { determineIsFormFieldsV3 } from './util/validate' 10 | import CryptoBase from './crypto-base' 11 | import { 12 | DecryptedContentV3, 13 | DecryptParams, 14 | DecryptParamsV3, 15 | EncryptedContentV3, 16 | FormFieldsV3, 17 | } from './types' 18 | 19 | export default class CryptoV3 extends CryptoBase { 20 | constructor() { 21 | super() 22 | } 23 | 24 | /** 25 | * Encrypt input with a unique keypair for each submission. 26 | * @param msg The message to encrypt, will be stringified. 27 | * @param form The base-64 encoded form public key for encrypting. 28 | * @returns The encrypted basestring. 29 | */ 30 | encrypt = (msg: any, formPublicKey: string): EncryptedContentV3 => { 31 | const submissionKeypair = generateKeypair() 32 | 33 | const encryptedSubmissionSecretKey = encryptMessage( 34 | decodeBase64(submissionKeypair.secretKey), 35 | formPublicKey 36 | ) 37 | 38 | const processedMsg = decodeUTF8(JSON.stringify(msg)) 39 | const encryptedContent = encryptMessage( 40 | processedMsg, 41 | submissionKeypair.publicKey 42 | ) 43 | 44 | return { 45 | submissionPublicKey: submissionKeypair.publicKey, 46 | submissionSecretKey: submissionKeypair.secretKey, 47 | encryptedContent, 48 | encryptedSubmissionSecretKey, 49 | } 50 | } 51 | 52 | /** 53 | * Decrypts an encrypted submission and returns it. 54 | * @param submissionSecretKey The base-64 encoded secret key for decrypting. 55 | * @param decryptParams The params containing encrypted content and information. 56 | * @param decryptParams.encryptedContent The encrypted content encoded with base-64. 57 | * @param decryptParams.version The version of the payload. 58 | * @returns The decrypted content if successful. Else, null will be returned. 59 | */ 60 | decryptFromSubmissionKey = ( 61 | submissionSecretKey: string, 62 | decryptParams: DecryptParams 63 | ): DecryptedContentV3 | null => { 64 | try { 65 | const { encryptedContent } = decryptParams 66 | 67 | // Do not return the transformed object in `_decrypt` function as a signed 68 | // object is not encoded in UTF8 and is encoded in Base-64 instead. 69 | const decryptedContent = decryptContent( 70 | submissionSecretKey, 71 | encryptedContent 72 | ) 73 | if (!decryptedContent) { 74 | throw new Error('Failed to decrypt content') 75 | } 76 | const decryptedObject: Record = JSON.parse( 77 | encodeUTF8(decryptedContent) 78 | ) 79 | if (!determineIsFormFieldsV3(decryptedObject)) { 80 | throw new Error('Decrypted object does not fit expected shape') 81 | } 82 | 83 | const returnedObject: DecryptedContentV3 = { 84 | submissionSecretKey, 85 | responses: decryptedObject as FormFieldsV3, 86 | } 87 | 88 | return returnedObject 89 | } catch (err) { 90 | return null 91 | } 92 | } 93 | 94 | /** 95 | * Decrypts an encrypted submission and returns it. 96 | * @param formSecretKey The base-64 encoded form secret key for decrypting the submission. 97 | * @param decryptParams The params containing encrypted content, encrypted submission key and information. 98 | * @param decryptParams.encryptedContent The encrypted content encoded with base-64. 99 | * @param decryptParams.encryptedSubmissionSecretKey The encrypted submission secret key encoded with base-64. 100 | * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. 101 | * @returns The decrypted content if successful. Else, null will be returned. 102 | */ 103 | decrypt = ( 104 | formSecretKey: string, 105 | decryptParams: DecryptParamsV3 106 | ): DecryptedContentV3 | null => { 107 | const { encryptedSubmissionSecretKey, ...rest } = decryptParams 108 | 109 | const submissionSecretKey = decryptContent( 110 | formSecretKey, 111 | encryptedSubmissionSecretKey 112 | ) 113 | 114 | if (submissionSecretKey === null) return null 115 | 116 | return this.decryptFromSubmissionKey( 117 | encodeBase64(submissionSecretKey), 118 | rest 119 | ) 120 | } 121 | 122 | /** 123 | * Returns true if a pair of public & secret keys are associated with each other 124 | * @param publicKey The public key to verify against. 125 | * @param secretKey The private key to verify against. 126 | */ 127 | valid = (publicKey: string, secretKey: string) => { 128 | const testResponse: FormFieldsV3 = {} 129 | const internalValidationVersion = 3 130 | 131 | const cipherResponse = this.encrypt(testResponse, publicKey) 132 | // Use toString here since the return should be an empty array. 133 | return ( 134 | testResponse.toString() === 135 | this.decrypt(secretKey, { 136 | ...cipherResponse, 137 | version: internalValidationVersion, 138 | })?.responses.toString() 139 | ) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/crypto.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import nacl from 'tweetnacl' 3 | import { decodeBase64, decodeUTF8, encodeUTF8 } from 'tweetnacl-util' 4 | 5 | import { 6 | areAttachmentFieldIdsValid, 7 | convertEncryptedAttachmentToFileContent, 8 | decryptContent, 9 | encryptMessage, 10 | verifySignedMessage, 11 | } from './util/crypto' 12 | import { determineIsFormFields } from './util/validate' 13 | import CryptoBase from './crypto-base' 14 | import { AttachmentDecryptionError, MissingPublicKeyError } from './errors' 15 | import { 16 | DecryptedAttachments, 17 | DecryptedContent, 18 | DecryptedContentAndAttachments, 19 | DecryptParams, 20 | EncryptedAttachmentContent, 21 | EncryptedAttachmentRecords, 22 | EncryptedContent, 23 | FormField, 24 | } from './types' 25 | 26 | export default class Crypto extends CryptoBase { 27 | signingPublicKey?: string 28 | 29 | constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) { 30 | super() 31 | this.signingPublicKey = signingPublicKey 32 | } 33 | 34 | /** 35 | * Encrypt input with a unique keypair for each submission 36 | * @param encryptionPublicKey The base-64 encoded public key for encrypting. 37 | * @param msg The message to encrypt, will be stringified. 38 | * @param signingPrivateKey Optional. Must be a base-64 encoded private key. If given, will be used to signing the given msg param prior to encrypting. 39 | * @returns The encrypted basestring. 40 | */ 41 | encrypt = ( 42 | msg: any, 43 | encryptionPublicKey: string, 44 | signingPrivateKey?: string 45 | ): EncryptedContent => { 46 | let processedMsg = decodeUTF8(JSON.stringify(msg)) 47 | 48 | if (signingPrivateKey) { 49 | processedMsg = nacl.sign(processedMsg, decodeBase64(signingPrivateKey)) 50 | } 51 | 52 | return encryptMessage(processedMsg, encryptionPublicKey) 53 | } 54 | 55 | /** 56 | * Decrypts an encrypted submission and returns it. 57 | * @param formSecretKey The base-64 secret key of the form to decrypt with. 58 | * @param decryptParams The params containing encrypted content and information. 59 | * @param decryptParams.encryptedContent The encrypted content encoded with base-64. 60 | * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. 61 | * @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message. 62 | * @returns The decrypted content if successful. Else, null will be returned. 63 | * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. 64 | */ 65 | decrypt = ( 66 | formSecretKey: string, 67 | decryptParams: DecryptParams 68 | ): DecryptedContent | null => { 69 | try { 70 | const { encryptedContent, verifiedContent } = decryptParams 71 | 72 | // Do not return the transformed object in `_decrypt` function as a signed 73 | // object is not encoded in UTF8 and is encoded in Base-64 instead. 74 | const decryptedContent = decryptContent(formSecretKey, encryptedContent) 75 | if (!decryptedContent) { 76 | throw new Error('Failed to decrypt content') 77 | } 78 | const decryptedObject: Record = JSON.parse( 79 | encodeUTF8(decryptedContent) 80 | ) 81 | if (!determineIsFormFields(decryptedObject)) { 82 | throw new Error('Decrypted object does not fit expected shape') 83 | } 84 | 85 | const returnedObject: DecryptedContent = { 86 | responses: decryptedObject, 87 | } 88 | 89 | if (verifiedContent) { 90 | if (!this.signingPublicKey) { 91 | throw new MissingPublicKeyError( 92 | 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' 93 | ) 94 | } 95 | // Only care if it is the correct shape if verifiedContent exists, since 96 | // we need to append it to the end. 97 | // Decrypted message must be able to be authenticated by the public key. 98 | const decryptedVerifiedContent = decryptContent( 99 | formSecretKey, 100 | verifiedContent 101 | ) 102 | if (!decryptedVerifiedContent) { 103 | // Returns null if decrypting verified content failed. 104 | throw new Error('Failed to decrypt verified content') 105 | } 106 | const decryptedVerifiedObject = verifySignedMessage( 107 | decryptedVerifiedContent, 108 | this.signingPublicKey 109 | ) 110 | 111 | returnedObject.verified = decryptedVerifiedObject 112 | } 113 | 114 | return returnedObject 115 | } catch (err) { 116 | // Should only throw if MissingPublicKeyError. 117 | // This library should be able to be used to encrypt and decrypt content 118 | // if the content does not contain verified fields. 119 | if (err instanceof MissingPublicKeyError) { 120 | throw err 121 | } 122 | return null 123 | } 124 | } 125 | 126 | /** 127 | * Returns true if a pair of public & secret keys are associated with each other 128 | * @param publicKey The public key to verify against. 129 | * @param secretKey The private key to verify against. 130 | */ 131 | valid = (publicKey: string, secretKey: string) => { 132 | const testResponse: FormField[] = [] 133 | const internalValidationVersion = 1 134 | 135 | const cipherResponse = this.encrypt(testResponse, publicKey) 136 | // Use toString here since the return should be an empty array. 137 | return ( 138 | testResponse.toString() === 139 | this.decrypt(secretKey, { 140 | encryptedContent: cipherResponse, 141 | version: internalValidationVersion, 142 | })?.responses.toString() 143 | ) 144 | } 145 | 146 | /** 147 | * Decrypts an encrypted submission, and also download and decrypt any attachments alongside it. 148 | * @param formSecretKey Secret key as a base-64 string 149 | * @param decryptParams The params containing encrypted content and information. 150 | * @returns A promise of the decrypted submission, including attachments (if any). Or else returns null if a decryption error decrypting any part of the submission. 151 | * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. 152 | */ 153 | decryptWithAttachments = async ( 154 | formSecretKey: string, 155 | decryptParams: DecryptParams 156 | ): Promise => { 157 | const decryptedRecords: DecryptedAttachments = {} 158 | const filenames: Record = {} 159 | 160 | const attachmentRecords: EncryptedAttachmentRecords = 161 | decryptParams.attachmentDownloadUrls ?? {} 162 | const decryptedContent = this.decrypt(formSecretKey, decryptParams) 163 | if (decryptedContent === null) return null 164 | 165 | // Retrieve all original filenames for attachments for easy lookup 166 | decryptedContent.responses.forEach((response) => { 167 | if (response.fieldType === 'attachment' && response.answer) { 168 | filenames[response._id] = response.answer 169 | } 170 | }) 171 | 172 | const fieldIds = Object.keys(attachmentRecords) 173 | // Check if all fieldIds are within filenames 174 | if (!areAttachmentFieldIdsValid(fieldIds, filenames)) { 175 | return null 176 | } 177 | 178 | const downloadPromises = fieldIds.map((fieldId) => { 179 | return ( 180 | axios 181 | // Retrieve all the attachments as JSON 182 | .get(attachmentRecords[fieldId], { 183 | responseType: 'json', 184 | }) 185 | // Decrypt all the attachments 186 | .then(({ data: downloadResponse }) => { 187 | const encryptedFile = 188 | convertEncryptedAttachmentToFileContent(downloadResponse) 189 | return this.decryptFile(formSecretKey, encryptedFile) 190 | }) 191 | .then((decryptedFile) => { 192 | // Check if the file exists and set the filename accordingly; otherwise, throw an error 193 | if (decryptedFile) { 194 | decryptedRecords[fieldId] = { 195 | filename: filenames[fieldId], 196 | content: decryptedFile, 197 | } 198 | } else { 199 | throw new AttachmentDecryptionError() 200 | } 201 | }) 202 | ) 203 | }) 204 | 205 | try { 206 | await Promise.all(downloadPromises) 207 | } catch { 208 | return null 209 | } 210 | 211 | return { 212 | content: decryptedContent, 213 | attachments: decryptedRecords, 214 | } 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/errors.ts: -------------------------------------------------------------------------------- 1 | class MissingSecretKeyError extends Error { 2 | constructor( 3 | message = 'Provide a secret key when initializing the FormSG SDK to use this function.' 4 | ) { 5 | super(message) 6 | this.name = this.constructor.name 7 | 8 | // Set the prototype explicitly. 9 | // See https://github.com/facebook/jest/issues/8279 10 | Object.setPrototypeOf(this, MissingSecretKeyError.prototype) 11 | } 12 | } 13 | class MissingPublicKeyError extends Error { 14 | constructor( 15 | message = 'Provide a public key when initializing the FormSG SDK to use this function.' 16 | ) { 17 | super(message) 18 | this.name = this.constructor.name 19 | 20 | // Set the prototype explicitly. 21 | // See https://github.com/facebook/jest/issues/8279 22 | Object.setPrototypeOf(this, MissingPublicKeyError.prototype) 23 | } 24 | } 25 | 26 | class WebhookAuthenticateError extends Error { 27 | constructor(message: string) { 28 | super(message) 29 | this.name = this.constructor.name 30 | // Set the prototype explicitly. 31 | // See https://github.com/facebook/jest/issues/8279 32 | Object.setPrototypeOf(this, WebhookAuthenticateError.prototype) 33 | } 34 | } 35 | 36 | class AttachmentDecryptionError extends Error { 37 | constructor(message = 'Attachment decryption with the given nonce failed.') { 38 | super(message) 39 | this.name = this.constructor.name 40 | } 41 | } 42 | 43 | export { 44 | MissingSecretKeyError, 45 | MissingPublicKeyError, 46 | WebhookAuthenticateError, 47 | AttachmentDecryptionError, 48 | } 49 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { getSigningPublicKey, getVerificationPublicKey } from './util/publicKey' 2 | import Crypto from './crypto' 3 | import CryptoV3 from './crypto-v3' 4 | import { PackageInitParams } from './types' 5 | import Verification from './verification' 6 | import Webhooks from './webhooks' 7 | 8 | /** 9 | * Entrypoint into the FormSG SDK 10 | * 11 | * @param {PackageInitParams} config Package initialization config parameters 12 | * @param {string?} [config.mode] Optional. Initializes public key used for verifying and decrypting in this package. If `config.signingPublicKey` is given, this param will be ignored. 13 | * @param {string?} [config.webhookSecretKey] Optional. base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. 14 | * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. 15 | */ 16 | export = function (config: PackageInitParams = {}) { 17 | const { webhookSecretKey, mode, verificationOptions } = config 18 | /** 19 | * Public key is used for decrypting signed verified content in the `crypto` module, and 20 | * also for verifying webhook signatures' authenticity in the `wehbooks` module. 21 | */ 22 | const signingPublicKey = getSigningPublicKey(mode || 'production') 23 | /** 24 | * Public key is used for verifying verified field signatures' authenticity in the `verification` module. 25 | */ 26 | const verificationPublicKey = getVerificationPublicKey(mode || 'production') 27 | 28 | return { 29 | webhooks: new Webhooks({ 30 | publicKey: signingPublicKey, 31 | secretKey: webhookSecretKey, 32 | }), 33 | crypto: new Crypto({ signingPublicKey }), 34 | cryptoV3: new CryptoV3(), 35 | verification: new Verification({ 36 | publicKey: verificationPublicKey, 37 | secretKey: verificationOptions?.secretKey, 38 | transactionExpiry: verificationOptions?.transactionExpiry, 39 | }), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/resource/signing-keys.ts: -------------------------------------------------------------------------------- 1 | // keys generated using nacl.sign.keyPair() from tweetnacl 2 | export const SIGNING_KEYS = { 3 | staging: { 4 | // staging must never contain secret keys 5 | publicKey: 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=', 6 | }, 7 | development: { 8 | publicKey: 'Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE=', 9 | secretKey: 'HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ==', 10 | }, 11 | production: { 12 | // production must never contain secret keys 13 | publicKey: '3Tt8VduXsjjd4IrpdCd7BAkdZl/vUCstu9UvTX84FWw=', 14 | }, 15 | test: { 16 | publicKey: 'KUY1XT30ar+XreVjsS1w/c3EpDs2oASbF6G3evvaUJM=', 17 | secretKey: 18 | '/u+LP57Ib9y5Ytpud56FzuitSC9O6lJ4EOLOFHpsHlYpRjVdPfRqv5et5WOxLXD9zcSkOzagBJsXobd6+9pQkw==', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/resource/verification-keys.ts: -------------------------------------------------------------------------------- 1 | // keys generated using nacl.sign.keyPair() from tweetnacl 2 | export const VERIFICATION_KEYS = { 3 | staging: { 4 | // staging must never contain secret keys 5 | publicKey: 'bDgK1223JbrDNePFIrj7b0z02Z5nSiBzkRYRqDdVPfA=', 6 | }, 7 | development: { 8 | publicKey: 'SZ4pV0JXgj8dhFU69uHllqYcxTtliYmi+d6Ml56lnQU=', 9 | secretKey: 'iGkfOuI6uxrlfw+7CZFFUZBwk86I+pu6v+g7EWA6qJpJnilXQleCPx2EVTr24eWWphzFO2WJiaL53oyXnqWdBQ==', 10 | }, 11 | production: { 12 | // production must never contain secret keys 13 | publicKey: 'W/lf24kRJ9PVvSK1Ubjjhc9zHjp1amKr+3Q+Nmsy4w4=', 14 | }, 15 | test: { 16 | publicKey: 'ileDo328P/UApBPANuS/xO6P4BuHSgPnjRRBifgQYvs=', 17 | secretKey: 18 | 'zLnXIV0cGjODell5w1usEHcGOJ/xsQDuDOw2BPcPEQOKV4Ojfbw/9QCkE8A25L/E7o/gG4dKA+eNFEGJ+BBi+w==', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type PackageInitParams = { 2 | /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ 3 | webhookSecretKey?: string 4 | /** If provided, enables the usage of the verification module. */ 5 | verificationOptions?: VerificationOptions 6 | /** Initializes public key used for verifying and decrypting in this package. If not given, will default to "production". */ 7 | mode?: PackageMode 8 | } 9 | 10 | // A field type available in FormSG as a string 11 | export type FieldType = 12 | | 'section' 13 | | 'radiobutton' 14 | | 'dropdown' 15 | | 'checkbox' 16 | | 'nric' 17 | | 'email' 18 | | 'table' 19 | | 'number' 20 | | 'rating' 21 | | 'yes_no' 22 | | 'decimal' 23 | | 'textfield' // Short Text 24 | | 'textarea' // Long Text 25 | | 'attachment' 26 | | 'date' 27 | | 'mobile' 28 | | 'homeno' 29 | | 'statement' 30 | | 'image' 31 | | 'country_region' 32 | | 'uen' 33 | | 'children' 34 | | 'address' 35 | 36 | // Represents form field responses in a form. 37 | export type FormField = { 38 | _id: string 39 | question: string 40 | fieldType: FieldType 41 | isHeader?: boolean 42 | signature?: string 43 | } & ( 44 | | { answer: string; answerArray?: never } 45 | | { answer?: never; answerArray: string[] | string[][] } 46 | ) 47 | 48 | // Represents form field responses in a form. 49 | export type FormFieldsV3 = Record< 50 | string, 51 | { 52 | fieldType: FieldType 53 | answer: any // too complex to represent here 54 | } 55 | > 56 | 57 | // Encrypted basestring containing the submission public key, 58 | // nonce and encrypted data in base-64. 59 | // A string in the format of 60 | // ;: 61 | export type EncryptedContent = string 62 | 63 | export type EncryptedContentV3 = { 64 | submissionPublicKey: string 65 | submissionSecretKey: string 66 | encryptedContent: EncryptedContent 67 | encryptedSubmissionSecretKey: EncryptedContent 68 | } 69 | 70 | // Records containing a map of field IDs to URLs where encrypted 71 | // attachments can be downloaded. 72 | export type EncryptedAttachmentRecords = Record 73 | 74 | export interface DecryptParams { 75 | encryptedContent: EncryptedContent 76 | version: number 77 | verifiedContent?: EncryptedContent 78 | attachmentDownloadUrls?: EncryptedAttachmentRecords 79 | } 80 | 81 | export interface DecryptParamsV3 { 82 | encryptedContent: EncryptedContent 83 | encryptedSubmissionSecretKey: EncryptedContent 84 | version: number 85 | } 86 | 87 | export type DecryptedContent = { 88 | responses: FormField[] 89 | verified?: Record 90 | } 91 | 92 | export type DecryptedContentV3 = { 93 | submissionSecretKey: string 94 | responses: FormFieldsV3 95 | } 96 | 97 | export type DecryptedFile = { 98 | filename: string 99 | content: Uint8Array 100 | } 101 | 102 | // Records containing a map of field IDs to DecryptedFiles. 103 | export type DecryptedAttachments = Record 104 | 105 | export type DecryptedContentAndAttachments = { 106 | content: DecryptedContent 107 | attachments: DecryptedAttachments 108 | } 109 | 110 | export type EncryptedFileContent = { 111 | submissionPublicKey: string 112 | nonce: string 113 | binary: Uint8Array 114 | } 115 | 116 | export type EncryptedAttachmentContent = { 117 | encryptedFile: { 118 | submissionPublicKey: string 119 | nonce: string 120 | binary: string 121 | } 122 | } 123 | 124 | // A base-64 encoded cryptographic keypair suitable for curve25519. 125 | export type Keypair = { 126 | publicKey: string 127 | secretKey: string 128 | } 129 | 130 | export type PackageMode = 'staging' | 'production' | 'development' | 'test' 131 | 132 | export type VerificationOptions = { 133 | publicKey?: string 134 | secretKey?: string 135 | transactionExpiry?: number 136 | } 137 | 138 | // A verified answer contains a field ID and answer 139 | export type VerifiedAnswer = { 140 | fieldId: string 141 | answer: string 142 | } 143 | 144 | // Add the transaction ID and form ID to a VerifiedAnswer to obtain a signature 145 | export type VerificationSignatureOptions = VerifiedAnswer & { 146 | transactionId: string 147 | formId: string 148 | } 149 | 150 | // Creating a basestring requires the epoch in addition to signature requirements 151 | export type VerificationBasestringOptions = VerificationSignatureOptions & { 152 | time: number 153 | } 154 | 155 | // Authenticate a VerifiedAnswer with a signatureString and epoch 156 | export type VerificationAuthenticateOptions = VerifiedAnswer & { 157 | signatureString: string 158 | submissionCreatedAt: number 159 | } 160 | -------------------------------------------------------------------------------- /src/util/crypto.ts: -------------------------------------------------------------------------------- 1 | import nacl from 'tweetnacl' 2 | import { decodeBase64, encodeBase64, encodeUTF8 } from 'tweetnacl-util' 3 | 4 | import { 5 | EncryptedAttachmentContent, 6 | EncryptedContent, 7 | EncryptedFileContent, 8 | Keypair, 9 | } from '../types' 10 | 11 | /** 12 | * Helper method to generate a new keypair for encryption. 13 | * @returns The generated keypair. 14 | */ 15 | export const generateKeypair = (): Keypair => { 16 | const kp = nacl.box.keyPair() 17 | return { 18 | publicKey: encodeBase64(kp.publicKey), 19 | secretKey: encodeBase64(kp.secretKey), 20 | } 21 | } 22 | 23 | /** 24 | * Helper function to encrypt input with a unique keypair for each submission. 25 | * @param msg The message to encrypt 26 | * @param theirPublicKey The base-64 encoded public key 27 | * @returns The encrypted basestring 28 | * @throws error if any of the encrypt methods fail 29 | */ 30 | export const encryptMessage = ( 31 | msg: Uint8Array, 32 | theirPublicKey: string 33 | ): EncryptedContent => { 34 | const submissionKeypair = generateKeypair() 35 | const nonce = nacl.randomBytes(24) 36 | const encrypted = encodeBase64( 37 | nacl.box( 38 | msg, 39 | nonce, 40 | decodeBase64(theirPublicKey), 41 | decodeBase64(submissionKeypair.secretKey) 42 | ) 43 | ) 44 | return `${submissionKeypair.publicKey};${encodeBase64(nonce)}:${encrypted}` 45 | } 46 | 47 | /** 48 | * Helper method to decrypt an encrypted submission. 49 | * @param formPrivateKey base64 50 | * @param encryptedContent encrypted string encoded in base64 51 | * @return The decrypted content, or null if decryption failed. 52 | */ 53 | export const decryptContent = ( 54 | formPrivateKey: string, 55 | encryptedContent: EncryptedContent 56 | ): Uint8Array | null => { 57 | try { 58 | const [submissionPublicKey, nonceEncrypted] = encryptedContent.split(';') 59 | const [nonce, encrypted] = nonceEncrypted.split(':').map(decodeBase64) 60 | return nacl.box.open( 61 | encrypted, 62 | nonce, 63 | decodeBase64(submissionPublicKey), 64 | decodeBase64(formPrivateKey) 65 | ) 66 | } catch (err) { 67 | return null 68 | } 69 | } 70 | 71 | /** 72 | * Helper method to verify a signed message. 73 | * @param msg the message to verify 74 | * @param publicKey the public key to authenticate the signed message with 75 | * @returns the signed message if successful, else an error will be thrown 76 | * @throws {Error} if the message cannot be verified 77 | */ 78 | export const verifySignedMessage = ( 79 | msg: Uint8Array, 80 | publicKey: string 81 | ): Record => { 82 | const openedMessage = nacl.sign.open(msg, decodeBase64(publicKey)) 83 | if (!openedMessage) 84 | throw new Error('Failed to open signed message with given public key') 85 | return JSON.parse(encodeUTF8(openedMessage)) 86 | } 87 | 88 | /** 89 | * Helper method to check if all the field IDs given are within the filenames 90 | * @param fieldIds the list of fieldIds to check 91 | * @param filenames the filenames that should contain the fields 92 | * @returns boolean indicating whether the fields are valid 93 | */ 94 | export const areAttachmentFieldIdsValid = ( 95 | fieldIds: string[], 96 | filenames: Record 97 | ): boolean => { 98 | return fieldIds.every((fieldId) => filenames[fieldId]) 99 | } 100 | 101 | /** 102 | * Converts an encrypted attachment to encrypted file content 103 | * @param encryptedAttachment The encrypted attachment 104 | * @returns EncryptedFileContent The encrypted file content 105 | */ 106 | export const convertEncryptedAttachmentToFileContent = ( 107 | encryptedAttachment: EncryptedAttachmentContent 108 | ): EncryptedFileContent => ({ 109 | submissionPublicKey: encryptedAttachment.encryptedFile.submissionPublicKey, 110 | nonce: encryptedAttachment.encryptedFile.nonce, 111 | binary: decodeBase64(encryptedAttachment.encryptedFile.binary), 112 | }) 113 | -------------------------------------------------------------------------------- /src/util/parser.ts: -------------------------------------------------------------------------------- 1 | // The constituents of the X-FormSG-Signature 2 | export type HeaderSignature = { 3 | // The ed25519 signature 4 | v1: string 5 | // The epoch used for signing, number of milliseconds since Jan 1, 1970 6 | t: number 7 | // The submission ID, usually the MongoDB submission ObjectId 8 | s: string 9 | // The form ID, usually the MongoDB form ObjectId 10 | f: string 11 | } 12 | 13 | // The constituents of the verification signature 14 | // Note that even though this is similar to HeaderSignature, the keys do not 15 | // mean the same thing. 16 | export type VerificationSignature = { 17 | // The transaction id. 18 | v: string 19 | // The epoch used for signing, number of milliseconds since Jan 1, 1970 20 | t: number 21 | // The signature component. 22 | s: string 23 | // The form ID, usually the MongoDB form ObjectId 24 | f: string 25 | } 26 | 27 | /** 28 | * Helper function to retrieve keys-values in a signature. 29 | * @param signature The signature to convert to a keymap 30 | * @returns The key-value map of the signature 31 | */ 32 | const signatureToKeyMap = (signature: string) => { 33 | return signature 34 | .split(',') 35 | .map((kv) => kv.split(/=(.*)/)) 36 | .reduce((acc: Record, [k, v]) => { 37 | acc[k] = v 38 | return acc 39 | }, {}) 40 | } 41 | 42 | /** 43 | * Parses the X-FormSG-Signature header into its constituents 44 | * @param header The X-FormSG-Signature header 45 | * @returns The signature header constituents 46 | */ 47 | export const parseSignatureHeader = (header: string): HeaderSignature => { 48 | const parsedSignature = signatureToKeyMap(header) as Record< 49 | string, 50 | string | number 51 | > 52 | 53 | parsedSignature.t = Number(parsedSignature.t) 54 | 55 | return parsedSignature as HeaderSignature 56 | } 57 | 58 | /** 59 | * Parses the verification signature into its constituent 60 | * @param signature The verification signature 61 | * @returns The verification signature constituents 62 | */ 63 | export const parseVerificationSignature = ( 64 | signature: string 65 | ): VerificationSignature => { 66 | const parsedSignature = signatureToKeyMap(signature) as Record< 67 | string, 68 | string | number 69 | > 70 | 71 | parsedSignature.t = Number(parsedSignature.t) 72 | 73 | return parsedSignature as VerificationSignature 74 | } 75 | -------------------------------------------------------------------------------- /src/util/publicKey.ts: -------------------------------------------------------------------------------- 1 | import { SIGNING_KEYS } from '../resource/signing-keys' 2 | import { VERIFICATION_KEYS } from '../resource/verification-keys' 3 | import { PackageMode } from '../types' 4 | 5 | import STAGE from './stage' 6 | 7 | /** 8 | * Retrieves the appropriate signing public key. 9 | * Defaults to production. 10 | * @param mode The package mode to retrieve the public key for. 11 | */ 12 | function getSigningPublicKey(mode?: PackageMode) { 13 | switch (mode) { 14 | case STAGE.development: 15 | return SIGNING_KEYS.development.publicKey 16 | case STAGE.staging: 17 | return SIGNING_KEYS.staging.publicKey 18 | case STAGE.test: 19 | return SIGNING_KEYS.test.publicKey 20 | default: 21 | return SIGNING_KEYS.production.publicKey 22 | } 23 | } 24 | 25 | /** 26 | * Retrieves the appropriate verification public key. 27 | * Defaults to production. 28 | * @param mode The package mode to retrieve the public key for. 29 | */ 30 | function getVerificationPublicKey(mode?: PackageMode) { 31 | switch (mode) { 32 | case STAGE.development: 33 | return VERIFICATION_KEYS.development.publicKey 34 | case STAGE.staging: 35 | return VERIFICATION_KEYS.staging.publicKey 36 | case STAGE.test: 37 | return VERIFICATION_KEYS.test.publicKey 38 | default: 39 | return VERIFICATION_KEYS.production.publicKey 40 | } 41 | } 42 | 43 | export { getSigningPublicKey, getVerificationPublicKey } 44 | -------------------------------------------------------------------------------- /src/util/signature.ts: -------------------------------------------------------------------------------- 1 | import * as tweetnacl from 'tweetnacl' 2 | import { decodeBase64, decodeUTF8, encodeBase64 } from 'tweetnacl-util' 3 | 4 | /** 5 | * Returns a signature from a basestring and secret key 6 | * @param basestring The data you want to sign. 7 | * @param secretKey 64-byte secret key in base64 encoding. 8 | * @return base64 encoded signature 9 | */ 10 | function sign(basestring: string, secretKey: string): string { 11 | return encodeBase64( 12 | tweetnacl.sign.detached(decodeUTF8(basestring), decodeBase64(secretKey)) 13 | ) 14 | } 15 | 16 | /** 17 | * Verifies a signature against a message and public key 18 | * @param message The message to verify 19 | * @param signature The base64 encoded signature generated from sign() 20 | * @param publicKey 32-byte public key in base64 encoding 21 | * @return True if verification checks out, false otherwise 22 | */ 23 | function verify( 24 | message: string, 25 | signature: string, 26 | publicKey: string 27 | ): boolean { 28 | return tweetnacl.sign.detached.verify( 29 | decodeUTF8(message), 30 | decodeBase64(signature), 31 | decodeBase64(publicKey) 32 | ) 33 | } 34 | 35 | export { sign, verify } 36 | -------------------------------------------------------------------------------- /src/util/stage.ts: -------------------------------------------------------------------------------- 1 | import { PackageMode } from '../types' 2 | 3 | const STAGE: { [stage in PackageMode]: stage } = { 4 | staging: 'staging', 5 | production: 'production', 6 | development: 'development', 7 | test: 'test', 8 | } 9 | 10 | export default STAGE 11 | -------------------------------------------------------------------------------- /src/util/validate.ts: -------------------------------------------------------------------------------- 1 | import { FormField, FormFieldsV3 } from '../types' 2 | 3 | function determineIsFormFields(tbd: any): tbd is FormField[] { 4 | if (!Array.isArray(tbd)) { 5 | return false 6 | } 7 | 8 | // If there exists even a single internal response that does not fit the 9 | // shape, the object is not created properly. 10 | const filter = tbd.filter( 11 | (internal) => 12 | // Have either answer or answerArray or is isHeader 13 | // Since empty strings are allowed, check using typeof. 14 | (typeof internal.answer === 'string' || 15 | Array.isArray(internal.answerArray) || 16 | internal.isHeader) && 17 | internal._id && 18 | internal.fieldType && 19 | // The field is still valid even when the question title is empty string 20 | // (even though it is not intended behavior). 21 | typeof internal.question === 'string' 22 | ) 23 | 24 | return filter.length === tbd.length 25 | } 26 | 27 | // TODO(MRF): This is currently very rudimentary, we should look at making this more specific where required. 28 | function determineIsFormFieldsV3(tbd: any): tbd is FormFieldsV3 { 29 | for (const id of Object.keys(tbd)) { 30 | const value = tbd[id] 31 | const hasCorrectShape = value.fieldType && value.answer !== undefined 32 | if (!hasCorrectShape) return false 33 | } 34 | return true 35 | } 36 | 37 | export { determineIsFormFields, determineIsFormFieldsV3 } 38 | -------------------------------------------------------------------------------- /src/util/webhooks.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'url' 2 | 3 | import { WebhookAuthenticateError } from '../errors' 4 | 5 | import { HeaderSignature } from './parser' 6 | import { verify } from './signature' 7 | 8 | /** 9 | * Helper function to construct the basestring and verify the signature of an 10 | * incoming request 11 | * @param uri incoming request to verify 12 | * @param signatureHeader the X-FormSG-Signature header to verify against 13 | * @returns true if verification succeeds, false otherwise 14 | * @throws {WebhookAuthenticateError} if given signature header is malformed. 15 | */ 16 | const isSignatureHeaderValid = ( 17 | uri: string, 18 | signatureHeader: HeaderSignature, 19 | publicKey: string 20 | ) => { 21 | const { 22 | v1: signature, 23 | t: epoch, 24 | s: submissionId, 25 | f: formId, 26 | } = signatureHeader 27 | 28 | if (!epoch || !signature || !submissionId || !formId) { 29 | throw new WebhookAuthenticateError('X-FormSG-Signature header is invalid') 30 | } 31 | 32 | const baseString = `${url.parse(uri).href}.${submissionId}.${formId}.${epoch}` 33 | return verify(baseString, signature, publicKey) 34 | } 35 | 36 | /** 37 | * Helper function to verify that the epoch submitted is recent and valid. 38 | * Prevents against replay attacks. Allows for negative time interval 39 | * in case of clock drift between Form servers and recipient server. 40 | * @param epoch The number of milliseconds since 1 Jan 1970 00:00:00 UTC. 41 | * @param expiry Duration of expiry in milliseconds. The default is 5 minutes. 42 | * @returns true if the epoch given has exceeded expiry duration calculated from current time. 43 | */ 44 | const hasEpochExpired = (epoch: number, expiry = 300000) => { 45 | const difference = Math.abs(Date.now() - epoch) 46 | return difference > expiry 47 | } 48 | 49 | export { isSignatureHeaderValid, hasEpochExpired } 50 | -------------------------------------------------------------------------------- /src/verification/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @file Manages verification of otp form fields (email, sms, whatsapp) 3 | * @author Jean Tan 4 | */ 5 | import nacl from 'tweetnacl' 6 | import { decodeBase64, decodeUTF8, encodeBase64 } from 'tweetnacl-util' 7 | 8 | import { MissingPublicKeyError, MissingSecretKeyError } from '../errors' 9 | import { 10 | VerificationAuthenticateOptions, 11 | VerificationOptions, 12 | VerificationSignatureOptions, 13 | } from '../types' 14 | import { parseVerificationSignature } from '../util/parser' 15 | 16 | import { formatToBaseString, isSignatureTimeValid } from './utils' 17 | 18 | export default class Verification { 19 | verificationPublicKey?: string 20 | verificationSecretKey?: string 21 | transactionExpiry?: number 22 | 23 | constructor(params?: VerificationOptions) { 24 | this.verificationPublicKey = params?.publicKey 25 | this.verificationSecretKey = params?.secretKey 26 | this.transactionExpiry = params?.transactionExpiry 27 | } 28 | 29 | /** 30 | * Verifies signature 31 | * @param {object} data 32 | * @param {string} data.signatureString 33 | * @param {number} data.submissionCreatedAt date in milliseconds 34 | * @param {string} data.fieldId 35 | * @param {string} data.answer 36 | * @param {string} data.publicKey 37 | */ 38 | authenticate = ({ 39 | signatureString, 40 | submissionCreatedAt, 41 | fieldId, 42 | answer, 43 | }: VerificationAuthenticateOptions) => { 44 | if (!this.transactionExpiry) { 45 | throw new Error( 46 | 'Provide a transaction expiry when when initializing the FormSG SDK to use this function.' 47 | ) 48 | } 49 | 50 | if (!this.verificationPublicKey) { 51 | throw new MissingPublicKeyError() 52 | } 53 | 54 | try { 55 | const { 56 | v: transactionId, 57 | t: time, 58 | f: formId, 59 | s: signature, 60 | } = parseVerificationSignature(signatureString) 61 | 62 | if (!time) { 63 | throw new Error('Malformed signature string was passed into function') 64 | } 65 | 66 | if ( 67 | isSignatureTimeValid(time, submissionCreatedAt, this.transactionExpiry) 68 | ) { 69 | const data = formatToBaseString({ 70 | transactionId, 71 | formId, 72 | fieldId, 73 | answer, 74 | time, 75 | }) 76 | 77 | return nacl.sign.detached.verify( 78 | decodeUTF8(data), 79 | decodeBase64(signature), 80 | decodeBase64(this.verificationPublicKey) 81 | ) 82 | } else { 83 | console.info( 84 | `Signature was expired for signatureString="${signatureString}" signatureDate="${time}" submissionCreatedAt="${submissionCreatedAt}"` 85 | ) 86 | return false 87 | } 88 | } catch (error) { 89 | console.error(`An error occurred for \ 90 | signatureString="${signatureString}" \ 91 | submissionCreatedAt="${submissionCreatedAt}" \ 92 | fieldId="${fieldId}" \ 93 | answer="${answer}" \ 94 | error="${error}"`) 95 | return false 96 | } 97 | } 98 | 99 | generateSignature = ({ 100 | transactionId, 101 | formId, 102 | fieldId, 103 | answer, 104 | }: VerificationSignatureOptions): string => { 105 | if (!this.verificationSecretKey) { 106 | throw new MissingSecretKeyError( 107 | 'Provide a secret key when when initializing the Verification class to use this function.' 108 | ) 109 | } 110 | 111 | const time = Date.now() 112 | const data = formatToBaseString({ 113 | transactionId, 114 | formId, 115 | fieldId, 116 | answer, 117 | time, 118 | }) 119 | const signature = nacl.sign.detached( 120 | decodeUTF8(data), 121 | decodeBase64(this.verificationSecretKey) 122 | ) 123 | return `f=${formId},v=${transactionId},t=${time},s=${encodeBase64( 124 | signature 125 | )}` 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/verification/utils.ts: -------------------------------------------------------------------------------- 1 | import { VerificationBasestringOptions } from '../types' 2 | 3 | /** 4 | * Checks if signature was made within the given expiry range before 5 | * submission was created. 6 | * @param signatureTime ms 7 | * @param submissionCreatedAt ms 8 | */ 9 | export const isSignatureTimeValid = ( 10 | signatureTime: number, 11 | submissionCreatedAt: number, 12 | transactionExpiry: number 13 | ): boolean => { 14 | const maxTime = submissionCreatedAt 15 | const minTime = maxTime - transactionExpiry * 1000 16 | return signatureTime > minTime && signatureTime < maxTime 17 | } 18 | 19 | /** 20 | * Formats given data into a string for signing 21 | */ 22 | export const formatToBaseString = ({ 23 | transactionId, 24 | formId, 25 | fieldId, 26 | answer, 27 | time, 28 | }: VerificationBasestringOptions): string => { 29 | return `${transactionId}.${formId}.${fieldId}.${answer}.${time}` 30 | } 31 | -------------------------------------------------------------------------------- /src/webhooks.ts: -------------------------------------------------------------------------------- 1 | import * as url from 'url' 2 | 3 | import { parseSignatureHeader } from './util/parser' 4 | import { sign } from './util/signature' 5 | import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' 6 | import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' 7 | 8 | export default class Webhooks { 9 | publicKey: string 10 | secretKey?: string 11 | 12 | constructor({ 13 | publicKey, 14 | secretKey, 15 | }: { 16 | publicKey: string 17 | secretKey?: string 18 | }) { 19 | this.publicKey = publicKey 20 | this.secretKey = secretKey 21 | } 22 | 23 | /** 24 | * Injects the webhook public key for authentication 25 | * @param header X-FormSG-Signature header 26 | * @param uri The endpoint that FormSG is POSTing to 27 | * @returns true if the header is verified 28 | * @throws {WebhookAuthenticateError} If the signature or uri cannot be verified 29 | */ 30 | authenticate = (header: string, uri: string) => { 31 | // Parse the header 32 | const signatureHeader = parseSignatureHeader(header) 33 | const { 34 | v1: signature, 35 | t: epoch, 36 | s: submissionId, 37 | f: formId, 38 | } = signatureHeader 39 | 40 | // Verify signature authenticity 41 | if (!isSignatureHeaderValid(uri, signatureHeader, this.publicKey)) { 42 | throw new WebhookAuthenticateError( 43 | `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` 44 | ) 45 | } 46 | 47 | // Verify epoch recency 48 | if (hasEpochExpired(epoch)) { 49 | throw new WebhookAuthenticateError( 50 | `Signature is not recent for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` 51 | ) 52 | } 53 | 54 | // All checks pass. 55 | return true 56 | } 57 | 58 | /** 59 | * Generates a signature based on the URI, submission ID and epoch timestamp. 60 | * @param params The parameters needed to generate the signature 61 | * @param params.uri Full URL of the request 62 | * @param params.submissionId Submission Mongo ObjectId saved to the database 63 | * @param params.epoch Number of milliseconds since Jan 1, 1970 64 | * @returns the generated signature 65 | * @throws {MissingSecretKeyError} if a secret key is not provided when instantiating this class 66 | * @throws {TypeError} if any parameters are undefined 67 | */ 68 | generateSignature = ({ 69 | uri, 70 | submissionId, 71 | formId, 72 | epoch, 73 | }: { 74 | uri: string 75 | submissionId: string 76 | formId: string 77 | epoch: number 78 | }) => { 79 | if (!this.secretKey) { 80 | throw new MissingSecretKeyError() 81 | } 82 | 83 | if (!submissionId || !uri || !formId || !epoch) { 84 | throw new TypeError( 85 | 'submissionId, uri, formId, or epoch must be provided to generate a webhook signature' 86 | ) 87 | } 88 | 89 | const baseString = `${ 90 | url.parse(uri).href 91 | }.${submissionId}.${formId}.${epoch}` 92 | return sign(baseString, this.secretKey) 93 | } 94 | 95 | /** 96 | * Constructs the `X-FormSG-Signature` header 97 | * @param params The parameters needed to construct the header 98 | * @param params.epoch Epoch timestamp 99 | * @param params.submissionId Mongo ObjectId 100 | * @param params.formId Mongo ObjectId 101 | * @param params.signature A signature generated by the generateSignature() function 102 | * @returns The `X-FormSG-Signature` header 103 | * @throws {Error} if a secret key is not provided when instantiating this class 104 | */ 105 | constructHeader = ({ 106 | epoch, 107 | submissionId, 108 | formId, 109 | signature, 110 | }: { 111 | epoch: number 112 | submissionId: string 113 | formId: string 114 | signature: string 115 | }) => { 116 | if (!this.secretKey) { 117 | throw new MissingSecretKeyError() 118 | } 119 | 120 | return `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "outDir": "./dist", 8 | "strict": true, 9 | "types": ["jest", "node"], 10 | "esModuleInterop": true, 11 | "rootDir": "src", 12 | "lib": ["WebWorker"] 13 | }, 14 | "include": ["src"], 15 | "exclude": ["node_modules", "**/*.spec.ts", "dist"] 16 | } 17 | --------------------------------------------------------------------------------