• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

opengovsg / formsg-javascript-sdk / 13652596553

04 Mar 2025 11:24AM UTC coverage: 97.495%. First build
13652596553

push

github

littlemight
fix: upgrade actions/cache

104 of 114 branches covered (91.23%)

Branch coverage included in aggregate %.

363 of 365 relevant lines covered (99.45%)

6.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

100.0
/src/crypto.ts
1
import axios from 'axios'
2✔
2
import nacl from 'tweetnacl'
2✔
3
import { decodeBase64, decodeUTF8, encodeUTF8 } from 'tweetnacl-util'
2✔
4

5
import {
2✔
6
  areAttachmentFieldIdsValid,
7
  convertEncryptedAttachmentToFileContent,
8
  decryptContent,
9
  encryptMessage,
10
  verifySignedMessage,
11
} from './util/crypto'
12
import { determineIsFormFields } from './util/validate'
2✔
13
import CryptoBase from './crypto-base'
2✔
14
import { AttachmentDecryptionError, MissingPublicKeyError } from './errors'
2✔
15
import {
16
  DecryptedAttachments,
17
  DecryptedContent,
18
  DecryptedContentAndAttachments,
19
  DecryptParams,
20
  EncryptedAttachmentContent,
21
  EncryptedAttachmentRecords,
22
  EncryptedContent,
23
  FormField,
24
} from './types'
25

26
export default class Crypto extends CryptoBase {
2✔
27
  signingPublicKey?: string
28

29
  constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) {
5✔
30
    super()
5✔
31
    this.signingPublicKey = signingPublicKey
5✔
32
  }
33

34
  /**
35
   * Encrypt input with a unique keypair for each submission
36
   * @param encryptionPublicKey The base-64 encoded public key for encrypting.
37
   * @param msg The message to encrypt, will be stringified.
38
   * @param signingPrivateKey Optional. Must be a base-64 encoded private key. If given, will be used to signing the given msg param prior to encrypting.
39
   * @returns The encrypted basestring.
40
   */
41
  encrypt = (
5✔
42
    msg: any,
43
    encryptionPublicKey: string,
44
    signingPrivateKey?: string
45
  ): EncryptedContent => {
46
    let processedMsg = decodeUTF8(JSON.stringify(msg))
18✔
47

48
    if (signingPrivateKey) {
18✔
49
      processedMsg = nacl.sign(processedMsg, decodeBase64(signingPrivateKey))
2✔
50
    }
51

52
    return encryptMessage(processedMsg, encryptionPublicKey)
18✔
53
  }
54

55
  /**
56
   * Decrypts an encrypted submission and returns it.
57
   * @param formSecretKey The base-64 secret key of the form to decrypt with.
58
   * @param decryptParams The params containing encrypted content and information.
59
   * @param decryptParams.encryptedContent The encrypted content encoded with base-64.
60
   * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with.
61
   * @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message.
62
   * @returns The decrypted content if successful. Else, null will be returned.
63
   * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content.
64
   */
65
  decrypt = (
5✔
66
    formSecretKey: string,
67
    decryptParams: DecryptParams
68
  ): DecryptedContent | null => {
69
    try {
19✔
70
      const { encryptedContent, verifiedContent } = decryptParams
38✔
71

72
      // Do not return the transformed object in `_decrypt` function as a signed
73
      // object is not encoded in UTF8 and is encoded in Base-64 instead.
74
      const decryptedContent = decryptContent(formSecretKey, encryptedContent)
19✔
75
      if (!decryptedContent) {
19✔
76
        throw new Error('Failed to decrypt content')
3✔
77
      }
78
      const decryptedObject: Record<string, unknown> = JSON.parse(
16✔
79
        encodeUTF8(decryptedContent)
80
      )
81
      if (!determineIsFormFields(decryptedObject)) {
16✔
82
        throw new Error('Decrypted object does not fit expected shape')
1✔
83
      }
84

85
      const returnedObject: DecryptedContent = {
15✔
86
        responses: decryptedObject,
87
      }
88

89
      if (verifiedContent) {
15✔
90
        if (!this.signingPublicKey) {
3✔
91
          throw new MissingPublicKeyError(
1✔
92
            'Public signing key must be provided when instantiating the Crypto class in order to verify verified content'
93
          )
94
        }
95
        // Only care if it is the correct shape if verifiedContent exists, since
96
        // we need to append it to the end.
97
        // Decrypted message must be able to be authenticated by the public key.
98
        const decryptedVerifiedContent = decryptContent(
2✔
99
          formSecretKey,
100
          verifiedContent
101
        )
102
        if (!decryptedVerifiedContent) {
2✔
103
          // Returns null if decrypting verified content failed.
104
          throw new Error('Failed to decrypt verified content')
1✔
105
        }
106
        const decryptedVerifiedObject = verifySignedMessage(
1✔
107
          decryptedVerifiedContent,
108
          this.signingPublicKey
109
        )
110

111
        returnedObject.verified = decryptedVerifiedObject
1✔
112
      }
113

114
      return returnedObject
13✔
115
    } catch (err) {
116
      // Should only throw if MissingPublicKeyError.
117
      // This library should be able to be used to encrypt and decrypt content
118
      // if the content does not contain verified fields.
119
      if (err instanceof MissingPublicKeyError) {
6✔
120
        throw err
1✔
121
      }
122
      return null
5✔
123
    }
124
  }
125

126
  /**
127
   * Returns true if a pair of public & secret keys are associated with each other
128
   * @param publicKey The public key to verify against.
129
   * @param secretKey The private key to verify against.
130
   */
131
  valid = (publicKey: string, secretKey: string) => {
5✔
132
    const testResponse: FormField[] = []
3✔
133
    const internalValidationVersion = 1
3✔
134

135
    const cipherResponse = this.encrypt(testResponse, publicKey)
3✔
136
    // Use toString here since the return should be an empty array.
137
    return (
3✔
138
      testResponse.toString() ===
139
      this.decrypt(secretKey, {
8✔
140
        encryptedContent: cipherResponse,
141
        version: internalValidationVersion,
142
      })?.responses.toString()
143
    )
144
  }
145

146
  /**
147
   * Decrypts an encrypted submission, and also download and decrypt any attachments alongside it.
148
   * @param formSecretKey Secret key as a base-64 string
149
   * @param decryptParams The params containing encrypted content and information.
150
   * @returns A promise of the decrypted submission, including attachments (if any). Or else returns null if a decryption error decrypting any part of the submission.
151
   * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content.
152
   */
153
  decryptWithAttachments = async (
5✔
154
    formSecretKey: string,
155
    decryptParams: DecryptParams
6✔
156
  ): Promise<DecryptedContentAndAttachments | null> => {
157
    const decryptedRecords: DecryptedAttachments = {}
6✔
158
    const filenames: Record<string, string> = {}
6✔
159

160
    const attachmentRecords: EncryptedAttachmentRecords =
6✔
161
      decryptParams.attachmentDownloadUrls ?? {}
18✔
162
    const decryptedContent = this.decrypt(formSecretKey, decryptParams)
6✔
163
    if (decryptedContent === null) return null
6✔
164

165
    // Retrieve all original filenames for attachments for easy lookup
166
    decryptedContent.responses.forEach((response) => {
5✔
167
      if (response.fieldType === 'attachment' && response.answer) {
73✔
168
        filenames[response._id] = response.answer
3✔
169
      }
170
    })
171

172
    const fieldIds = Object.keys(attachmentRecords)
5✔
173
    // Check if all fieldIds are within filenames
174
    if (!areAttachmentFieldIdsValid(fieldIds, filenames)) {
5✔
175
      return null
1✔
176
    }
177

178
    const downloadPromises = fieldIds.map((fieldId) => {
4✔
179
      return (
3✔
180
        axios
181
          // Retrieve all the attachments as JSON
182
          .get<EncryptedAttachmentContent>(attachmentRecords[fieldId], {
183
            responseType: 'json',
184
          })
185
          // Decrypt all the attachments
186
          .then(({ data: downloadResponse }) => {
3✔
187
            const encryptedFile =
188
              convertEncryptedAttachmentToFileContent(downloadResponse)
3✔
189
            return this.decryptFile(formSecretKey, encryptedFile)
2✔
190
          })
191
          .then((decryptedFile) => {
192
            // Check if the file exists and set the filename accordingly; otherwise, throw an error
193
            if (decryptedFile) {
2✔
194
              decryptedRecords[fieldId] = {
1✔
195
                filename: filenames[fieldId],
196
                content: decryptedFile,
197
              }
198
            } else {
199
              throw new AttachmentDecryptionError()
1✔
200
            }
201
          })
202
      )
203
    })
204

205
    try {
206
      await Promise.all(downloadPromises)
8✔
207
    } catch {
208
      return null
2✔
209
    }
210

211
    return {
2✔
212
      content: decryptedContent,
213
      attachments: decryptedRecords,
214
    }
215
  }
216
}
2✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc