diff --git a/src/config.ts b/src/config.ts index 28144e241..a6bbbed2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -104,7 +104,8 @@ const defaultConfig: IoDevServerConfig = { noSignatureFieldsCount: 0, response: { getFciResponseCode: 200, - documentExpirationDurationSeconds: 5 * 60 // make it 30 to allow testing expired documents without waiting too much + documentExpirationDurationSeconds: 5 * 60, // make it 30 to allow testing expired documents without waiting too much + nonceDurationSeconds: 5 * 60 // 5 minutes as production environment } }, withCTA: false, diff --git a/src/features/fci/qtspNonceStore.ts b/src/features/fci/qtspNonceStore.ts new file mode 100644 index 000000000..9e010d4d3 --- /dev/null +++ b/src/features/fci/qtspNonceStore.ts @@ -0,0 +1,37 @@ +import { randomUUID } from "crypto"; +import { ioDevServerConfig } from "../../config"; + +export const QTSP_NONCE_EXPIRING_MS = + ioDevServerConfig.messages.fci.response.nonceDurationSeconds * 1000; + +const qtspNonceExpirations = new Map(); + +const cleanupExpiredQtspNonces = () => { + const now = new Date(); + qtspNonceExpirations.forEach((expiresAt, nonce) => { + if (expiresAt <= now) { + qtspNonceExpirations.delete(nonce); + } + }); +}; + +const generateQtspNonce = () => + `devnonce-${randomUUID({ disableEntropyCache: true })}`; + +export const generateAndStoreQtspNonce = (now = new Date()) => { + cleanupExpiredQtspNonces(); + const nonce = generateQtspNonce(); + qtspNonceExpirations.set( + nonce, + new Date(now.getTime() + QTSP_NONCE_EXPIRING_MS) + ); + return nonce; +}; + +export const validateQtspNonce = (nonce: string): boolean => { + cleanupExpiredQtspNonces(); + const expiration = qtspNonceExpirations.get(nonce); + return expiration !== undefined; +}; + +export const getQtspNonceExpirations = () => qtspNonceExpirations; diff --git a/src/features/messages/types/messagesConfig.ts b/src/features/messages/types/messagesConfig.ts index cf951228a..a26e66dfa 100644 --- a/src/features/messages/types/messagesConfig.ts +++ b/src/features/messages/types/messagesConfig.ts @@ -37,6 +37,8 @@ export const MessagesConfig = t.intersection([ response: t.type({ // 200 success with payload getFciResponseCode: HttpResponseCode, + // qtsp nonce duration in seconds + nonceDurationSeconds: t.number, documentExpirationDurationSeconds: t.number }) }), diff --git a/src/routers/features/fci/__tests__/index.test.ts b/src/routers/features/fci/__tests__/index.test.ts index 6b03fa3f9..b860bc306 100644 --- a/src/routers/features/fci/__tests__/index.test.ts +++ b/src/routers/features/fci/__tests__/index.test.ts @@ -6,6 +6,7 @@ import { SIGNATURE_REQUEST_ID } from "../../../../payloads/features/fci/signatur import app from "../../../../server"; import { addFciPrefix } from "../index"; import { EnvironmentEnum } from "../../../../../generated/definitions/fci/Environment"; +import { getQtspNonceExpirations } from "../../../../features/fci/qtspNonceStore"; const request = supertest(app); @@ -47,11 +48,30 @@ describe("io-sign API", () => { }); }); describe("GET qtsp clauses", () => { + beforeEach(() => { + getQtspNonceExpirations().clear(); + }); + describe("when the signer request qtsp clauses", () => { it("should return 200 and the clauses list", async () => { const response = await request.get(addFciPrefix(`/qtsp/clauses`)); expect(response.status).toBe(200); expect(response.body).toHaveProperty("clauses"); + expect(response.body.nonce).toMatch(/^devnonce-/); + expect(getQtspNonceExpirations().has(response.body.nonce)).toBe(true); + }); + + it("should store the nonce with an expiration date", async () => { + const response = await request.get(addFciPrefix(`/qtsp/clauses`)); + const nonceExpiration = getQtspNonceExpirations().get( + response.body.nonce + ); + + if (nonceExpiration === undefined) { + throw new Error("missing nonce expiration"); + } + + expect(nonceExpiration.getTime()).toBeGreaterThan(Date.now()); }); }); }); @@ -67,16 +87,59 @@ describe("io-sign API", () => { }); }); describe("POST create signature", () => { + const SHOULD_RETURN_400 = "should return 400"; + + beforeEach(() => { + getQtspNonceExpirations().clear(); + }); + describe("when the signer request a signature with a valid body", () => { - it("should return 201", async () => { + it("should return 200", async () => { + const qtspClausesResponse = await request.get( + addFciPrefix(`/qtsp/clauses`) + ); + const response = await request.post(addFciPrefix(`/signatures`)).send({ + ...createSignatureBody, + qtsp_clauses: { + ...createSignatureBody.qtsp_clauses, + nonce: qtspClausesResponse.body.nonce + } + }); + expect(response.status).toBe(200); + }); + }); + describe("when the signer request a signature with an invalid nonce", () => { + it(SHOULD_RETURN_400, async () => { const response = await request .post(addFciPrefix(`/signatures`)) .send(createSignatureBody); - expect(response.status).toBe(200); + expect(response.status).toBe(400); + }); + }); + describe("when the signer request a signature with an expired nonce", () => { + it(SHOULD_RETURN_400, async () => { + const qtspClausesResponse = await request.get( + addFciPrefix(`/qtsp/clauses`) + ); + const expiredNonce = qtspClausesResponse.body.nonce; + getQtspNonceExpirations().set( + expiredNonce, + new Date(Date.now() - 1000) + ); + + const response = await request.post(addFciPrefix(`/signatures`)).send({ + ...createSignatureBody, + qtsp_clauses: { + ...createSignatureBody.qtsp_clauses, + nonce: expiredNonce + } + }); + + expect(response.status).toBe(400); }); }); describe("when the signer request signature detail with a not valid body", () => { - it("should return 400", async () => { + it(SHOULD_RETURN_400, async () => { const response = await request.post(addFciPrefix(`/signatures`)); expect(response.status).toBe(400); }); diff --git a/src/routers/features/fci/index.ts b/src/routers/features/fci/index.ts index 4d91aa2f8..ac8da321f 100644 --- a/src/routers/features/fci/index.ts +++ b/src/routers/features/fci/index.ts @@ -25,6 +25,10 @@ import { SignatureRequestStatusEnum } from "../../../../generated/definitions/fc import { EnvironmentEnum } from "../../../../generated/definitions/fci/Environment"; import { signatureRequestList } from "../../../payloads/features/fci/signature-requests"; import { getProblemJson } from "../../../payloads/error"; +import { + generateAndStoreQtspNonce, + validateQtspNonce +} from "../../../features/fci/qtspNonceStore"; export const fciRouter = Router(); const configResponse = ioDevServerConfig.messages.fci.response; @@ -142,7 +146,7 @@ addHandler( ); addHandler(fciRouter, "get", addFciPrefix("/qtsp/clauses"), (_, res) => { - res.status(200).json(qtspClauses); + res.status(200).json({ ...qtspClauses, nonce: generateAndStoreQtspNonce() }); }); addHandler( @@ -165,9 +169,25 @@ addHandler(fciRouter, "post", addFciPrefix("/signatures"), (req, res) => { pipe( O.fromNullable(req.body), O.chain(cb => (isEqual(cb, {}) ? O.none : O.some(cb))), + O.chain(cb => + pipe( + O.fromNullable(cb.qtsp_clauses?.nonce), + O.map(nonce => validateQtspNonce(nonce)) + ) + ), O.fold( () => res.sendStatus(400), - _ => res.status(200).json(mockSignatureDetailView) + nonceValidationResult => { + if (nonceValidationResult) { + return res.status(200).json(mockSignatureDetailView); + } + return res.status(400).json({ + detail: + "An error occurred while validating the request body | undefined", + status: 400, + title: "Invalid request" + }); + } ) ); });