-
Notifications
You must be signed in to change notification settings - Fork 10
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
12 changed files
with
446 additions
and
74 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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: | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,6 @@ module.exports = { | |
global: { | ||
statements: 85, | ||
functions: 80, | ||
} | ||
} | ||
}, | ||
}, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
) | ||
} | ||
} |
Oops, something went wrong.