Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: mrf crypto #101

Merged
merged 18 commits into from
Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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/[email protected]
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: '[email protected]',
},
'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)
)
}
}
Loading