Skip to content

Commit

Permalink
add downloadAndDecryptAttachments for downloading and decrypting atta…
Browse files Browse the repository at this point in the history
…chments (#62)

* add downloadAndDecryptAttachments for downloading and decrypting attachments

* add more tests to improve coverage

* Update src/crypto.ts

Co-authored-by: Antariksh Mahajan <[email protected]>

* update code per comments

* add a backtick to readme.md

* address comments by @seaerchin and @mantariksh

* update types in README.md

* remove extraneous http status check

* remove type annotations in readme.md

Co-authored-by: Antariksh Mahajan <[email protected]>
  • Loading branch information
frankchn and mantariksh authored May 14, 2021
1 parent 32f743c commit c3ca99f
Show file tree
Hide file tree
Showing 6 changed files with 349 additions and 7 deletions.
60 changes: 53 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,13 +63,6 @@ app.post(
express.json(),
// Decrypt the submission
function (req, res, next) {
// `req.body.data` is an object fulfilling the DecryptParams interface.
// interface DecryptParams {
// encryptedContent: EncryptedContent
// version: number
// verifiedContent?: EncryptedContent
// }
/** @type {{responses: FormField[], verified?: Record<string, any>}} */
const submission = formsg.crypto.decrypt(
formSecretKey,
// If `verifiedContent` is provided in `req.body.data`, the return object
Expand All @@ -86,6 +79,42 @@ app.post(
}
)

// Example for submissions with attachments
app.post(
'/submissions-attachment',
// Endpoint authentication by verifying signatures
function (req, res, next) {
try {
formsg.webhooks.authenticate(req.get('X-FormSG-Signature'), POST_URI)
// Continue processing the POST body
return next()
} catch (e) {
return res.status(401).send({ message: 'Unauthorized' })
}
},
// Parse JSON from raw request body
express.json(),
// Decrypt the submission and attachments
async function (req, res, next) {
const submission = formsg.crypto.decryptWithAttachments(
formSecretKey,
// If `verifiedContent` is provided in `req.body.data`, the return object
// will include a verified key.
req.body.data
)

// If the decryption failed at any point, submission will be `null`.
if (submission) {
// Continue processing the submission

// processSubmission(submission.content)
// processAttachments(submission.attachments)
} else {
// Could not decrypt the submission
}
}
)

app.listen(8080, () => console.log('Running on port 8080'))
```

Expand All @@ -103,6 +132,7 @@ The underlying cryptosystem is `x25519-xsalsa20-poly1305` which is implemented b
| submissionId | string | Unique response identifier, displayed as 'Response ID' to form respondents |
| encryptedContent | string | The encrypted submission in base64. |
| created | string | Creation timestamp. |
| attachmentDownloadUrls | Record<string, string> | (Optional) Records containing field IDs and URLs where encrypted uploaded attachments can be downloaded. |

### Format of Decrypted Submissions

Expand Down Expand Up @@ -158,6 +188,22 @@ If the decrypted content is the correct shape, then:
verified content. **If the verification fails, `null` is returned, even if
`decryptParams.encryptedContent` was successfully decrypted.**

### Processing Attachments

`formsg.crypto.decryptWithAttachments(formSecretKey: string, decryptParams: DecryptParams)` behaves similarly except it will return a `Promise<DecryptedContentAndAttachments | null>`.

`DecryptedContentAndAttachments` is an object containing two fields:
- `content`: the standard form decrypted responses (same as the return type of `formsg.crypto.decrypt`)
- `attachments`: A `Record<string, DecryptedFile>` 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.

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.

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.

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.

*Warning:* We do not have the ability to scan any attachments for malicious content (e.g. spyware or viruses), so careful handling is neeeded.

## Verifying Signatures Manually

You can use the following information to create a custom solution, although we recommend using this SDK.
Expand Down
28 changes: 28 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"author": "Open Government Products (FormSG)",
"license": "MIT",
"dependencies": {
"axios": "^0.21.1",
"tweetnacl": "^1.0.3",
"tweetnacl-util": "^0.15.1"
},
Expand All @@ -34,6 +35,7 @@
"@types/node": "^13.11.1",
"coveralls": "^3.1.0",
"jest": "^25.3.0",
"jest-mock-axios": "^4.4.0",
"ts-jest": "^25.3.1",
"typescript": "^3.8.3"
}
Expand Down
176 changes: 176 additions & 0 deletions spec/crypto.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import mockAxios from 'jest-mock-axios'
import Crypto from '../src/crypto'
import { SIGNING_KEYS } from '../src/resource/signing-keys'

import {
encodeBase64,
} from 'tweetnacl-util'

import {
plaintext,
ciphertext,
Expand All @@ -16,7 +21,11 @@ const encryptionPublicKey = SIGNING_KEYS.test.publicKey
const signingSecretKey = SIGNING_KEYS.test.secretKey
const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg'))

jest.mock('axios', () => mockAxios)

describe('Crypto', function () {
afterEach(() => mockAxios.reset())

const crypto = new Crypto({ signingPublicKey: encryptionPublicKey })

const mockVerifiedContent = {
Expand Down Expand Up @@ -231,4 +240,171 @@ describe('Crypto', function () {
})
expect(decryptResult).toBeNull()
})

it('should be able to download and decrypt an attachment successfully', async () => {
// Arrange
const { publicKey, secretKey } = crypto.generate()

let attachmentPlaintext = plaintext.slice(0)
attachmentPlaintext.push({
_id: '6e771c946b3c5100240368e5',
question: 'Random file',
fieldType: 'attachment',
answer: 'my-random-file.txt',
})

// Encrypt content that is not signed
const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey)

// Encrypt file
const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey)
const uploadedFile = {
submissionPublicKey: encryptedFile.submissionPublicKey,
nonce: encryptedFile.nonce,
binary: encodeBase64(encryptedFile.binary)
}

// Act
const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, {
encryptedContent: ciphertext,
attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' },
version: INTERNAL_TEST_VERSION,
})
mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }})
const decryptedContentWithAttachments = await decryptedFilesPromise
const decryptedFiles = decryptedContentWithAttachments!.attachments

// Assert
expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' })
expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { filename: 'my-random-file.txt', content: testFileBuffer })
})

it('should be able to handle fields without attachmentDownloadUrls', async () => {
// Arrange
const { publicKey, secretKey } = crypto.generate()

// Encrypt content that is not signed
const ciphertext = crypto.encrypt(plaintext, publicKey)

// Act
const decryptedContentWithAttachments = await crypto.decryptWithAttachments(secretKey, {
encryptedContent: ciphertext,
version: INTERNAL_TEST_VERSION,
})
const decryptedFiles = decryptedContentWithAttachments!.attachments

// Assert
expect(decryptedFiles).toEqual({})
})

it('should be able to handle corrupted encrypted content', async () => {
// Arrange
const { secretKey } = crypto.generate()

// Act
const decryptedContents = await crypto.decryptWithAttachments(secretKey, {
encryptedContent: 'bad encrypted content',
version: INTERNAL_TEST_VERSION,
})

// Assert
expect(decryptedContents).toBe(null)
})

it('should be able to handle corrupted download', async () => {
// Arrange
const { publicKey, secretKey } = crypto.generate()

let attachmentPlaintext = plaintext.slice(0)
attachmentPlaintext.push({
_id: '6e771c946b3c5100240368e5',
question: 'Random file',
fieldType: 'attachment',
answer: 'my-random-file.txt',
})

// Encrypt content that is not signed
const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey)

// Encrypt file
const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey)
const uploadedFile = {
submissionPublicKey: encryptedFile.submissionPublicKey,
nonce: encryptedFile.nonce,
binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data
}

// Act
const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, {
encryptedContent: ciphertext,
attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' },
version: INTERNAL_TEST_VERSION,
})
mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }})
const decryptedContents = await decryptedFilesPromise

// Assert
expect(decryptedContents).toBe(null)
})

it('should be able to handle decrypted submission without corresponding attachment field', async () => {
// Arrange
const { publicKey, secretKey } = crypto.generate()

// Encrypt content that is not signed
// Note that plaintext doesn't have any attachment fields
const ciphertext = crypto.encrypt(plaintext, publicKey)

// Encrypt file
const encryptedFile = await crypto.encryptFile(testFileBuffer, publicKey)
const uploadedFile = {
submissionPublicKey: encryptedFile.submissionPublicKey,
nonce: encryptedFile.nonce,
binary: encodeBase64(encryptedFile.binary)
}

// Act
const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, {
encryptedContent: ciphertext,
attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' },
version: INTERNAL_TEST_VERSION,
})
const decryptedContents = await decryptedFilesPromise

// Assert
expect(decryptedContents).toBe(null)
})

it('should be able to handle axios errors', async () => {
// Arrange
const { publicKey, secretKey } = crypto.generate()

let attachmentPlaintext = plaintext.slice(0)
attachmentPlaintext.push({
_id: '6e771c946b3c5100240368e5',
question: 'Random file',
fieldType: 'attachment',
answer: 'my-random-file.txt',
})

// Encrypt content that is not signed
const ciphertext = crypto.encrypt(attachmentPlaintext, publicKey)

// Act
const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, {
encryptedContent: ciphertext,
attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' },
version: INTERNAL_TEST_VERSION,
})
mockAxios.mockResponse({
data: {},
status: 404,
statusText: 'Not Found',
})
const decryptedContents = await decryptedFilesPromise

// Assert
expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' })
expect(decryptedContents).toBe(null)
})
})
Loading

0 comments on commit c3ca99f

Please sign in to comment.