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

add downloadAndDecryptAttachments for downloading and decrypting attachments #62

Merged
merged 9 commits into from
May 14, 2021
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
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. |
frankchn marked this conversation as resolved.
Show resolved Hide resolved

### 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