Skip to content

Commit

Permalink
v0.8.0 - Update decrypt signature (#25)
Browse files Browse the repository at this point in the history
Update decrypt to take a new DecryptParams object as the second parameter. This change is to accommodate future changes in the decrypt body. This is not backwards compatible with previous versions due to change in parameter signature.
  • Loading branch information
karrui committed May 27, 2020
1 parent 77e2fe6 commit e1aa63a
Show file tree
Hide file tree
Showing 9 changed files with 104 additions and 60 deletions.
50 changes: 27 additions & 23 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,18 @@ app.post(
},
// Decrypt the submission
function (req, res, next) {
// As the third parameter `verifiedContent` is not provided, only
// responses will be returned in the response object.
/** @type {{responses: FormField[]}} */
// `req.body.data` must be 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,
req.body.encryptedContent
)

// If a third parameter is provided, the return object will include a verified
// key.
/** @type {{
* responses: FormField[],
* verified: Record<string, any>
* }}
*/
const submission = formsg.crypto.decrypt(
formSecretKey,
req.body.encryptedContent,
req.body.verifiedContent
// If `verifiedContent` is provided in `req.body.data`, the return object
// will include a verified key.
req.body.data
)

// If the decryption failed, submission will be `null`.
Expand Down Expand Up @@ -110,7 +103,9 @@ The underlying cryptosystem is `x25519-xsalsa20-poly1305` which is implemented b

### Format of Decrypted Submissions

`formsg.crypto.decrypt` returns an an object with the shape
`formsg.crypto.decrypt(formSecretKey: string, decryptParams: DecryptParams)`
takes in `decryptParams` as the second argument, and returns an an object with
the shape

<pre>
{
Expand All @@ -119,9 +114,18 @@ The underlying cryptosystem is `x25519-xsalsa20-poly1305` which is implemented b
}
</pre>

The `encryptedContent` field decrypts into an array of `FormField` objects, which will be assigned to the `responses` key of the returned object.
encryptedContent: EncryptedContent
version: number
verifiedContent?: EncryptedContent

The `decryptParams.encryptedContent` field decrypts into an array of `FormField` objects, which will be assigned to the `responses` key of the returned object.

Furthermore, if `verifiedContent` is passed as the third parameter of the `decrypt` function, the function will decrypt and open the signed decrypted content with the package's own `signingPublicKey` in [`signing-keys.ts`](https://github.com/opengovsg/formsg-javascript-sdk/tree/master/src/resource/signing-keys.ts).
Furthermore, if `decryptParams.verifiedContent` exists, the function will
decrypt and open the signed decrypted content with the package's own
`signingPublicKey` in
[`signing-keys.ts`](https://github.com/opengovsg/formsg-javascript-sdk/tree/master/src/resource/signing-keys.ts).
The resulting decrypted verifiedContent will be assigned to the `verified` key
of the returned object.

> **NOTE** <br>
> If any errors occur, either from the failure to decrypt either `encryptedContent` or `verifiedContent`, or the failure to authenticate the decrypted signed message in `verifiedContent`, `null` will be returned.
Expand All @@ -144,12 +148,12 @@ The full schema can be viewed in

If the decrypted content is the correct shape, then:

1. the decrypted content will be set as the value of the `responses` key.
2. if `verifiedContent` is passed as the third parameter, then an attempt to
1. the decrypted content (from `decryptParams.encryptedContent`) will be set as the value of the `responses` key.
2. if `decryptParams.verifiedContent` exists, then an attempt to
decrypted the verified content will be called, and the result set as the
value of `verified` key. There is no shape validation for the decrypted
verified content. **If the verification fails, `null` is returned, even if
`decryptedContent` was successfully decrypted.**
`decryptParams.encryptedContent` was successfully decrypted.**

## Verifying Signatures Manually

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opengovsg/formsg-sdk",
"version": "0.7.0",
"version": "0.8.0",
"repository": {
"type": "git",
"url": "https://github.com/opengovsg/formsg-javascript-sdk.git"
Expand Down
41 changes: 31 additions & 10 deletions spec/crypto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {

const formsg = formsgPackage({ mode: 'test' })

const INTERNAL_TEST_VERSION = 1

describe('Crypto', function () {
const signingSecretKey = SIGNING_KEYS.test.secretKey
const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg'))
Expand Down Expand Up @@ -39,14 +41,22 @@ describe('Crypto', function () {

it('should decrypt the submission ciphertext from 2020-03-22 successfully', () => {
// Act
const decrypted = formsg.crypto.decrypt(formSecretKey, ciphertext)
const decrypted = formsg.crypto.decrypt(formSecretKey, {
encryptedContent: ciphertext,
version: INTERNAL_TEST_VERSION,
})

// Assert
expect(decrypted).toHaveProperty('responses', plaintext)
})

it('should return null on unsuccessful decryption', () => {
expect(formsg.crypto.decrypt('random', ciphertext)).toBe(null)
expect(
formsg.crypto.decrypt('random', {
encryptedContent: ciphertext,
version: INTERNAL_TEST_VERSION,
})
).toBe(null)
})

it('should return null when successfully decrypted content does not fit FormField type shape', () => {
Expand All @@ -58,7 +68,12 @@ describe('Crypto', function () {
// Assert
// Using correct secret key, but the decrypted object should not fit the
// expected shape and thus return null.
expect(formsg.crypto.decrypt(secretKey, malformedEncrypt)).toBe(null)
expect(
formsg.crypto.decrypt(secretKey, {
encryptedContent: malformedEncrypt,
version: INTERNAL_TEST_VERSION,
})
).toBe(null)
})

it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', () => {
Expand All @@ -67,7 +82,10 @@ describe('Crypto', function () {

// Act
const ciphertext = formsg.crypto.encrypt(plaintext, publicKey)
const decrypted = formsg.crypto.decrypt(secretKey, ciphertext)
const decrypted = formsg.crypto.decrypt(secretKey, {
encryptedContent: ciphertext,
version: INTERNAL_TEST_VERSION,
})
// Assert
expect(decrypted).toHaveProperty('responses', plaintext)
})
Expand All @@ -79,7 +97,10 @@ describe('Crypto', function () {
// Act
// Signing key (last parameter) is omitted.
const ciphertext = formsg.crypto.encrypt(plaintext, publicKey)
const decrypted = formsg.crypto.decrypt(secretKey, ciphertext)
const decrypted = formsg.crypto.decrypt(secretKey, {
encryptedContent: ciphertext,
version: INTERNAL_TEST_VERSION,
})

// Assert
expect(decrypted).toHaveProperty('responses', plaintext)
Expand All @@ -103,11 +124,11 @@ describe('Crypto', function () {
signingSecretKey
)
// Decrypt encrypted content along with our signed+encrypted content.
const decrypted = formsg.crypto.decrypt(
secretKey,
ciphertext,
signedAndEncryptedText
)
const decrypted = formsg.crypto.decrypt(secretKey, {
encryptedContent: ciphertext,
verifiedContent: signedAndEncryptedText,
version: INTERNAL_TEST_VERSION,
})

// Assert
expect(decrypted).toHaveProperty('verified', mockVerifiedContent)
Expand Down
21 changes: 13 additions & 8 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,19 @@ function decrypt(signingPublicKey: string) {
/**
* Decrypts an encrypted submission and returns it.
* @param formSecretKey The base-64 secret key of the form to decrypt with.
* @param encryptedContent The encrypted content encoded with base-64.
* @param verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message.
* @param decryptParams The params containing encrypted content and information.
* @param decryptParams.encryptedContent The encrypted content encoded with base-64.
* @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with.
* @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message.
* @returns The decrypted content if successful. Else, null will be returned.
*/
function _internalDecrypt(
formSecretKey: string,
encryptedContent: EncryptedContent,
verifiedContent?: EncryptedContent
decryptParams: DecryptParams
): DecryptedContent | null {
try {
const { encryptedContent, verifiedContent, version } = decryptParams

// Do not return the transformed object in `_decrypt` function as a signed
// object is not encoded in UTF8 and is encoded in Base-64 instead.
const decryptedContent = _decrypt(formSecretKey, encryptedContent)
Expand Down Expand Up @@ -173,15 +176,17 @@ function valid(signingPublicKey: string) {
*/
function _internalValid(publicKey: string, secretKey: string) {
const testResponse: FormField[] = []
const internalValidationVersion = 1

try {
const cipherResponse = encrypt(testResponse, publicKey)
// Use toString here since the return should be an empty array.
return (
testResponse.toString() ===
decrypt(signingPublicKey)(
secretKey,
cipherResponse
)?.responses.toString()
decrypt(signingPublicKey)(secretKey, {
encryptedContent: cipherResponse,
version: internalValidationVersion,
})?.responses.toString()
)
} catch (err) {
return false
Expand Down
5 changes: 2 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import webhooks from './webhooks'
import crypto from './crypto'
import verification from './verification'

/**
* Entrypoint into the FormSG SDK
* @param {Object} options
Expand All @@ -22,7 +21,7 @@ export = function (options: PackageInitParams = {}) {
}),
verification: verification({
mode: mode || 'production',
verificationOptions
})
verificationOptions,
}),
}
}
26 changes: 17 additions & 9 deletions src/types/index.d.ts → src/typings/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ type FormField = {
// <SubmissionPublicKey>;<Base64Nonce>:<Base64EncryptedData>
type EncryptedContent = string

interface DecryptParams {
encryptedContent: EncryptedContent
version: number
verifiedContent?: EncryptedContent
}

type DecryptedContent = {
responses: FormField[]
verified?: Record<string, any>
Expand All @@ -61,27 +67,29 @@ type Keypair = {
type PackageMode = 'staging' | 'production' | 'development' | 'test'

type VerificationOptions = {
secretKey?: string;
secretKey?: string
transactionExpiry?: number
}

// A verified answer contains a field ID and answer
type VerifiedAnswer = {
fieldId: string;
answer: string;
fieldId: string
answer: string
}

// Add the transaction ID and form ID to a VerifiedAnswer to obtain a signature
type VerificationSignatureOptions = VerifiedAnswer & {
transactionId: string;
formId: string;
transactionId: string
formId: string
}

// Creating a basestring requires the epoch in addition to signature requirements
type VerificationBasestringOptions = VerificationSignatureOptions & { time: number }
type VerificationBasestringOptions = VerificationSignatureOptions & {
time: number
}

// Authenticate a VerifiedAnswer with a signatureString and epoch
type VerificationAuthenticateOptions = VerifiedAnswer & {
signatureString: string;
submissionCreatedAt: number;
type VerificationAuthenticateOptions = VerifiedAnswer & {
signatureString: string
submissionCreatedAt: number
}
15 changes: 11 additions & 4 deletions src/verification/generate-signature.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import nacl from 'tweetnacl'
import { decodeUTF8, decodeBase64, encodeBase64 } from 'tweetnacl-util'
import basestring from './basestring'
export default function (privateKey: string){

function generateSignature({ transactionId, formId, fieldId, answer }: VerificationSignatureOptions): string {

export default function (privateKey: string) {
function generateSignature({
transactionId,
formId,
fieldId,
answer,
}: VerificationSignatureOptions): string {
const time = Date.now()
const data = basestring({ transactionId, formId, fieldId, answer, time })
const signature = nacl.sign.detached(
decodeUTF8(data),
decodeBase64(privateKey)
)
return `f=${formId},v=${transactionId},t=${time},s=${encodeBase64(signature)}`
return `f=${formId},v=${transactionId},t=${time},s=${encodeBase64(
signature
)}`
}

return generateSignature
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
"lib": ["WebWorker"]
},
"include": ["src"],
"exclude": ["node_modules", "**/*.spec.ts, dist"]
"exclude": ["node_modules", "**/*.spec.ts", "dist"]
}

0 comments on commit e1aa63a

Please sign in to comment.