diff --git a/apps/backend/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts b/apps/backend/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts index cd53d0121a..ee9a6f6461 100644 --- a/apps/backend/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts +++ b/apps/backend/src/app/models/__tests__/multirespondent-submission.server.model.spec.ts @@ -689,6 +689,7 @@ describe('Multirespondent Submission Model', () => { version: 3, attachmentMetadata: { someFileName: 'some url of attachment' }, workflowStep: 0, + encryptedStepToken: 'senderPublicKey;nonce:ciphertext', }) // Act @@ -714,6 +715,7 @@ describe('Multirespondent Submission Model', () => { 'version', 'workflowStep', 'submittedSteps', + 'encryptedStepToken', ) expect(actual).not.toBeNull() expect(actual?.toJSON()).toEqual(expected) @@ -760,4 +762,66 @@ describe('Multirespondent Submission Model', () => { }) }) }) + + describe('step-token fields', () => { + it('should persist and retrieve stepTokenHash and encryptedStepToken', async () => { + // Arrange + const validFormId = new ObjectId().toHexString() + const MOCK_STEP_TOKEN_HASH = 'a'.repeat(64) + const MOCK_ENCRYPTED_STEP_TOKEN = + 'senderPublicKey;nonce:encryptedStepTokenCiphertext' + + // Act + const submission = await MultirespondentSubmission.create({ + form: validFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: [], + submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, + encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 3, + workflowStep: 0, + stepTokenHash: MOCK_STEP_TOKEN_HASH, + encryptedStepToken: MOCK_ENCRYPTED_STEP_TOKEN, + }) + const found = await MultirespondentSubmission.findById(submission._id) + + // Assert + expect(found?.stepTokenHash).toBe(MOCK_STEP_TOKEN_HASH) + expect(found?.encryptedStepToken).toBe(MOCK_ENCRYPTED_STEP_TOKEN) + }) + + it('should never expose stepTokenHash or encryptedStepToken in the webhook view', async () => { + // Arrange + const validFormId = new ObjectId().toHexString() + const MOCK_STEP_TOKEN_HASH = 'b'.repeat(64) + const MOCK_ENCRYPTED_STEP_TOKEN = 'senderPublicKey;nonce:ciphertext' + const submission = await MultirespondentSubmission.create({ + form: validFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: [], + submissionPublicKey: MOCK_SUBMISSION_PUBLIC_KEY, + encryptedSubmissionSecretKey: MOCK_ENCRYPTED_SUBMISSION_SECRET_KEY, + encryptedContent: MOCK_ENCRYPTED_CONTENT, + version: 3, + workflowStep: 0, + stepTokenHash: MOCK_STEP_TOKEN_HASH, + encryptedStepToken: MOCK_ENCRYPTED_STEP_TOKEN, + }) + + // Act + const webhookView = await submission.getWebhookView() + + // Assert: step-token fields are ROW-ONLY, never in any webhook payload. + const serialised = JSON.stringify(webhookView) + expect(serialised).not.toContain('stepTokenHash') + expect(serialised).not.toContain('encryptedStepToken') + expect(serialised).not.toContain(MOCK_STEP_TOKEN_HASH) + expect(serialised).not.toContain(MOCK_ENCRYPTED_STEP_TOKEN) + }) + }) }) diff --git a/apps/backend/src/app/models/submission.server.model.ts b/apps/backend/src/app/models/submission.server.model.ts index 1bd3dffcca..ab77e709b3 100644 --- a/apps/backend/src/app/models/submission.server.model.ts +++ b/apps/backend/src/app/models/submission.server.model.ts @@ -592,6 +592,14 @@ export const MultirespondentSubmissionSchema = new Schema< type: [submittedStepSchema], default: [], }, + stepTokenHash: { + type: String, + trim: true, + }, + encryptedStepToken: { + type: String, + trim: true, + }, }) type MultiRespondentAggregates = Pick< @@ -879,6 +887,7 @@ MultirespondentSubmissionSchema.statics.findEncryptedSubmissionById = function ( workflowStep: 1, mrfVersion: 1, submittedSteps: 1, + encryptedStepToken: 1, }) .exec() } diff --git a/apps/backend/src/app/modules/core/core.errors.ts b/apps/backend/src/app/modules/core/core.errors.ts index aa2ac06151..70364959d9 100644 --- a/apps/backend/src/app/modules/core/core.errors.ts +++ b/apps/backend/src/app/modules/core/core.errors.ts @@ -84,6 +84,7 @@ export enum ErrorCodes { SUBMISSION_MISSING_SUBMITTER_ID = 100234, SUBMISSION_ENCRYPTION_VERIFICATION_FAILED = 100235, SUBMISSION_ENCRYPTION_MISMATCH = 100236, + SUBMISSION_MRF_STEP_TOKEN_INVALID = 100237, // [100300 - 100399] Receiver Errors (/modules/submission/receiver) RECEIVER_INITIALISE_MULTIPART_RECEIVER = 100301, RECEIVER_MULTIPART_CONTENT_LIMIT = 100302, diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts index 3dbc2a92b2..6ef3987a55 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.controller.spec.ts @@ -1158,6 +1158,44 @@ describe('multirespondent-submision.controller', () => { }) }) + it('resends the SAME step token in the reminder link without rotating it', async () => { + // Arrange: the admin has unwrapped encryptedStepToken client-side and + // passes the RAW token back in the body. + const RAW_STEP_TOKEN = 'raw-step-token-abc' + const mockReq = expressHandler.mockRequest({ + params: { formId: mockFormId, submissionId: mockSubmissionId }, + body: { + submissionSecretKey: 'mockSubmissionSecretKey', + stepToken: RAW_STEP_TOKEN, + }, + session: { user: { _id: MOCK_USER._id } }, + }) + const mockRes = expressHandler.mockResponse() + const mockNext = jest.fn() + + MockMultiRespondentSubmissionService.getPendingStepRecipientEmailsFromSubmittedStepsMeta = + jest.fn().mockReturnValue( + okAsync({ + recipientEmails: ['test@example.com'], + reminderStepNumber: 1, + }), + ) + MockMultiRespondentSubmissionService.sendNextStepReminderEmail = jest + .fn() + .mockReturnValue(okAsync(true)) + + // Act + await sendPendingMrfSubmissionReminderForTest(mockReq, mockRes, mockNext) + + // Assert: the reminder link carries the exact same token (no rotation). + const call = + MockMultiRespondentSubmissionService.sendNextStepReminderEmail.mock + .calls[0][0] + expect(call.responseUrl).toContain( + `&token=${encodeURIComponent(RAW_STEP_TOKEN)}`, + ) + }) + it('returns 404 when retrieveFormById encounters FormNotFoundError', async () => { // Arrange const formNotFoundError = new FormNotFoundError('Form not found') diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.middleware.spec.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.middleware.spec.ts index 3789d9e0e1..dcc76f8ffd 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.middleware.spec.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.middleware.spec.ts @@ -4,9 +4,12 @@ import { isFieldResponsesV4, } from '@opengovsg/formsg-sdk/adapters' import { ObjectId } from 'bson' +import { featureFlags } from 'formsg-shared/constants' import { BasicField, FormAuthType, FormResponseMode } from 'formsg-shared/types' import { StatusCodes } from 'http-status-codes' import { errAsync, ok, okAsync } from 'neverthrow' +import nacl from 'tweetnacl' +import { decodeBase64, encodeBase64, encodeUTF8 } from 'tweetnacl-util' import formsgSdk from 'src/app/config/formsg-sdk' import { MyInfoService } from 'src/app/modules/myinfo/myinfo.service' @@ -24,6 +27,7 @@ import { createFormsgAndRetrieveForm, encryptSubmission, handleNdiResponses, + validateMultirespondentRemindBody, validateMultirespondentSubmission, } from '../multirespondent-submission.middleware' import { @@ -31,6 +35,7 @@ import { getMultirespondentSubmission, } from '../multirespondent-submission.service' import * as MrfUtils from '../multirespondent-submission.utils' +import * as stepToken from '../step-token' jest.mock('../../../feature-flags/feature-flags.service') jest.mock('../../../form/form.service') @@ -59,6 +64,25 @@ jest.mock('src/app/config/formsg-sdk', () => ({ })) describe('Multirespondent Submission Middleware', () => { + describe('validateMultirespondentRemindBody', () => { + const runValidator = (body: Record): Promise => + new Promise((resolve) => + validateMultirespondentRemindBody( + { body, method: 'POST', headers: {}, query: {}, params: {} } as any, + {} as any, + resolve as any, + ), + ) + + it('accepts a reminder body carrying both the secret key and the step token', async () => { + const error = await runValidator({ + submissionSecretKey: 'k', + stepToken: 't', + }) + expect(error).toBeFalsy() + }) + }) + // Helper function to create fresh mockReq objects for each test const createMockReq = (params: { formId: string; submissionId?: string }) => ({ @@ -845,9 +869,13 @@ describe('Multirespondent Submission Middleware', () => { describe('encryptSubmission', () => { const MOCK_FORM_ID = new ObjectId().toHexString() + const MOCK_FORM_KEYPAIR = nacl.box.keyPair() + const MOCK_FORM_PUBLIC_KEY = encodeBase64(MOCK_FORM_KEYPAIR.publicKey) + const MOCK_FORM_SECRET_KEY = encodeBase64(MOCK_FORM_KEYPAIR.secretKey) + const MOCK_FORM = { _id: MOCK_FORM_ID, - publicKey: 'mockPublicKey', + publicKey: MOCK_FORM_PUBLIC_KEY, form_fields: [ { _id: 'field1', fieldType: BasicField.ShortText, title: 'Field 1' }, ], @@ -953,6 +981,70 @@ describe('Multirespondent Submission Middleware', () => { expect(mockRes.json).toHaveBeenCalled() expect(mockNext).not.toHaveBeenCalled() }) + + describe('step-token mint', () => { + const unwrapStepToken = ( + encryptedStepToken: string, + formSecretKey: string, + ): string | null => { + const [senderPublicKey, nonceAndCipher] = encryptedStepToken.split(';') + const [nonce, cipher] = nonceAndCipher.split(':').map(decodeBase64) + const opened = nacl.box.open( + cipher, + nonce, + decodeBase64(senderPublicKey), + decodeBase64(formSecretKey), + ) + return opened ? encodeUTF8(opened) : null + } + + it('should mint a step token whose hash and wrapped copy match the raw token when the flag is on', async () => { + const mockReq = createMockEncryptReq(false) + mockReq.growthbook.isOn = jest.fn( + (flag: string) => flag === featureFlags.mrfStepWriteToken, + ) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await encryptSubmission(mockReq, mockRes as any, mockNext) + + const payload = mockReq.formsg.encryptedPayload + expect(payload.stepToken).toEqual(expect.any(String)) + // Hash on the row verifies against the raw token in the link. + expect(payload.stepTokenHash).toBe(stepToken.hash(payload.stepToken)) + // Wrapped copy unwraps (with the form secret key) to the same raw token. + expect( + unwrapStepToken(payload.encryptedStepToken, MOCK_FORM_SECRET_KEY), + ).toBe(payload.stepToken) + expect(mockNext).toHaveBeenCalled() + }) + + it('should not mint a step token when the flag is off (flag-off path unchanged)', async () => { + const mockReq = createMockEncryptReq(false) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await encryptSubmission(mockReq, mockRes as any, mockNext) + + const payload = mockReq.formsg.encryptedPayload + expect(payload.stepToken).toBeUndefined() + expect(payload.stepTokenHash).toBeUndefined() + expect(payload.encryptedStepToken).toBeUndefined() + expect(mockNext).toHaveBeenCalled() + }) + + it('should mint a fresh, unique token on each advance (rotation)', async () => { + const run = async () => { + const mockReq = createMockEncryptReq(false) + mockReq.growthbook.isOn = jest.fn( + (flag: string) => flag === featureFlags.mrfStepWriteToken, + ) + await encryptSubmission(mockReq, createMockRes() as any, jest.fn()) + return mockReq.formsg.encryptedPayload.stepToken as string + } + expect(await run()).not.toBe(await run()) + }) + }) }) describe('validateMultirespondentSubmission', () => { @@ -1145,5 +1237,152 @@ describe('Multirespondent Submission Middleware', () => { expect(mockNext).not.toHaveBeenCalled() expect(mockRes.status).toHaveBeenCalledWith(400) }) + + describe('step-token write-guard', () => { + const RAW_STEP_TOKEN = stepToken.generate() + + // Build a request whose decrypt-gate will pass (matching the beforeEach + // mocks), varying only the step-token bits. + const createGuardReq = ({ + flagOn, + stepTokenHash, + presentedToken, + }: { + flagOn: boolean + stepTokenHash?: string + presentedToken?: string + }) => { + const mockReq = createMockReq({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + }) + mockReq.body.responses = { + [EDITABLE_FIELD_ID]: { + fieldType: BasicField.ShortText, + answer: 'updated', + question: 'Editable Field', + provenance: {}, + }, + [NON_EDITABLE_FIELD_ID]: { + fieldType: BasicField.ShortText, + answer: 'locked-value', + question: 'Non-editable Field', + provenance: {}, + }, + } + mockReq.body.submissionSecretKey = 'submission-secret-key' + mockReq.body.stepToken = presentedToken + mockReq.growthbook = { + isOn: jest.fn( + (flag: string) => flagOn && flag === featureFlags.mrfStepWriteToken, + ), + } + mockReq.formsg = { + formDef: { + _id: MOCK_FORM_ID, + form_fields: SNAPSHOT_FORM_FIELDS, + form_logics: [], + workflow: SNAPSHOT_WORKFLOW, + }, + mrfSubmission: { ...MOCK_MRF_SUBMISSION_V2, stepTokenHash }, + } + return mockReq + } + + it('should advance when a valid step token accompanies a valid decrypt', async () => { + const mockReq = createGuardReq({ + flagOn: true, + stepTokenHash: stepToken.hash(RAW_STEP_TOKEN), + presentedToken: RAW_STEP_TOKEN, + }) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await validateMultirespondentSubmission( + mockReq, + mockRes as any, + mockNext, + ) + + expect(mockNext).toHaveBeenCalled() + expect(mockRes.status).not.toHaveBeenCalled() + }) + + it('should return 403 and not advance when the presented token is wrong (decrypt still valid)', async () => { + const mockReq = createGuardReq({ + flagOn: true, + stepTokenHash: stepToken.hash(RAW_STEP_TOKEN), + presentedToken: stepToken.generate(), // wrong token + }) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await validateMultirespondentSubmission( + mockReq, + mockRes as any, + mockNext, + ) + + expect(mockNext).not.toHaveBeenCalled() + expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) + }) + + it('should return 403 when the token is absent but the row carries a hash (decrypt-gate alone no longer advances)', async () => { + const mockReq = createGuardReq({ + flagOn: true, + stepTokenHash: stepToken.hash(RAW_STEP_TOKEN), + presentedToken: undefined, // absent + }) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await validateMultirespondentSubmission( + mockReq, + mockRes as any, + mockNext, + ) + + expect(mockNext).not.toHaveBeenCalled() + expect(mockRes.status).toHaveBeenCalledWith(StatusCodes.FORBIDDEN) + }) + + it('should advance on a legacy row without a hash even with no token (migration grace)', async () => { + const mockReq = createGuardReq({ + flagOn: true, + stepTokenHash: undefined, // legacy in-flight row + presentedToken: undefined, + }) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await validateMultirespondentSubmission( + mockReq, + mockRes as any, + mockNext, + ) + + expect(mockNext).toHaveBeenCalled() + expect(mockRes.status).not.toHaveBeenCalled() + }) + + it('should not require a token when the flag is off, even if the row carries a hash (regression)', async () => { + const mockReq = createGuardReq({ + flagOn: false, + stepTokenHash: stepToken.hash(RAW_STEP_TOKEN), + presentedToken: undefined, + }) + const mockNext = jest.fn() + const mockRes = createMockRes() + + await validateMultirespondentSubmission( + mockReq, + mockRes as any, + mockNext, + ) + + expect(mockNext).toHaveBeenCalled() + expect(mockRes.status).not.toHaveBeenCalled() + }) + }) }) }) diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts index e3a8828778..f5562a8aa2 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.service.spec.ts @@ -7,6 +7,7 @@ import { FormFieldDto, FormResponseMode, FormWorkflowStepDto, + SubmissionType, WorkflowStatus, WorkflowType, } from 'formsg-shared/types' @@ -23,12 +24,14 @@ import { } from 'src/types' import { MultirespondentSubmissionDto, SnapshottedFormDef } from 'src/types/api' +import { DatabaseConflictError } from '../../../core/core.errors' import { FormRespondentSingleSubmissionValidationError } from '../../../form/form.errors' import { MrfReminderInvalidWorkflowStepError, MrfReminderRecipientEmailsEmptyError, SubmissionSaveError, } from '../../submission.errors' +import { mapRouteError } from '../../submission.utils' import * as MultirespondentSubmissionService from '../multirespondent-submission.service' import { createMultiRespondentFormSubmission, @@ -36,7 +39,9 @@ import { performMultiRespondentPostSubmissionCreateActions, performMultiRespondentPostSubmissionUpdateActions, sendNextStepReminderEmail, + updateMultiRespondentFormSubmission, } from '../multirespondent-submission.service' +import * as stepToken from '../step-token' jest.mock('src/app/modules/datadog/datadog.utils') jest.mock('src/app/services/mail/mail.utils') @@ -3391,4 +3396,325 @@ describe('multirespondent-submission.service', () => { ) }) }) + + describe('step-token lifecycle', () => { + const fieldId = new ObjectId().toHexString() + const stepId0 = new ObjectId().toHexString() + const stepId1 = new ObjectId().toHexString() + + const twoStepWorkflow: FormWorkflowStepDto[] = [ + { + _id: stepId0, + workflow_type: WorkflowType.Static, + emails: [], + edit: [fieldId], + }, + { + _id: stepId1, + workflow_type: WorkflowType.Static, + emails: ['next@example.com'], + edit: [fieldId], + }, + ] + + const buildForm = (): IPopulatedMultirespondentForm => + ({ + _id: mockFormId, + authType: FormAuthType.NIL, + responseMode: FormResponseMode.Multirespondent, + title: 'Test form', + form_fields: [ + { _id: fieldId, fieldType: BasicField.ShortText, title: 'Q1' }, + ], + form_logics: [], + workflow: twoStepWorkflow, + isSingleSubmission: false, + getUniqueMyInfoAttrs: jest.fn().mockReturnValue([]), + }) as unknown as IPopulatedMultirespondentForm + + const buildSnapshottedFormDef = (): SnapshottedFormDef => + ({ + _id: mockFormId, + title: 'Test form', + form_fields: [ + { _id: fieldId, fieldType: BasicField.ShortText, title: 'Q1' }, + ], + form_logics: [], + workflow: twoStepWorkflow, + }) as unknown as SnapshottedFormDef + + const buildPayload = ( + overrides: Partial = {}, + ): MultirespondentSubmissionDto => ({ + submissionPublicKey: 'submission-public-key', + encryptedSubmissionSecretKey: 'encrypted-submission-secret-key', + encryptedContent: 'encrypted-content', + submissionSecretKey: 'submission-secret-key', + version: 2, + workflowStep: 0, + responses: { + [fieldId]: { fieldType: BasicField.ShortText, answer: 'answer' }, + }, + mrfVersion: 1, + ...overrides, + }) + + it('persists stepTokenHash and encryptedStepToken on create', async () => { + const raw = stepToken.generate() + const result = await createMultiRespondentFormSubmission({ + form: buildForm(), + encryptedPayload: buildPayload({ + stepToken: raw, + stepTokenHash: stepToken.hash(raw), + encryptedStepToken: 'wrapped-token-A', + }), + logMeta: { action: 'test' }, + }) + + expect(result.isOk()).toBe(true) + const saved = await getMultirespondentSubmissionModel(mongoose).findById( + result._unsafeUnwrap()._id, + ) + expect(saved?.stepTokenHash).toBe(stepToken.hash(raw)) + expect(saved?.encryptedStepToken).toBe('wrapped-token-A') + }) + + it('clears token fields on update when the next step does not generate one', async () => { + const Model = getMultirespondentSubmissionModel(mongoose) + // Row already carries the previous step's token (minted under flag ON). + const prevRaw = stepToken.generate() + const row = await Model.create({ + form: mockFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: twoStepWorkflow, + submissionPublicKey: 'pk', + encryptedSubmissionSecretKey: 'esk', + encryptedContent: 'ec', + version: 2, + workflowStep: 0, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + stepTokenHash: stepToken.hash(prevRaw), + encryptedStepToken: 'wrapped-token-prev', + }) + expect(row.stepTokenHash).toBe(stepToken.hash(prevRaw)) + expect(row.encryptedStepToken).toBe('wrapped-token-prev') + + // Flag OFF advance: middleware omits both token fields from the payload. + const result = await updateMultiRespondentFormSubmission({ + submissionId: row._id.toString(), + snapshottedFormDef: buildSnapshottedFormDef(), + encryptedPayload: buildPayload({ workflowStep: 1 }), + logMeta: { action: 'test' }, + }) + + expect(result.isOk()).toBe(true) + const saved = await Model.findById(row._id).lean() + expect(saved?.stepTokenHash).toBeUndefined() + expect(saved?.encryptedStepToken).toBeUndefined() + expect('stepTokenHash' in (saved as object)).toBe(false) + expect('encryptedStepToken' in (saved as object)).toBe(false) + }) + + it('rotates the token on advance, upgrading a legacy row that carried no hash (migration)', async () => { + const Model = getMultirespondentSubmissionModel(mongoose) + // Legacy in-flight row: no stepTokenHash / encryptedStepToken. + const legacy = await Model.create({ + form: mockFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: twoStepWorkflow, + submissionPublicKey: 'pk', + encryptedSubmissionSecretKey: 'esk', + encryptedContent: 'ec', + version: 2, + workflowStep: 0, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + }) + expect(legacy.stepTokenHash).toBeUndefined() + + const nextRaw = stepToken.generate() + const result = await updateMultiRespondentFormSubmission({ + submissionId: legacy._id.toString(), + snapshottedFormDef: buildSnapshottedFormDef(), + encryptedPayload: buildPayload({ + workflowStep: 1, + stepToken: nextRaw, + stepTokenHash: stepToken.hash(nextRaw), + encryptedStepToken: 'wrapped-token-B', + }), + logMeta: { action: 'test' }, + }) + + expect(result.isOk()).toBe(true) + const saved = await Model.findById(legacy._id) + expect(saved?.stepTokenHash).toBe(stepToken.hash(nextRaw)) + expect(saved?.encryptedStepToken).toBe('wrapped-token-B') + // The freshly minted token verifies against the rotated hash (loop-back: + // a stale/previous token would not). + expect(stepToken.verify(nextRaw, saved?.stepTokenHash as string)).toBe( + true, + ) + expect( + stepToken.verify(stepToken.generate(), saved?.stepTokenHash as string), + ).toBe(false) + }) + + it('rotates both token fields on a flag-on advance from a row that already carried a token (D1)', async () => { + const Model = getMultirespondentSubmissionModel(mongoose) + const prevRaw = stepToken.generate() + const row = await Model.create({ + form: mockFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: twoStepWorkflow, + submissionPublicKey: 'pk', + encryptedSubmissionSecretKey: 'esk', + encryptedContent: 'ec', + version: 2, + workflowStep: 0, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + stepTokenHash: stepToken.hash(prevRaw), + encryptedStepToken: 'wrapped-token-prev', + }) + + const nextRaw = stepToken.generate() + const result = await updateMultiRespondentFormSubmission({ + submissionId: row._id.toString(), + snapshottedFormDef: buildSnapshottedFormDef(), + encryptedPayload: buildPayload({ + workflowStep: 1, + stepToken: nextRaw, + stepTokenHash: stepToken.hash(nextRaw), + encryptedStepToken: 'wrapped-token-next', + }), + logMeta: { action: 'test' }, + }) + + expect(result.isOk()).toBe(true) + const saved = await Model.findById(row._id) + expect(saved?.stepTokenHash).toBe(stepToken.hash(nextRaw)) + expect(saved?.encryptedStepToken).toBe('wrapped-token-next') + // New token verifies; the previous step's token must not. + expect(stepToken.verify(nextRaw, saved?.stepTokenHash as string)).toBe( + true, + ) + expect(stepToken.verify(prevRaw, saved?.stepTokenHash as string)).toBe( + false, + ) + }) + + it('leaves a legacy no-hash row absent after a flag-off advance (no stale key introduced) (D1)', async () => { + const Model = getMultirespondentSubmissionModel(mongoose) + // Legacy in-flight row: no token fields at all. + const legacy = await Model.create({ + form: mockFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: twoStepWorkflow, + submissionPublicKey: 'pk', + encryptedSubmissionSecretKey: 'esk', + encryptedContent: 'ec', + version: 2, + workflowStep: 0, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + }) + + const result = await updateMultiRespondentFormSubmission({ + submissionId: legacy._id.toString(), + snapshottedFormDef: buildSnapshottedFormDef(), + encryptedPayload: buildPayload({ workflowStep: 1 }), + logMeta: { action: 'test' }, + }) + + expect(result.isOk()).toBe(true) + const saved = await Model.findById(legacy._id).lean() + expect(saved?.stepTokenHash).toBeUndefined() + expect(saved?.encryptedStepToken).toBeUndefined() + expect('stepTokenHash' in (saved as object)).toBe(false) + expect('encryptedStepToken' in (saved as object)).toBe(false) + }) + + it('surfaces a lost concurrent-write race as a 409 conflict, not a 500', async () => { + const Model = getMultirespondentSubmissionModel(mongoose) + const doc = await Model.create({ + form: mockFormId, + submissionType: SubmissionType.Multirespondent, + form_fields: [], + form_logics: [], + workflow: twoStepWorkflow, + submissionPublicKey: 'pk', + encryptedSubmissionSecretKey: 'esk', + encryptedContent: 'ec', + version: 2, + workflowStep: 0, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + }) + + // Simulate the optimistic-concurrency loser: the save rejects with a + // Mongoose VersionError (__v mismatch on the submittedSteps array). + const versionError = new mongoose.Error.VersionError( + doc as any, + (doc as any).__v, + ['submittedSteps'], + ) + const saveSpy = jest + .spyOn(Model.prototype, 'save') + .mockRejectedValueOnce(versionError) + + const result = await updateMultiRespondentFormSubmission({ + submissionId: doc._id.toString(), + snapshottedFormDef: buildSnapshottedFormDef(), + encryptedPayload: buildPayload({ workflowStep: 1 }), + logMeta: { action: 'test' }, + }) + + expect(result.isErr()).toBe(true) + const error = result._unsafeUnwrapErr() + expect(error).toBeInstanceOf(DatabaseConflictError) + // Non-retryable 409, not a 5xx default. + expect(mapRouteError(error).statusCode).toBe(409) + + saveSpy.mockRestore() + }) + + it('threads the raw step token into the next respondent magic link on create', async () => { + const raw = stepToken.generate() + const sendSpy = jest + .spyOn(MailService, 'sendMRFWorkflowStepEmail') + .mockReturnValue(okAsync(true)) + + await performMultiRespondentPostSubmissionCreateActions({ + submission: { + id: mockSubmissionId, + submittedSteps: [ + { isApproval: false, submittedAt: new Date().toISOString() }, + ], + } as unknown as IMultirespondentSubmissionSchema, + submissionId: mockSubmissionId, + form: buildForm(), + encryptedPayload: buildPayload({ stepToken: raw }), + logMeta: { action: 'test' } as any, + }) + + expect(sendSpy).toHaveBeenCalled() + expect(sendSpy.mock.calls[0][0].responseUrl).toContain( + `&token=${encodeURIComponent(raw)}`, + ) + }) + }) }) diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts index 0fd0328362..1f447ba8c9 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/multirespondent-submission.utils.spec.ts @@ -293,6 +293,36 @@ describe('multirespondent-submission.utils', () => { expect(staticWorkflowStep).toBeDefined() expect(staticWorkflowStep).not.toContainKey('emails') }) + + it('should never expose encryptedStepToken in the public DTO', () => { + // Arrange + const submissionData = { + submissionType: SubmissionType.Multirespondent, + _id: new ObjectId(), + created: new Date(), + submissionPublicKey: 'some public key', + encryptedSubmissionSecretKey: 'some encrypted secret key', + encryptedContent: 'some encrypted content', + encryptedStepToken: 'some encrypted step token', + workflow: [], + workflowStep: 1, + form_fields: [], + form_logics: [], + attachmentMetadata: {}, + version: 3, + mrfVersion: 3, + } as unknown as MultirespondentSubmissionData + + // Act + const actual = createPublicMultirespondentSubmissionDto( + submissionData, + {}, + ) + + // Assert + expect(actual.encryptedStepToken).toBeUndefined() + expect(JSON.stringify(actual)).not.toContain('encryptedStepToken') + }) }) describe('createMultirespondentSubmissionDto', () => { diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/step-token.spec.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/step-token.spec.ts new file mode 100644 index 0000000000..c8022fbd22 --- /dev/null +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/__tests__/step-token.spec.ts @@ -0,0 +1,124 @@ +import nacl from 'tweetnacl' +import { decodeBase64, encodeBase64, encodeUTF8 } from 'tweetnacl-util' + +import * as stepToken from '../step-token' + +/** + * Client-side unwrap, mirroring how an admin holding the form secret key would + * recover the raw token from `encryptedStepToken` to resend a reminder. The + * server never does this — it only ever holds the form public key. + */ +const unwrapStepToken = ( + encryptedStepToken: string, + formSecretKey: string, +): string | null => { + const [senderPublicKey, nonceAndCipher] = encryptedStepToken.split(';') + const [nonce, cipher] = nonceAndCipher.split(':').map(decodeBase64) + const opened = nacl.box.open( + cipher, + nonce, + decodeBase64(senderPublicKey), + decodeBase64(formSecretKey), + ) + return opened ? encodeUTF8(opened) : null +} + +describe('step-token primitive', () => { + describe('generate', () => { + it('should generate a URL-safe token with at least 256 bits of entropy', () => { + // Act + const token = stepToken.generate() + + // Assert: base64url of 32 bytes (256 bits) is 43 chars, no padding/unsafe chars + expect(token).toMatch(/^[A-Za-z0-9_-]+$/) + const decodedByteLength = Buffer.from(token, 'base64url').length + expect(decodedByteLength).toBeGreaterThanOrEqual(32) + }) + + it('should generate a unique token on each call', () => { + const tokens = new Set( + Array.from({ length: 100 }, () => stepToken.generate()), + ) + expect(tokens.size).toBe(100) + }) + }) + + describe('hash', () => { + it('should produce a lowercase hex sha256 digest', () => { + // 'test' sha256 is a well-known vector; proves plain sha256, no salt. + expect(stepToken.hash('test')).toBe( + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + ) + }) + + it('should be deterministic and unsalted (same input -> same digest)', () => { + const token = stepToken.generate() + expect(stepToken.hash(token)).toBe(stepToken.hash(token)) + }) + + it('should produce different digests for different tokens', () => { + expect(stepToken.hash(stepToken.generate())).not.toBe( + stepToken.hash(stepToken.generate()), + ) + }) + }) + + describe('verify', () => { + it('should return true when the raw token matches the stored hash', () => { + const token = stepToken.generate() + expect(stepToken.verify(token, stepToken.hash(token))).toBe(true) + }) + + it('should return false when the raw token is tampered', () => { + const token = stepToken.generate() + const otherToken = stepToken.generate() + expect(stepToken.verify(otherToken, stepToken.hash(token))).toBe(false) + }) + + it('should return false when the stored hash is truncated', () => { + const token = stepToken.generate() + const truncatedHash = stepToken.hash(token).slice(0, -2) + expect(stepToken.verify(token, truncatedHash)).toBe(false) + }) + + it('should return false for an empty or malformed stored hash', () => { + const token = stepToken.generate() + expect(stepToken.verify(token, '')).toBe(false) + expect(stepToken.verify(token, 'not-hex-zzzz')).toBe(false) + }) + }) + + describe('wrap', () => { + const formKeypair = nacl.box.keyPair() + const formPublicKey = encodeBase64(formKeypair.publicKey) + const formSecretKey = encodeBase64(formKeypair.secretKey) + + it('should round-trip: unwrapping with the form secret key recovers a token that verifies against the stored hash', () => { + // Arrange + const rawToken = stepToken.generate() + const storedHash = stepToken.hash(rawToken) + + // Act + const encryptedStepToken = stepToken.wrap(rawToken, formPublicKey) + const recovered = unwrapStepToken(encryptedStepToken, formSecretKey) + + // Assert + expect(recovered).toBe(rawToken) + expect(stepToken.verify(recovered as string, storedHash)).toBe(true) + }) + + it('should not be unwrappable with the wrong secret key', () => { + const rawToken = stepToken.generate() + const encryptedStepToken = stepToken.wrap(rawToken, formPublicKey) + const wrongSecretKey = encodeBase64(nacl.box.keyPair().secretKey) + expect(unwrapStepToken(encryptedStepToken, wrongSecretKey)).toBeNull() + }) + + it('should produce a fresh ciphertext each call (random nonce)', () => { + const rawToken = stepToken.generate() + expect(stepToken.wrap(rawToken, formPublicKey)).not.toBe( + stepToken.wrap(rawToken, formPublicKey), + ) + }) + }) +}) diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts index b96f890ec9..575aca2044 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.controller.ts @@ -389,10 +389,10 @@ export const handleGetMultirespondentSubmissionForRespondent: ControllerHandler< const sendPendingMrfSubmissionReminder: ControllerHandler< { formId: string; submissionId: string }, unknown, - { submissionSecretKey: string } + { submissionSecretKey: string; stepToken?: string } > = async (req, res) => { const { formId, submissionId } = req.params - const { submissionSecretKey } = req.body + const { submissionSecretKey, stepToken } = req.body const authedUserId = (req.session as AuthedSessionData).user._id const logMeta = { @@ -443,7 +443,7 @@ const sendPendingMrfSubmissionReminder: ControllerHandler< responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( form._id, submissionId, - { key: submissionSecretKey }, + { key: submissionSecretKey, stepToken }, )}`, formTitle: form.title, formId, diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts index 2a8fc508cc..4ce5a21f72 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.middleware.ts @@ -61,6 +61,7 @@ import { MissingSubmitterIdError, MrfWorkflowOverflowError, ProcessingError, + StepTokenVerificationError, SubmissionEncryptionMismatchError, SubmissionEncryptionVerificationError, SubmissionNotFoundError, @@ -86,6 +87,7 @@ import { StrippedAttachmentResponseV3, } from './multirespondent-submission.types' import { validateMrfFieldResponses } from './multirespondent-submission.utils' +import * as stepToken from './step-token' const logger = createLoggerWithLabel(module) @@ -110,17 +112,22 @@ export const validateMultirespondentSubmissionParams = celebrate({ [Segments.BODY]: multirespondentSubmissionBodySchema, }) +const multirespondentSubmissionKeySchema = Joi.object({ + submissionSecretKey: Joi.string().required(), + // RATIONALE: step token is optional for backwards compatibility with in-flight submissions + // and allow `mrf-step-write-token` gb flag to be off. + stepToken: Joi.string().optional(), +}) + const updateMultirespondentSubmissionBodySchema = - multirespondentSubmissionBodySchema.append({ - submissionSecretKey: Joi.string().required(), - }) + multirespondentSubmissionBodySchema.concat(multirespondentSubmissionKeySchema) export const validateUpdateMultirespondentSubmissionParams = celebrate({ [Segments.BODY]: updateMultirespondentSubmissionBodySchema, }) export const validateMultirespondentRemindBody = celebrate({ - [Segments.BODY]: Joi.object({ submissionSecretKey: Joi.string().required() }), + [Segments.BODY]: multirespondentSubmissionKeySchema, }) const retrieveMultirespondentSubmissionIfExists = ( @@ -459,8 +466,23 @@ export const validateMultirespondentSubmission = async ( return ( // Step 0: Prepare by retrieving relevant reference data ok(mrfSubmission) + // Step 0a: Verify write permissions by verifying step bearer token if exists + .andThen((mrfSubmission) => { + const isStepWriteTokenEnabled = + req.growthbook?.isOn(featureFlags.mrfStepWriteToken) ?? false + if (isStepWriteTokenEnabled && mrfSubmission?.stepTokenHash) { + const presentedToken = req.body.stepToken + if ( + !presentedToken || + !stepToken.verify(presentedToken, mrfSubmission.stepTokenHash) + ) { + return err(new StepTokenVerificationError()) + } + } + return ok(mrfSubmission) + }) .andThen((mrfSubmission) => - // Step 0a: If its an existing submission, use the reference data from + // Step 0b: If its an existing submission, use the reference data from // the submission rather than the form mrfSubmission ? ok({ @@ -492,7 +514,7 @@ export const validateMultirespondentSubmission = async ( form_fields, form_logics, }) => { - // Step 0b: Determine editable fields based on the workflow step, if it exists. + // Step 0c: Determine editable fields based on the workflow step, if it exists. const editableFieldIds = ( workflow[workflowStep] ? workflow[workflowStep].edit @@ -916,6 +938,24 @@ export const encryptSubmission = async ( req.body.version, ) + const isStepWriteTokenEnabled = + req.growthbook?.isOn(featureFlags.mrfStepWriteToken) ?? false + let mintedStepToken: + | { + stepToken: string + stepTokenHash: string + encryptedStepToken: string + } + | undefined + if (isStepWriteTokenEnabled) { + const rawStepToken = stepToken.generate() + mintedStepToken = { + stepToken: rawStepToken, + stepTokenHash: stepToken.hash(rawStepToken), + encryptedStepToken: stepToken.wrap(rawStepToken, formPublicKey), + } + } + req.formsg.encryptedPayload = { attachments: encryptedAttachments, responseMetadata: req.body.responseMetadata, @@ -931,6 +971,7 @@ export const encryptSubmission = async ( * MRF Version: 2 — V4 encrypted responses (with provenance) */ mrfVersion: useV4Encryption ? 2 : 1, + ...mintedStepToken, } return next() diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts index d8c2285639..daba71d924 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.service.ts @@ -42,7 +42,7 @@ import { import MailService from '../../../services/mail/mail.service' import { generateAutoreplyPdf } from '../../../services/mail/mail.utils' import { transformMongoError } from '../../../utils/handle-mongo-error' -import { DatabaseError } from '../../core/core.errors' +import { DatabaseError, PossibleDatabaseError } from '../../core/core.errors' import { FormRespondentSingleSubmissionValidationError } from '../../form/form.errors' import { isFormMultirespondent } from '../../form/form.utils' import { WebhookFactory } from '../../webhook/webhook.factory' @@ -732,6 +732,8 @@ export const createMultiRespondentFormSubmission = ({ version, mrfVersion, hashedSubmitterId, + stepTokenHash, + encryptedStepToken, } = encryptedPayload const nextStepNumber = 1 // since current step is 0 @@ -785,6 +787,8 @@ export const createMultiRespondentFormSubmission = ({ workflowStep: 0, mrfVersion, submittedSteps: [submittedStepMeta], + stepTokenHash, + encryptedStepToken, } const saveSubmission = async () => { @@ -1041,7 +1045,7 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ attachments?: IAttachmentInfo[] growthbook?: GrowthBook }): ResultAsync => { - const { submissionSecretKey, responses } = encryptedPayload + const { submissionSecretKey, responses, stepToken } = encryptedPayload const currentStepNumber = 0 // if there is no workflow, every field is an active field @@ -1139,7 +1143,7 @@ export const performMultiRespondentPostSubmissionCreateActions = ({ responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( form._id, submissionId, - { key: submissionSecretKey }, + { key: submissionSecretKey, stepToken }, )}`, formId: form._id, submissionId, @@ -1186,7 +1190,10 @@ export const updateMultiRespondentFormSubmission = ({ logMeta: CustomLoggerParams['meta'] }): ResultAsync< IMultirespondentSubmissionSchema & { _id: mongoose.Types.ObjectId }, - AttachmentUploadError | SubmissionSaveError | SubmissionNotFoundError + | AttachmentUploadError + | SubmissionSaveError + | SubmissionNotFoundError + | PossibleDatabaseError > => { logMeta = { ...logMeta, @@ -1221,6 +1228,8 @@ export const updateMultiRespondentFormSubmission = ({ version, workflowStep, mrfVersion, + stepTokenHash, + encryptedStepToken, } = encryptedPayload const nextStepNumber = workflowStep + 1 @@ -1300,10 +1309,15 @@ export const updateMultiRespondentFormSubmission = ({ submission.workflowStep = workflowStep submission.attachmentMetadata = attachmentMetadata submission.mrfVersion = mrfVersion + submission.stepTokenHash = stepTokenHash + submission.encryptedStepToken = encryptedStepToken return ResultAsync.fromPromise( submission.save().then(() => ({ submission, responseMetadata })), (error) => { + if (error instanceof mongoose.Error.VersionError) { + return transformMongoError(error) + } logger.error({ message: 'Multirespondent submission save error', meta: logMeta, @@ -1348,7 +1362,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ | ExpectedResponseNotFoundError | InvalidApprovalFieldTypeError > => { - const { responses, submissionSecretKey } = encryptedPayload + const { responses, submissionSecretKey, stepToken } = encryptedPayload logMeta = { ...logMeta, @@ -1506,7 +1520,7 @@ export const performMultiRespondentPostSubmissionUpdateActions = ({ responseUrl: `${appUrl}/${getMultirespondentSubmissionEditPath( snapshottedFormDef._id, submissionId, - { key: submissionSecretKey }, + { key: submissionSecretKey, stepToken }, )}`, formId: snapshottedFormDef._id, submissionId, diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts index 4835fa87de..35878c355f 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.types.ts @@ -48,6 +48,7 @@ export type ProcessedMultirespondentSubmissionHandlerType = ControllerHandler< SubmissionResponseDto | SubmissionErrorDto, Omit & { submissionSecretKey?: string + stepToken?: string responses: ParsedClearFormFieldResponsesV3 }, { captchaResponse?: unknown; captchaType?: unknown } @@ -94,6 +95,10 @@ export type MultirespondentSubmissionContent = { workflowStep: number mrfVersion: number submittedSteps: SubmittedStep[] + // RATIONALE: optional for backwards compatibility on + // in-flight mrf steps which do not have a step token at time of creation. + stepTokenHash?: string + encryptedStepToken?: string } export type StrippedAttachmentResponseV4 = Omit< diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts index 9919c7d38e..527890c39c 100644 --- a/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/multirespondent-submission.utils.ts @@ -70,6 +70,7 @@ export const createMultirespondentSubmissionDto = ( encryptedContent: submissionData.encryptedContent, verifiedContent: submissionData.verifiedContent, encryptedSubmissionSecretKey: submissionData.encryptedSubmissionSecretKey, + encryptedStepToken: submissionData.encryptedStepToken, attachmentMetadata: attachmentPresignedUrls, version: submissionData.version, workflowStep: submissionData.workflowStep, @@ -102,6 +103,7 @@ export const createPublicMultirespondentSubmissionDto = ( submissionData.form_fields, ), workflow: stripWorkflowEmails(submissionData.workflow), + encryptedStepToken: undefined, } } diff --git a/apps/backend/src/app/modules/submission/multirespondent-submission/step-token.ts b/apps/backend/src/app/modules/submission/multirespondent-submission/step-token.ts new file mode 100644 index 0000000000..5390608a43 --- /dev/null +++ b/apps/backend/src/app/modules/submission/multirespondent-submission/step-token.ts @@ -0,0 +1,43 @@ +import { createHash, randomBytes, timingSafeEqual } from 'crypto' +import nacl from 'tweetnacl' +import { decodeBase64, decodeUTF8, encodeBase64 } from 'tweetnacl-util' + +const TOKEN_BYTES = 32 + +export const generate = (): string => + randomBytes(TOKEN_BYTES).toString('base64url') + +export const hash = (rawToken: string): string => + createHash('sha256').update(rawToken).digest('hex') + +/** + * Verify a presented raw token against a stored hash in constant time. + */ +export const verify = (rawToken: string, storedHash: string): boolean => { + const computed = Buffer.from(hash(rawToken), 'hex') + const stored = Buffer.from(storedHash, 'hex') + if (computed.length !== stored.length || stored.length === 0) { + return false + } + return timingSafeEqual(computed, stored) +} + +/** + * Wrap a raw token to the form's public key, producing `encryptedStepToken`. + * + * Uses the same scheme as `encryptedSubmissionSecretKey`. However, it is defined here so + * that the SDK does not expose the wrapping logic to public clients. + */ +export const wrap = (rawToken: string, formPublicKey: string): string => { + const ephemeralKeypair = nacl.box.keyPair() + const nonce = nacl.randomBytes(nacl.box.nonceLength) + const ciphertext = nacl.box( + decodeUTF8(rawToken), + nonce, + decodeBase64(formPublicKey), + ephemeralKeypair.secretKey, + ) + return `${encodeBase64(ephemeralKeypair.publicKey)};${encodeBase64( + nonce, + )}:${encodeBase64(ciphertext)}` +} diff --git a/apps/backend/src/app/modules/submission/submission.errors.ts b/apps/backend/src/app/modules/submission/submission.errors.ts index c5193f8794..a67ade7282 100644 --- a/apps/backend/src/app/modules/submission/submission.errors.ts +++ b/apps/backend/src/app/modules/submission/submission.errors.ts @@ -344,3 +344,16 @@ export class SubmissionEncryptionMismatchError extends ApplicationError { super(message, undefined, ErrorCodes.SUBMISSION_ENCRYPTION_MISMATCH) } } + +/** + * Thrown when an MRF submission currently has a step token to gate write permissions. + * token but the submitted step is missing this token or the provided token does not + * match the pending step's stored hash. + */ +export class StepTokenVerificationError extends ApplicationError { + constructor( + message = 'You cannot submit this form because the link is invalid. Please check the link and try again.', + ) { + super(message, undefined, ErrorCodes.SUBMISSION_MRF_STEP_TOKEN_INVALID) + } +} diff --git a/apps/backend/src/app/modules/submission/submission.utils.ts b/apps/backend/src/app/modules/submission/submission.utils.ts index d63b8cbcf9..1894a4cd31 100644 --- a/apps/backend/src/app/modules/submission/submission.utils.ts +++ b/apps/backend/src/app/modules/submission/submission.utils.ts @@ -154,6 +154,7 @@ import { ParseVirusScannerLambdaPayloadError, ProcessingError, ResponseModeError, + StepTokenVerificationError, SubmissionFailedError, SubmissionNotFoundError, SubmissionSaveError, @@ -208,6 +209,11 @@ const errorMapper: MapRouteError = ( statusCode: StatusCodes.FORBIDDEN, errorMessage: error.message, } + case StepTokenVerificationError: + return { + statusCode: StatusCodes.FORBIDDEN, + errorMessage: error.message, + } case FormRespondentSingleSubmissionValidationError: return { statusCode: StatusCodes.BAD_REQUEST, diff --git a/apps/backend/src/types/api/multirespondent_submission.ts b/apps/backend/src/types/api/multirespondent_submission.ts index 09e48758f3..1ec626da0b 100644 --- a/apps/backend/src/types/api/multirespondent_submission.ts +++ b/apps/backend/src/types/api/multirespondent_submission.ts @@ -65,4 +65,7 @@ export type MultirespondentSubmissionDto = { submitterId?: string responses: FieldResponsesV3 mrfVersion: number + stepToken?: string + stepTokenHash?: string + encryptedStepToken?: string } diff --git a/apps/backend/src/types/submission.ts b/apps/backend/src/types/submission.ts index fc57c084a8..fa148b6130 100644 --- a/apps/backend/src/types/submission.ts +++ b/apps/backend/src/types/submission.ts @@ -231,6 +231,7 @@ export type MultirespondentSubmissionData = { | 'workflowStep' | 'mrfVersion' | 'submittedSteps' + | 'encryptedStepToken' > & Document diff --git a/apps/frontend/src/features/admin-form/common/mutations.ts b/apps/frontend/src/features/admin-form/common/mutations.ts index e9631d8ae9..b583c57609 100644 --- a/apps/frontend/src/features/admin-form/common/mutations.ts +++ b/apps/frontend/src/features/admin-form/common/mutations.ts @@ -573,15 +573,18 @@ export const useFormRemindersMutations = () => { formId, submissionId, submissionSecretKey, + stepToken, }: { formId: string submissionId: string submissionSecretKey: string + stepToken?: string }) => { return sendReminderForPendingMrfResponse({ formId, submissionId, submissionSecretKey, + stepToken, }) }, { diff --git a/apps/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts b/apps/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts index aa3293b534..758fd4c745 100644 --- a/apps/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts +++ b/apps/frontend/src/features/admin-form/responses/AdminSubmissionsService.ts @@ -100,7 +100,7 @@ export const getDecryptedSubmissionById = async ({ submissionId, }) - let processedContent, submissionSecretKey, mrfVersion + let processedContent, submissionSecretKey, mrfVersion, stepToken switch (encryptedSubmission.submissionType) { case SubmissionType.Encrypt: { const decryptedContent = formsgSdk.crypto.decrypt(secretKey, { @@ -126,6 +126,7 @@ export const getDecryptedSubmissionById = async ({ encryptedSubmission.encryptedSubmissionSecretKey, verifiedContent: encryptedSubmission.verifiedContent, version: encryptedSubmission.version, + encryptedStepToken: encryptedSubmission.encryptedStepToken, }, formFieldsMeta, ) @@ -148,6 +149,7 @@ export const getDecryptedSubmissionById = async ({ ) submissionSecretKey = decryptedV4.submissionSecretKey mrfVersion = encryptedSubmission.mrfVersion + stepToken = decryptedV4.stepToken break } } @@ -174,6 +176,7 @@ export const getDecryptedSubmissionById = async ({ : undefined, responses, mrfVersion, + stepToken, } } diff --git a/apps/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx b/apps/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx index 05ea4cd9ec..9d9a99918a 100644 --- a/apps/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx +++ b/apps/frontend/src/features/admin-form/responses/IndividualResponsePage/IndividualResponsePage.tsx @@ -187,6 +187,7 @@ export const IndividualResponsePage = (): JSX.Element => { window.location.origin }/${getMultirespondentSubmissionEditPath(form?._id ?? '', submissionId, { key: data?.submissionSecretKey || '', + stepToken: data?.stepToken, })}` const workflowStatus = data?.mrf?.workflowStatus diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/SendReminderButton.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/SendReminderButton.tsx index 0d03344806..134ae3aad8 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/SendReminderButton.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/SendReminderButton.tsx @@ -30,6 +30,7 @@ export const SendReminderButton = ({ submissionId, }) const submissionSecretKey = submissionData?.submissionSecretKey + const stepToken = submissionData?.stepToken if (!formId) { return null @@ -59,6 +60,7 @@ export const SendReminderButton = ({ formId, submissionId, submissionSecretKey, + stepToken, }) setIsSent(true) }} diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.test.ts b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.test.ts new file mode 100644 index 0000000000..4502f9f927 --- /dev/null +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.test.ts @@ -0,0 +1,49 @@ +import { ApiService } from '~services/ApiService' + +import { sendReminderForPendingMrfResponse } from './ReminderService' + +vi.mock('~services/ApiService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ApiService: { post: vi.fn() }, + } +}) + +const mockPost = ApiService.post as unknown as ReturnType + +const MOCK_FORM_ID = '61540ece3d4a6e50ac0cc6ff' +const MOCK_SUBMISSION_ID = '61540ece3d4a6e50ac0cc700' + +beforeEach(() => { + mockPost.mockReset() + mockPost.mockResolvedValue({ data: undefined }) +}) + +describe('sendReminderForPendingMrfResponse', () => { + it('includes stepToken in the POST body when provided', async () => { + await sendReminderForPendingMrfResponse({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + submissionSecretKey: 'submission-secret-key', + stepToken: 'raw-step-token-abc123', + }) + + expect(mockPost).toHaveBeenCalledWith(expect.any(String), { + submissionSecretKey: 'submission-secret-key', + stepToken: 'raw-step-token-abc123', + }) + }) + + it('omits stepToken from the POST body when not provided', async () => { + await sendReminderForPendingMrfResponse({ + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + submissionSecretKey: 'submission-secret-key', + }) + + const body = mockPost.mock.calls.at(-1)?.[1] as Record + expect(body).not.toHaveProperty('stepToken') + expect(body).toEqual({ submissionSecretKey: 'submission-secret-key' }) + }) +}) diff --git a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.tsx b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.tsx index 5f5d9738ca..fe0e8f70b1 100644 --- a/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.tsx +++ b/apps/frontend/src/features/admin-form/responses/ResponsesPage/storage/UnlockedResponses/ResponsesTable/reminders/ReminderService.tsx @@ -11,15 +11,18 @@ export const sendReminderForPendingMrfResponse = async ({ formId, submissionId, submissionSecretKey, + stepToken, }: { formId: string submissionId: string submissionSecretKey: string + stepToken?: string }): Promise => { return ApiService.post( `${ADMIN_FORM_ENDPOINT}/${formId}/submissions/${submissionId}/remind`, { submissionSecretKey, + ...(stepToken ? { stepToken } : {}), }, ).then(() => {}) } diff --git a/apps/frontend/src/features/public-form/PublicFormProvider.tsx b/apps/frontend/src/features/public-form/PublicFormProvider.tsx index 373df552a1..6c065c2944 100644 --- a/apps/frontend/src/features/public-form/PublicFormProvider.tsx +++ b/apps/frontend/src/features/public-form/PublicFormProvider.tsx @@ -528,6 +528,13 @@ export const PublicFormProvider = ({ console.log(e) } + let stepToken = '' + try { + stepToken = decodeURIComponent(searchParams.get('token') ?? '') + } catch (e) { + console.log(e) + } + useEffect(() => { // Function to decrypt attachments retrieved from S3 using the submission secret key const decryptAttachments = async () => { @@ -772,6 +779,9 @@ export const PublicFormProvider = ({ formId, previousSubmissionId, previousSubmission?.submissionSecretKey, + // Empty (tokenless / flag-off link) → undefined so JSON.stringify drops + // `stepToken` from the PUT body, keeping it byte-identical to today. + stepToken || undefined, ) const form = data?.form diff --git a/apps/frontend/src/features/public-form/PublicFormService.ts b/apps/frontend/src/features/public-form/PublicFormService.ts index 8a38c93cd4..c449033b58 100644 --- a/apps/frontend/src/features/public-form/PublicFormService.ts +++ b/apps/frontend/src/features/public-form/PublicFormService.ts @@ -184,6 +184,7 @@ export type SubmitStorageFormWithVirusScanningArgs = export type SubmitMultirespondentFormWithVirusScanningArgs = SubmitEmailFormArgs & { submissionSecretKey?: string + stepToken?: string fieldIdToQuarantineKeyMap: FieldIdToQuarantineKeyType[] } @@ -427,6 +428,7 @@ export const updateMultirespondentSubmission = async ({ responseMetadata, fieldIdToQuarantineKeyMap, submissionSecretKey, + stepToken, respondentEmails, }: SubmitMultirespondentFormWithVirusScanningArgs & { submissionId?: string @@ -443,6 +445,7 @@ export const updateMultirespondentSubmission = async ({ formInputs: filteredInputs, responseMetadata, submissionSecretKey, + stepToken, version: MULTIRESPONDENT_FORM_SUBMISSION_VERSION, respondentEmails: respondentEmails, }, diff --git a/apps/frontend/src/features/public-form/mutations.ts b/apps/frontend/src/features/public-form/mutations.ts index 09394b1097..3938fefd0c 100644 --- a/apps/frontend/src/features/public-form/mutations.ts +++ b/apps/frontend/src/features/public-form/mutations.ts @@ -73,6 +73,7 @@ export const usePublicFormMutations = ( formId: string, submissionId?: string, submissionSecretKey?: string, + stepToken?: string, ) => { const submitEmailModeFormMutation = useMutation( (args: Omit) => { @@ -175,7 +176,11 @@ export const usePublicFormMutations = ( const updateMultirespondentSubmissionMutation = useSubmitMutationWithAttachmentVirusScanning((args) => - updateMultirespondentSubmission({ ...args, submissionSecretKey }), + updateMultirespondentSubmission({ + ...args, + submissionSecretKey, + stepToken, + }), ) return { diff --git a/apps/frontend/src/features/public-form/stepTokenEcho.test.tsx b/apps/frontend/src/features/public-form/stepTokenEcho.test.tsx new file mode 100644 index 0000000000..4d236fffac --- /dev/null +++ b/apps/frontend/src/features/public-form/stepTokenEcho.test.tsx @@ -0,0 +1,67 @@ +import { ApiService } from '~services/ApiService' + +import { updateMultirespondentSubmission } from './PublicFormService' + +vi.mock('~services/ApiService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ApiService: { put: vi.fn(), post: vi.fn() }, + } +}) + +const MOCK_FORM_ID = '61540ece3d4a6e50ac0cc6ff' +const MOCK_SUBMISSION_ID = '61540ece3d4a6e50ac0cc700' + +const mockPut = ApiService.put as unknown as ReturnType + +const putBodyFromLastCall = (): Record => { + const lastCall = mockPut.mock.calls.at(-1) + if (!lastCall) throw new Error('ApiService.put was not called') + const formData = lastCall[1] as FormData + return JSON.parse(formData.get('body') as string) +} + +beforeEach(() => { + mockPut.mockReset() + mockPut.mockResolvedValue({ + data: { submissionId: MOCK_SUBMISSION_ID, timestamp: 1700000000000 }, + }) +}) + +const NEXT_STEP_ARGS = { + formFields: [], + formLogics: [], + formInputs: {}, + responseMetadata: undefined, +} + +describe('frontend step-token echoes to next-step PUT body', () => { + it('includes the step token as `stepToken` in the PUT body when a token is present', async () => { + await updateMultirespondentSubmission({ + ...NEXT_STEP_ARGS, + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + fieldIdToQuarantineKeyMap: [], + submissionSecretKey: 'submission-secret-key', + stepToken: 'raw-step-token-abc123', + }) + + expect(putBodyFromLastCall()).toMatchObject({ + stepToken: 'raw-step-token-abc123', + }) + }) + + it('omits `stepToken` from the PUT body when no token is present', async () => { + await updateMultirespondentSubmission({ + ...NEXT_STEP_ARGS, + formId: MOCK_FORM_ID, + submissionId: MOCK_SUBMISSION_ID, + fieldIdToQuarantineKeyMap: [], + submissionSecretKey: 'submission-secret-key', + // stepToken intentionally omitted + }) + + expect(putBodyFromLastCall()).not.toHaveProperty('stepToken') + }) +}) diff --git a/apps/frontend/src/features/public-form/utils/createSubmission.ts b/apps/frontend/src/features/public-form/utils/createSubmission.ts index 969001f800..b9db4bae0c 100644 --- a/apps/frontend/src/features/public-form/utils/createSubmission.ts +++ b/apps/frontend/src/features/public-form/utils/createSubmission.ts @@ -105,6 +105,7 @@ type CreateStorageSubmissionFormDataArgs = CreateEmailSubmissionFormDataArgs & { type CreateMultirespondentSubmissionFormDataArgs = CreateEmailSubmissionFormDataArgs & { submissionSecretKey?: string + stepToken?: string version: number } diff --git a/packages/sdk/spec/crypto-v3.spec.ts b/packages/sdk/spec/crypto-v3.spec.ts index b37a729ce1..d3b0e25978 100644 --- a/packages/sdk/spec/crypto-v3.spec.ts +++ b/packages/sdk/spec/crypto-v3.spec.ts @@ -1,15 +1,19 @@ import mockAxios from 'jest-mock-axios' +import { decodeUTF8 } from 'tweetnacl-util' + +import Crypto from '../src/crypto' +import CryptoV3 from '../src/crypto-v3' +import { SIGNING_KEYS } from '../src/resource/signing-keys' +import { encryptMessage } from '../src/util/crypto' + import { - plaintext, ciphertext, formPublicKey, formSecretKey, + plaintext, + plainVerifiedText, submissionSecretKey, - plainVerifiedText } from './resources/crypto-v3-data-20231207' -import CryptoV3 from '../src/crypto-v3' -import Crypto from '../src/crypto' -import { SIGNING_KEYS } from '../src/resource/signing-keys' const INTERNAL_TEST_VERSION = 3 @@ -100,6 +104,72 @@ describe('CryptoV3', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) + describe('decryptToV4 — MRF step-token recovery', () => { + const rawStepToken = 'raw-step-token-value_123' + + it('recovers the raw step token when encryptedStepToken is present', () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + const ciphertext = crypto.encrypt(plaintext, publicKey) + const encryptedStepToken = encryptMessage( + decodeUTF8(rawStepToken), + publicKey + ) + + // Act + const decrypted = crypto.decryptToV4( + secretKey, + { ...ciphertext, encryptedStepToken, version: INTERNAL_TEST_VERSION }, + {} + ) + + // Assert + expect(decrypted?.stepToken).toBe(rawStepToken) + expect(decrypted?.responses).toBeDefined() + }) + + it('returns stepToken undefined when encryptedStepToken is absent', () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + const ciphertext = crypto.encrypt(plaintext, publicKey) + + // Act + const decrypted = crypto.decryptToV4( + secretKey, + { ...ciphertext, version: INTERNAL_TEST_VERSION }, + {} + ) + + // Assert + expect(decrypted?.stepToken).toBeUndefined() + expect(decrypted?.responses).toBeDefined() + }) + + it('returns stepToken undefined without failing the decrypt when encryptedStepToken is tampered', () => { + // Arrange + const { publicKey, secretKey } = crypto.generate() + const ciphertext = crypto.encrypt(plaintext, publicKey) + // Wrapped to an unrelated key, so it cannot be unwrapped with `secretKey`. + const { publicKey: otherPublicKey } = crypto.generate() + const encryptedStepToken = encryptMessage( + decodeUTF8(rawStepToken), + otherPublicKey + ) + + // Act + const decrypted = crypto.decryptToV4( + secretKey, + { ...ciphertext, encryptedStepToken, version: INTERNAL_TEST_VERSION }, + {} + ) + + // Assert: decrypt still succeeds, step token just comes back undefined. + expect(decrypted).not.toBeNull() + expect(decrypted?.stepToken).toBeUndefined() + expect(decrypted?.responses).toBeDefined() + }) + }) + it('should be able to encrypt and decrypt files end-to-end', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() @@ -134,13 +204,17 @@ describe('CryptoV3', function () { expect(decrypted).toBeNull() }) - it('should be able to encrypt and decrypt submissions with verifiedContent from 2023-12-07 end-to-end successfully from the form private key', () => { + it('should be able to encrypt and decrypt submissions with verifiedContent 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 verifiedText = cryptoV1.encrypt(plainVerifiedText, ciphertext.submissionPublicKey, signingSecretKey) + const verifiedText = cryptoV1.encrypt( + plainVerifiedText, + ciphertext.submissionPublicKey, + signingSecretKey + ) const decrypted = crypto.decrypt(secretKey, { ...ciphertext, verifiedContent: verifiedText, diff --git a/packages/sdk/src/crypto-v3.ts b/packages/sdk/src/crypto-v3.ts index a86a8d6a54..c84bf8f6e2 100644 --- a/packages/sdk/src/crypto-v3.ts +++ b/packages/sdk/src/crypto-v3.ts @@ -22,7 +22,12 @@ import { EncryptedContentV3, FormFieldsV3, } from './types' -import { DecryptedContentV4, FieldResponsesV4, FormFieldMeta } from './types-v4' +import { + DecryptedContentV4, + DecryptParamsV4, + FieldResponsesV4, + FormFieldMeta, +} from './types-v4' /** * Checks whether decrypted responses are already in V4 format. @@ -230,7 +235,7 @@ export default class CryptoV3 extends CryptoBase { */ decryptToV4 = ( formSecretKey: string, - decryptParams: DecryptParamsV3, + decryptParams: DecryptParamsV4, formFields: Record ): DecryptedContentV4 | null => { const decrypted = this.decrypt(formSecretKey, decryptParams) @@ -242,8 +247,20 @@ export default class CryptoV3 extends CryptoBase { ? backfillMissingQuestions(decrypted.responses, formFields) : adaptV3ToV4(decrypted.responses, { formFields }) + let stepToken: string | undefined = undefined + if (decryptParams.encryptedStepToken) { + const decryptedStepToken = decryptContent( + formSecretKey, + decryptParams.encryptedStepToken + ) + if (decryptedStepToken) { + stepToken = encodeUTF8(decryptedStepToken) + } + } + return { submissionSecretKey: decrypted.submissionSecretKey, + stepToken, responses, verified: decrypted.verified, } diff --git a/packages/sdk/src/types-v4.ts b/packages/sdk/src/types-v4.ts index 539352850b..cf9da20f6a 100644 --- a/packages/sdk/src/types-v4.ts +++ b/packages/sdk/src/types-v4.ts @@ -1,4 +1,4 @@ -import { FieldType } from './types' +import { DecryptParamsV3, EncryptedContent, FieldType } from './types' // TODO: provenance shape may be updated when it is implemented export type ResponseProvenance = { @@ -172,8 +172,13 @@ export type AdaptV3ToV4Options = { // --------------- Decrypted content V4 --------------- +export interface DecryptParamsV4 extends DecryptParamsV3 { + encryptedStepToken?: EncryptedContent +} + export type DecryptedContentV4 = { submissionSecretKey: string + stepToken?: string responses: FieldResponsesV4 // eslint-disable-next-line @typescript-eslint/no-explicit-any verified?: Record diff --git a/packages/shared/constants/feature-flags.ts b/packages/shared/constants/feature-flags.ts index 3a75262453..a356764c69 100644 --- a/packages/shared/constants/feature-flags.ts +++ b/packages/shared/constants/feature-flags.ts @@ -11,6 +11,7 @@ export const featureFlags = { ogpSuiteSso: 'ogp-suite-sso' as const, enableIntranetSgidLogin: 'enable-intranet-sgid-login' as const, enableMrfWebhooks: 'enable-mrf-webhooks' as const, + mrfStepWriteToken: 'mrf-step-write-token' as const, useFormsgEsrvcId: 'use-formsg-esrvcid' as const, lambdaPdfGeneration: 'lambda-pdf-generation' as const, enableSaveDraftButtonHeader: 'enable-save-draft-button-header' as const, diff --git a/packages/shared/types/submission.ts b/packages/shared/types/submission.ts index 7eaa886dec..cd5f6123d6 100644 --- a/packages/shared/types/submission.ts +++ b/packages/shared/types/submission.ts @@ -137,6 +137,10 @@ export const MultirespondentSubmissionBase = SubmissionBase.extend({ workflowStep: z.number(), mrfVersion: z.number().optional(), submittedSteps: z.array(SubmittedStep).optional(), + // RATIONALE: optional for backwards compatibility on + // in-flight mrf steps which do not have a step token at time of creation. + stepTokenHash: z.string().optional(), + encryptedStepToken: z.string().optional(), }) export type MultirespondentSubmissionBase = z.infer< @@ -190,6 +194,7 @@ export type MultirespondentSubmissionDto = SubmissionDtoBase & { verifiedContent?: string submissionPublicKey: string encryptedSubmissionSecretKey: string + encryptedStepToken?: string encryptedContent: string attachmentMetadata: Record workflowStep: number @@ -202,10 +207,11 @@ export type MultirespondentSubmissionDto = SubmissionDtoBase & { export type PublicMultirespondentSubmissionDto = Omit< MultirespondentSubmissionDto, - 'workflow' | 'form_fields' + 'workflow' | 'form_fields' | 'encryptedStepToken' > & { form_fields: StrippedFormFieldDto[] workflow: StrippedFormWorkflowDto + encryptedStepToken: undefined } export type SubmissionDto = diff --git a/packages/shared/utils/urls.ts b/packages/shared/utils/urls.ts index 7076eef85d..51f095bc95 100644 --- a/packages/shared/utils/urls.ts +++ b/packages/shared/utils/urls.ts @@ -30,12 +30,16 @@ export const getMultirespondentSubmissionEditPath = ( submissionId: string, options?: { key?: string + stepToken?: string }, ) => { const editPath = `${formId}/edit/${submissionId}` - const { key } = options || {} + const { key, stepToken } = options || {} if (key) { - return `${editPath}?key=${encodeURIComponent(key)}` + const withKey = `${editPath}?key=${encodeURIComponent(key)}` + return stepToken + ? `${withKey}&token=${encodeURIComponent(stepToken)}` + : withKey } return editPath }