diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43d2d3d..e2339dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,8 @@ jobs: ${{ runner.OS }}- - run: npm ci - run: npm run test-ci + env: + NODE_OPTIONS: '--max-old-space-size=8192' - name: Submit test coverage to Coveralls uses: coverallsapp/github-action@v1.1.2 with: diff --git a/jest.config.js b/jest.config.js index dae6ede..f01ddfe 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,6 @@ module.exports = { global: { statements: 85, functions: 80, - } - } + }, + }, } diff --git a/package.json b/package.json index f33d8c5..1c1ad93 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "test": "jest", + "test": "NODE_OPTIONS=\"--max-old-space-size=8192\" jest", "test-ci": "jest --coverage", "test-watch": "jest --watch", "build": "tsc", diff --git a/spec/crypto-v3.spec.ts b/spec/crypto-v3.spec.ts new file mode 100644 index 0000000..25ebaa4 --- /dev/null +++ b/spec/crypto-v3.spec.ts @@ -0,0 +1,129 @@ +import mockAxios from 'jest-mock-axios' +import { + plaintext, + ciphertext, + formPublicKey, + formSecretKey, + submissionSecretKey +} from './resources/crypto-v3-data-20231207' +import CryptoV3 from '../src/crypto-v3' + +const INTERNAL_TEST_VERSION = 3 + +const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg')) + +jest.mock('axios', () => mockAxios) + +describe('CryptoV3', function () { + afterEach(() => mockAxios.reset()) + + const crypto = new CryptoV3() + + it('should generate a keypair', () => { + const keypair = crypto.generate() + expect(keypair).toHaveProperty('secretKey') + expect(keypair).toHaveProperty('publicKey') + }) + + it('should generate a keypair that is valid', () => { + const { publicKey, secretKey } = crypto.generate() + expect(crypto.valid(publicKey, secretKey)).toBe(true) + }) + + it('should validate an existing keypair', () => { + expect(crypto.valid(formPublicKey, formSecretKey)).toBe(true) + }) + + it('should invalidate unassociated keypairs', () => { + // Act + const { secretKey } = crypto.generate() + const { publicKey } = crypto.generate() + + // Assert + expect(crypto.valid(publicKey, secretKey)).toBe(false) + }) + + it('should return null on unsuccessful decryption from form secret key', () => { + expect( + crypto.decrypt('random', { + ...ciphertext, + version: INTERNAL_TEST_VERSION, + }) + ).toBe(null) + }) + + it('should return null when successfully decrypted content from form secret key does not fit FormFieldV3 type shape', () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + const malformedContent = 'just a string, not an object with FormField shape' + const malformedEncrypt = crypto.encrypt(malformedContent, publicKey) + + // Assert + // Using correct secret key, but the decrypted object should not fit the + // expected shape and thus return null. + expect( + crypto.decrypt(secretKey, { + ...malformedEncrypt, + version: INTERNAL_TEST_VERSION, + }) + ).toBe(null) + }) + + it('should be able to encrypt and decrypt submissions from 2023-12-07 end-to-end successfully from the form private key', () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + + // Act + const ciphertext = crypto.encrypt(plaintext, publicKey) + const decrypted = crypto.decrypt(secretKey, { + ...ciphertext, + version: INTERNAL_TEST_VERSION, + }) + // Assert + expect(decrypted).toHaveProperty('responses', plaintext) + }) + + it('should be able to decrypt submissions from 2023-12-07 from the submission private key', () => { + // Act + const decrypted = crypto.decryptFromSubmissionKey(submissionSecretKey, { + encryptedContent: ciphertext.encryptedContent, + version: INTERNAL_TEST_VERSION, + }) + // Assert + expect(decrypted).toHaveProperty('responses', plaintext) + }) + + it('should be able to encrypt and decrypt files end-to-end', async () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + + // Act + // Encrypt + const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) + expect(encrypted).toHaveProperty('submissionPublicKey') + expect(encrypted).toHaveProperty('nonce') + expect(encrypted).toHaveProperty('binary') + + // Decrypt + const decrypted = await crypto.decryptFile(secretKey, encrypted) + + if (!decrypted) { + throw new Error('File should be able to decrypt successfully.') + } + + // Compare + expect(testFileBuffer).toEqual(decrypted) + }) + + it('should return null if file could not be decrypted', async () => { + const { publicKey, secretKey } = crypto.generate() + + const encrypted = await crypto.encryptFile(testFileBuffer, publicKey) + // Rewrite binary with invalid Uint8Array. + encrypted.binary = new Uint8Array([1, 2]) + + const decrypted = await crypto.decryptFile(secretKey, encrypted) + + expect(decrypted).toBeNull() + }) +}) diff --git a/spec/resources/crypto-v3-data-20231207.ts b/spec/resources/crypto-v3-data-20231207.ts new file mode 100644 index 0000000..7cd9149 --- /dev/null +++ b/spec/resources/crypto-v3-data-20231207.ts @@ -0,0 +1,52 @@ +/** + * DO NOT MODIFY THE DATA BELOW. + * + * The below data represents a submission from 2023-12-07. + * It must remain unmodified to maintain strict backwards compatibility. + * + * If changes are necessary, create new test data instead. + */ + +const plaintext = { + '5e7479a086eaf2002488a20e': { + fieldType: 'email', + answer: 'test@open.gov.sg', + }, + '5e771c246b3c5100240368d8': { + fieldType: 'mobile', + answer: '+6598765432', + }, + '5e7479a386eaf2002488a20f': { + fieldType: 'number', + answer: '123', + }, + '5e771c8a6b3c5100240368e1': { + fieldType: 'radiobutton', + answer: { value: 'Option 1' }, + }, + '5e771c7a6b3c5100240368e0': { + fieldType: 'checkbox', + answer: { value: ['Option 2'], othersInput: 'Another answer' }, + } +} + +const ciphertext = { + encryptedContent: + 'yUW5li4+IA9q2/n3ZS+5+wrXQ8mKGrFJ1KW9Kf/eRzc=;PgZE8+y8rBvssnqLnqjnnqHDW6PngYKK:eIEuOUQjf1YkQIulZ7bCKXIl6wByg644Ulk/LjhefmLzhkVmXbTxBJVKVG6YgV0ZMcG4JPUuQ+WOW+N1/AOyL/8DJqclX74kG6s0DNXIJixkqNZCnfZapulerR9XXKSfwBjpo1nK25KCg32F/ey2HypPcluGV19hWwgj80mlms7Ya7x1X5wcdttlGrzGEnNH2VEPXjzJZHqiV1TWoQGwxSZ753fpkHUkBeKFA1UkMHS5XYnWyYD48JpfpOAz0L2ti6RHQnQLSKUHscYVfAZt5OyUGqPFmhm2ulWdycNVp8HayQrpqeY8cdu8QsmZRdNCMfMFLahZCm6xKS+8GUrJWgJr64yaZpkxQS45uPb9zxC+G/u4FZhS/YsrjDTuIIwMGS0+qsNr4075yemFFAQHIpbhWZ9QlYrNq2TAolrVezeAw3AQ/nr4sz60dvqRahcse9x8oMxB7jA55OuxH5uk6PcCIAmEi+njr6Lgbcn2mtPMyk7kGcwjNzCL57b51RxJVi0ZqNXrS0FFepvzCK3IOEqKqrKGGK0qGqF4MFsH2wdq4RFkXjLMZk4u9ZWjIRjc', + encryptedSubmissionSecretKey: + 'ywWDxb29guAgVK4yhLmLK19UKzLrfLAl65JzPDCVNz8=;/Q3WNg7Dk/tWBmpdUcST39zG16/Nyn8V:p1YqpiwEtOssq3yZUhZC1SgIYJcfJDmVFmgNwKf8D+YEqDzLaq5GShR7hTtTixtp', +} + +const formPublicKey = 'ySgusViv6xdSIXELuGOq2L3Obp8xorT0Qilv+G4nHnM=' +const formSecretKey = 'Ngx1Kwpe8JXZUof/DCkkVduVmPSN4paqaKj5971Gq5c=' +const submissionPublicKey = '8JCuSlyJZ5N684o9TNdZLijtuORTlD/pbXiFwNf7Fhc=' +const submissionSecretKey = 'bIyKphcx5hiuBaJ4q5cwnXaFNY9Ofe5NQBqTEzf3zYA=' + +export { + plaintext, + ciphertext, + formPublicKey, + formSecretKey, + submissionPublicKey, + submissionSecretKey +} diff --git a/src/crypto-base.ts b/src/crypto-base.ts new file mode 100644 index 0000000..a0bcfbf --- /dev/null +++ b/src/crypto-base.ts @@ -0,0 +1,64 @@ +import nacl from 'tweetnacl' +import { decodeBase64, encodeBase64 } from 'tweetnacl-util' + +import { generateKeypair } from './util/crypto' +import { EncryptedFileContent } from './types' + +export default class CryptoBase { + /** + * Generates a new keypair for encryption. + * @returns The generated keypair. + */ + generate = generateKeypair + + /** + * Encrypt given binary file with a unique keypair for each submission. + * @param binary The file to encrypt, should be a blob that is converted to Uint8Array binary + * @param publicKey The base-64 encoded public key + * @returns Promise holding the encrypted file + * @throws error if any of the encrypt methods fail + */ + encryptFile = async ( + binary: Uint8Array, + publicKey: string + ): Promise => { + const fileKeypair = this.generate() + const nonce = nacl.randomBytes(24) + return { + //! NOTE: submissionPublicKey here is a misnomer as a new keypair is generated per file. + // The naming is only retained for backward-compatibility purposes. + submissionPublicKey: fileKeypair.publicKey, + nonce: encodeBase64(nonce), + binary: nacl.box( + binary, + nonce, + decodeBase64(publicKey), + decodeBase64(fileKeypair.secretKey) + ), + } + } + + /** + * Decrypt the given encrypted file content. + * @param secretKey Secret key as a base-64 string + * @param encrypted Object returned from encryptFile function + * @param encrypted.submissionPublicKey The file's public key as a base-64 string + * @param encrypted.nonce The nonce as a base-64 string + * @param encrypted.blob The encrypted file as a Blob object + */ + decryptFile = async ( + secretKey: string, + { + submissionPublicKey: filePublicKey, + nonce, + binary: encryptedBinary, + }: EncryptedFileContent + ): Promise => { + return nacl.box.open( + encryptedBinary, + decodeBase64(nonce), + decodeBase64(filePublicKey), + decodeBase64(secretKey) + ) + } +} diff --git a/src/crypto-v3.ts b/src/crypto-v3.ts new file mode 100644 index 0000000..9e7fc00 --- /dev/null +++ b/src/crypto-v3.ts @@ -0,0 +1,141 @@ +import { + decodeBase64, + decodeUTF8, + encodeBase64, + encodeUTF8, +} from 'tweetnacl-util' + +import { decryptContent, encryptMessage, generateKeypair } from './util/crypto' +import { determineIsFormFieldsV3 } from './util/validate' +import CryptoBase from './crypto-base' +import { + DecryptedContentV3, + DecryptParams, + DecryptParamsV3, + EncryptedContentV3, + FormFieldsV3, +} from './types' + +export default class CryptoV3 extends CryptoBase { + constructor() { + super() + } + + /** + * Encrypt input with a unique keypair for each submission. + * @param msg The message to encrypt, will be stringified. + * @param form The base-64 encoded form public key for encrypting. + * @returns The encrypted basestring. + */ + encrypt = (msg: any, formPublicKey: string): EncryptedContentV3 => { + const submissionKeypair = generateKeypair() + + const encryptedSubmissionSecretKey = encryptMessage( + decodeBase64(submissionKeypair.secretKey), + formPublicKey + ) + + const processedMsg = decodeUTF8(JSON.stringify(msg)) + const encryptedContent = encryptMessage( + processedMsg, + submissionKeypair.publicKey + ) + + return { + submissionPublicKey: submissionKeypair.publicKey, + submissionSecretKey: submissionKeypair.secretKey, + encryptedContent, + encryptedSubmissionSecretKey, + } + } + + /** + * Decrypts an encrypted submission and returns it. + * @param submissionSecretKey The base-64 encoded secret key for decrypting. + * @param decryptParams The params containing encrypted content and information. + * @param decryptParams.encryptedContent The encrypted content encoded with base-64. + * @param decryptParams.version The version of the payload. + * @returns The decrypted content if successful. Else, null will be returned. + */ + decryptFromSubmissionKey = ( + submissionSecretKey: string, + decryptParams: DecryptParams + ): DecryptedContentV3 | null => { + try { + const { encryptedContent } = decryptParams + + // Do not return the transformed object in `_decrypt` function as a signed + // object is not encoded in UTF8 and is encoded in Base-64 instead. + const decryptedContent = decryptContent( + submissionSecretKey, + encryptedContent + ) + if (!decryptedContent) { + throw new Error('Failed to decrypt content') + } + const decryptedObject: Record = JSON.parse( + encodeUTF8(decryptedContent) + ) + if (!determineIsFormFieldsV3(decryptedObject)) { + throw new Error('Decrypted object does not fit expected shape') + } + + const returnedObject: DecryptedContentV3 = { + submissionSecretKey, + responses: decryptedObject as FormFieldsV3, + } + + return returnedObject + } catch (err) { + return null + } + } + + /** + * Decrypts an encrypted submission and returns it. + * @param formSecretKey The base-64 encoded form secret key for decrypting the submission. + * @param decryptParams The params containing encrypted content, encrypted submission key and information. + * @param decryptParams.encryptedContent The encrypted content encoded with base-64. + * @param decryptParams.encryptedSubmissionSecretKey The encrypted submission secret key encoded with base-64. + * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. + * @returns The decrypted content if successful. Else, null will be returned. + */ + decrypt = ( + formSecretKey: string, + decryptParams: DecryptParamsV3 + ): DecryptedContentV3 | null => { + const { encryptedSubmissionSecretKey, ...rest } = decryptParams + + const submissionSecretKey = decryptContent( + formSecretKey, + encryptedSubmissionSecretKey + ) + + if (submissionSecretKey === null) return null + + return this.decryptFromSubmissionKey( + encodeBase64(submissionSecretKey), + rest + ) + } + + /** + * Returns true if a pair of public & secret keys are associated with each other + * @param publicKey The public key to verify against. + * @param secretKey The private key to verify against. + */ + valid = (publicKey: string, secretKey: string) => { + const testResponse: FormFieldsV3 = {} + const internalValidationVersion = 3 + + const cipherResponse = this.encrypt(testResponse, publicKey) + // Use toString here since the return should be an empty array. + return ( + testResponse.toString() === + this.decrypt(secretKey, { + ...cipherResponse, + version: internalValidationVersion, + })?.responses.toString() + ) + } +} diff --git a/src/crypto.ts b/src/crypto.ts index 7b0d4dc..59086d8 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,22 +1,17 @@ import axios from 'axios' import nacl from 'tweetnacl' -import { - decodeBase64, - decodeUTF8, - encodeBase64, - encodeUTF8, -} from 'tweetnacl-util' +import { decodeBase64, decodeUTF8, encodeUTF8 } from 'tweetnacl-util' import { + areAttachmentFieldIdsValid, + convertEncryptedAttachmentToFileContent, decryptContent, encryptMessage, - generateKeypair, verifySignedMessage, - areAttachmentFieldIdsValid, - convertEncryptedAttachmentToFileContent, } from './util/crypto' import { determineIsFormFields } from './util/validate' -import { MissingPublicKeyError, AttachmentDecryptionError } from './errors' +import CryptoBase from './crypto-base' +import { AttachmentDecryptionError, MissingPublicKeyError } from './errors' import { DecryptedAttachments, DecryptedContent, @@ -25,14 +20,14 @@ import { EncryptedAttachmentContent, EncryptedAttachmentRecords, EncryptedContent, - EncryptedFileContent, FormField, } from './types' -export default class Crypto { +export default class Crypto extends CryptoBase { signingPublicKey?: string constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) { + super() this.signingPublicKey = signingPublicKey } @@ -128,12 +123,6 @@ export default class Crypto { } } - /** - * Generates a new keypair for encryption. - * @returns The generated keypair. - */ - generate = generateKeypair - /** * Returns true if a pair of public & secret keys are associated with each other * @param publicKey The public key to verify against. @@ -154,55 +143,6 @@ export default class Crypto { ) } - /** - * Encrypt given binary file with a unique keypair for each submission. - * @param binary The file to encrypt, should be a blob that is converted to Uint8Array binary - * @param formPublicKey The base-64 encoded public key - * @returns Promise holding the encrypted file - * @throws error if any of the encrypt methods fail - */ - encryptFile = async ( - binary: Uint8Array, - formPublicKey: string - ): Promise => { - const submissionKeypair = this.generate() - const nonce = nacl.randomBytes(24) - return { - submissionPublicKey: submissionKeypair.publicKey, - nonce: encodeBase64(nonce), - binary: nacl.box( - binary, - nonce, - decodeBase64(formPublicKey), - decodeBase64(submissionKeypair.secretKey) - ), - } - } - - /** - * Decrypt the given encrypted file content. - * @param formSecretKey Secret key as a base-64 string - * @param encrypted Object returned from encryptFile function - * @param encrypted.submissionPublicKey The submission public key as a base-64 string - * @param encrypted.nonce The nonce as a base-64 string - * @param encrypted.blob The encrypted file as a Blob object - */ - decryptFile = async ( - formSecretKey: string, - { - submissionPublicKey, - nonce, - binary: encryptedBinary, - }: EncryptedFileContent - ): Promise => { - return nacl.box.open( - encryptedBinary, - decodeBase64(nonce), - decodeBase64(submissionPublicKey), - decodeBase64(formSecretKey) - ) - } - /** * Decrypts an encrypted submission, and also download and decrypt any attachments alongside it. * @param formSecretKey Secret key as a base-64 string diff --git a/src/index.ts b/src/index.ts index 4c97c1c..9022d92 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import { getSigningPublicKey, getVerificationPublicKey } from './util/publicKey' import Crypto from './crypto' +import CryptoV3 from './crypto-v3' import { PackageInitParams } from './types' import Verification from './verification' import Webhooks from './webhooks' @@ -30,6 +31,7 @@ export = function (config: PackageInitParams = {}) { secretKey: webhookSecretKey, }), crypto: new Crypto({ signingPublicKey }), + cryptoV3: new CryptoV3(), verification: new Verification({ publicKey: verificationPublicKey, secretKey: verificationOptions?.secretKey, diff --git a/src/types.ts b/src/types.ts index 96b7e68..887f54c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,11 @@ export type FieldType = | 'date' | 'mobile' | 'homeno' + | 'statement' + | 'image' + | 'country_region' + | 'uen' + | 'children' // Represents form field responses in a form. export type FormField = { @@ -39,12 +44,28 @@ export type FormField = { | { answer?: never; answerArray: string[] | string[][] } ) +// Represents form field responses in a form. +export type FormFieldsV3 = Record< + string, + { + fieldType: FieldType + answer: any // too complex to represent here + } +> + // Encrypted basestring containing the submission public key, // nonce and encrypted data in base-64. // A string in the format of // ;: export type EncryptedContent = string +export type EncryptedContentV3 = { + submissionPublicKey: string + submissionSecretKey: string + encryptedContent: EncryptedContent + encryptedSubmissionSecretKey: EncryptedContent +} + // Records containing a map of field IDs to URLs where encrypted // attachments can be downloaded. export type EncryptedAttachmentRecords = Record @@ -56,11 +77,22 @@ export interface DecryptParams { attachmentDownloadUrls?: EncryptedAttachmentRecords } +export interface DecryptParamsV3 { + encryptedContent: EncryptedContent + encryptedSubmissionSecretKey: EncryptedContent + version: number +} + export type DecryptedContent = { responses: FormField[] verified?: Record } +export type DecryptedContentV3 = { + submissionSecretKey: string + responses: FormFieldsV3 +} + export type DecryptedFile = { filename: string content: Uint8Array diff --git a/src/util/crypto.ts b/src/util/crypto.ts index a569408..96ad6bd 100644 --- a/src/util/crypto.ts +++ b/src/util/crypto.ts @@ -2,10 +2,10 @@ import nacl from 'tweetnacl' import { decodeBase64, encodeBase64, encodeUTF8 } from 'tweetnacl-util' import { - Keypair, - EncryptedContent, EncryptedAttachmentContent, + EncryptedContent, EncryptedFileContent, + Keypair, } from '../types' /** diff --git a/src/util/validate.ts b/src/util/validate.ts index 2ae8fec..5544653 100644 --- a/src/util/validate.ts +++ b/src/util/validate.ts @@ -1,4 +1,4 @@ -import { FormField } from '../types' +import { FormField, FormFieldsV3 } from '../types' function determineIsFormFields(tbd: any): tbd is FormField[] { if (!Array.isArray(tbd)) { @@ -24,4 +24,14 @@ function determineIsFormFields(tbd: any): tbd is FormField[] { return filter.length === tbd.length } -export { determineIsFormFields } +// TODO(MRF): This is currently very rudimentary, we should look at making this more specific where required. +function determineIsFormFieldsV3(tbd: any): tbd is FormFieldsV3 { + for (const id of Object.keys(tbd)) { + const value = tbd[id] + const hasCorrectShape = value.fieldType && value.answer !== undefined + if (!hasCorrectShape) return false + } + return true +} + +export { determineIsFormFields, determineIsFormFieldsV3 }