diff --git a/jest.setup.cjs b/jest.setup.cjs index b30216847..602b53a87 100644 --- a/jest.setup.cjs +++ b/jest.setup.cjs @@ -19,3 +19,4 @@ process.env.SNS_SAVE_TOPIC_ARN = process.env.SNS_ADAPTER_TOPIC_ARN = 'arn:aws:sns:eu-west-2:123456789012:test-adapter-topic' process.env.SNS_ENDPOINT = 'http://localhost:4566' +process.env.PRIVATE_KEY_FOR_SECRETS = 'dummy-private-key' diff --git a/package-lock.json b/package-lock.json index 0a59f8030..38a680563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.997.0", - "@defra/forms-engine-plugin": "^4.0.60", - "@defra/forms-model": "^3.0.627", + "@defra/forms-engine-plugin": "^4.0.62", + "@defra/forms-model": "^3.0.629", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", @@ -2980,13 +2980,13 @@ } }, "node_modules/@defra/forms-engine-plugin": { - "version": "4.0.60", - "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.60.tgz", - "integrity": "sha512-/rdBc/X/UBatxM5XK5d0DuHaVDdKZ4eANDw4nkpfgJ/wouPvMtNUYnMPp0Kv1+euPwnz+FBiVoyYcZ6+RluR5w==", + "version": "4.0.62", + "resolved": "https://registry.npmjs.org/@defra/forms-engine-plugin/-/forms-engine-plugin-4.0.62.tgz", + "integrity": "sha512-urr7kygbNQJWjoZNZMx/qOcKmrmNVzsZyPO0Qd4jR1f0dF7w0U6ge857mgKQih22RWsXvTJucCpAgrTOQg0wSA==", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@defra/forms-model": "^3.0.622", + "@defra/forms-model": "^3.0.627", "@defra/hapi-tracing": "^1.29.0", "@defra/interactive-map": "^0.0.4-alpha", "@elastic/ecs-pino-format": "^1.5.0", @@ -3092,9 +3092,9 @@ } }, "node_modules/@defra/forms-model": { - "version": "3.0.627", - "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.627.tgz", - "integrity": "sha512-RtZiHpUQb+b87+WJuYoo7QtKCyg4IQvOoS2SXa9v3+mZcrTJZrR4ELWuZMgKNZo9n8qqLOl7t8N/n/4X5oyxyQ==", + "version": "3.0.629", + "resolved": "https://registry.npmjs.org/@defra/forms-model/-/forms-model-3.0.629.tgz", + "integrity": "sha512-MnNgpAHON8eKhVttqI/2xjLpFNCAE43J7Iaqe84XftN2ML0UvLeZHFh4Jx1ePu/h+dvctmPXwCeooaZ+aPVWyg==", "license": "OGL-UK-3.0", "dependencies": { "@joi/date": "^2.1.1", @@ -13294,9 +13294,9 @@ "license": "MIT" }, "node_modules/fast-xml-parser": { - "version": "5.4.2", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.2.tgz", - "integrity": "sha512-pw/6pIl4k0CSpElPEJhDppLzaixDEuWui2CUQQBH/ECDf7+y6YwA4Gf7Tyb0Rfe4DIMuZipYj4AEL0nACKglvQ==", + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 6ace3ba38..733469656 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@aws-sdk/client-sns": "^3.997.0", - "@defra/forms-engine-plugin": "^4.0.60", - "@defra/forms-model": "^3.0.627", + "@defra/forms-engine-plugin": "^4.0.62", + "@defra/forms-model": "^3.0.629", "@defra/hapi-tracing": "^1.30.0", "@elastic/ecs-pino-format": "^1.5.0", "@hapi/boom": "^10.0.1", diff --git a/src/config/index.ts b/src/config/index.ts index 60ca367b4..47d1c3598 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -329,6 +329,14 @@ export const config = convict({ nullable: false, default: 'defraforms@defra.gov.uk', env: 'FEEDBACK_VIA_EMAIL' + } as SchemaObj, + + privateKeyForSecrets: { + doc: 'The private key used to decrypt secret values', + format: String, + nullable: true, + default: undefined, + env: 'PRIVATE_KEY_FOR_SECRETS' } as SchemaObj }) diff --git a/src/server/services/formsService.js b/src/server/services/formsService.js index 149a368a6..0987f7c31 100644 --- a/src/server/services/formsService.js +++ b/src/server/services/formsService.js @@ -2,6 +2,7 @@ import { FormStatus } from '@defra/forms-engine-plugin/types' import { formMetadataSchema } from '@defra/forms-model' import { config } from '~/src/config/index.js' +import { decryptSecret } from '~/src/server/services/helpers/crypto.js' import { getJson, postJson } from '~/src/server/services/httpService.js' const managerUrl = config.get('managerUrl') @@ -111,6 +112,21 @@ export async function validateSaveAndExitCredentials( return results } +/** + * Retrieves a form secret and decrypts the value + * @param {string} formId - the id of the form + * @param {string} secretName - the name of the secret + */ +export async function getFormSecret(formId, secretName) { + const response = await fetch( + `${managerUrl}/forms/${formId}/secrets/${secretName}` + ) + if (response.statusText !== 'OK') { + return '' + } + return decryptSecret(await response.text()) +} + /** * @import { FormDefinition, FormMetadata } from '@defra/forms-model' * @import { SaveAndExitDetails, SaveAndExitResumeDetails } from '~/src/server/types.js' diff --git a/src/server/services/formsService.test.js b/src/server/services/formsService.test.js index 8999acefc..5423110ec 100644 --- a/src/server/services/formsService.test.js +++ b/src/server/services/formsService.test.js @@ -5,6 +5,7 @@ import { getFormDefinition, getFormMetadata, getFormMetadataById, + getFormSecret, getSaveAndExitDetails, validateSaveAndExitCredentials } from '~/src/server/services/formsService.js' @@ -16,6 +17,10 @@ const { MANAGER_URL, SUBMISSION_URL } = process.env const magicLinkId = '7ac201b2-bea3-490d-8ccb-2734b2794f7b' jest.mock('~/src/server/services/httpService') +jest.mock('node:crypto', () => ({ + ...jest.requireActual('node:crypto'), + privateDecrypt: () => 'decrypted-secret' +})) describe('Forms service', () => { const { definition, metadata } = fixtures.form @@ -60,6 +65,19 @@ describe('Forms service', () => { updatedAt: expect.any(Date) }) }) + + it('throws when validation error', async () => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: { invalid: '123' } + }) + + await expect(() => getFormMetadata(metadata.slug)).rejects.toThrow( + '"title" is required' + ) + }) }) describe('getFormMetadataById', () => { @@ -102,6 +120,19 @@ describe('Forms service', () => { updatedAt: expect.any(Date) }) }) + + it('throws when validation error', async () => { + jest.mocked(getJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: { invalid: '123' } + }) + + await expect(() => getFormMetadataById(metadata.id)).rejects.toThrow( + '"title" is required' + ) + }) }) describe('getFormDefinition', () => { @@ -168,6 +199,59 @@ describe('Forms service', () => { { payload: { securityAnswer: 'answer' } } ) }) + + it('throws if no results', async () => { + // @ts-expect-error - partial mock of payload + jest.mocked(postJson).mockResolvedValue({ + res: /** @type {IncomingMessage} */ ({ + statusCode: StatusCodes.OK + }), + payload: undefined + }) + + await expect(() => + validateSaveAndExitCredentials(magicLinkId, 'answer') + ).rejects.toThrow( + 'Unexpected empty response in validateSaveAndExitCredentials' + ) + }) + }) + + describe('getFormSecret', () => { + beforeEach(() => { + // @ts-expect-error - mock fetch + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve('secret-value'), + statusText: 'OK' + }) + ) + }) + + it('calls correct url', async () => { + const res = await getFormSecret(metadata.id, 'secret-name') + + expect(fetch).toHaveBeenCalledWith( + `${MANAGER_URL}/forms/${metadata.id}/secrets/secret-name` + ) + expect(res).toBe('decrypted-secret') + }) + + it('handles missing secret', async () => { + // @ts-expect-error - mock fetch + global.fetch = jest.fn(() => + Promise.resolve({ + text: () => Promise.resolve('secret-value'), + statusText: 'Error' + }) + ) + const res = await getFormSecret(metadata.id, 'secret-name') + + expect(fetch).toHaveBeenCalledWith( + `${MANAGER_URL}/forms/${metadata.id}/secrets/secret-name` + ) + expect(res).toBe('') + }) }) }) diff --git a/src/server/services/helpers/crypto.js b/src/server/services/helpers/crypto.js new file mode 100644 index 000000000..b4d70fc27 --- /dev/null +++ b/src/server/services/helpers/crypto.js @@ -0,0 +1,17 @@ +import crypto from 'node:crypto' + +import { config } from '~/src/config/index.js' + +/** + * @param {string} secretValue - cleartext secret value + * @returns {string} base64-encoded result + */ +export function decryptSecret(secretValue) { + const privateKey = config.get('privateKeyForSecrets') + if (!privateKey) { + throw new Error('Private key is missing') + } + const buffer = Buffer.from(secretValue, 'base64') + const decrypted = crypto.privateDecrypt(privateKey, buffer) + return decrypted.toString() +} diff --git a/src/server/services/helpers/crypto.test.js b/src/server/services/helpers/crypto.test.js new file mode 100644 index 000000000..7b894a337 --- /dev/null +++ b/src/server/services/helpers/crypto.test.js @@ -0,0 +1,36 @@ +import { config } from '~/src/config/index.js' +import { decryptSecret } from '~/src/server/services/helpers/crypto.js' + +jest.mock('~/src/config/index.ts', () => ({ + config: { + get: jest.fn((key) => { + if (key === 'privateKeyForSecrets') return 'abcdef' + return 'mock-value' + }) + } +})) +jest.mock('node:crypto', () => ({ + privateDecrypt: () => 'decrypted-secret' +})) + +describe('crypto helpers', () => { + describe('decryptSecret', () => { + it('should throw is private key is missing', () => { + jest.mocked(config.get).mockImplementationOnce((key) => { + if (key === 'privateKeyForSecrets') return undefined + return 'mock-value' + }) + expect(() => decryptSecret('some-string')).toThrow( + 'Private key is missing' + ) + }) + + it('should return decrypted value', () => { + jest.mocked(config.get).mockImplementationOnce((key) => { + if (key === 'privateKeyForSecrets') return 'private-key' + return 'mock-value' + }) + expect(decryptSecret('some-string')).toBe('decrypted-secret') + }) + }) +})