Skip to content

Commit

Permalink
feat: mrf crypto (#101)
Browse files Browse the repository at this point in the history
* feat: mrf crypto

* fix: export crypto-v3

* chore: update encodings

* feat: coalesce encryption and decryption steps

* chore: add logging for errors

* chore: remove validation check

* chore: skip catching errors

* chore: remove more error catching

* chore: log submission secret key

* chore: comment code

* test: add tests to crypto-v3

* feat: return submission secret key in the clear for workflows

* test: update test infra to use bigger heap size

* feat: provide submission public key as part of encrypted payload

* feat: attachments

* test: update tests

* fix: revert for backward-compatibility

* chore: update function documentation
  • Loading branch information
justynoh committed Dec 14, 2023
1 parent 4de687d commit cfe99cb
Show file tree
Hide file tree
Showing 12 changed files with 446 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ module.exports = {
global: {
statements: 85,
functions: 80,
}
}
},
},
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
129 changes: 129 additions & 0 deletions spec/crypto-v3.spec.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
52 changes: 52 additions & 0 deletions spec/resources/crypto-v3-data-20231207.ts
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions src/crypto-base.ts
Original file line number Diff line number Diff line change
@@ -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<EncryptedFileContent> => {
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<Uint8Array | null> => {
return nacl.box.open(
encryptedBinary,
decodeBase64(nonce),
decodeBase64(filePublicKey),
decodeBase64(secretKey)
)
}
}

0 comments on commit cfe99cb

Please sign in to comment.