From 1de369f0168a6738e98e149db6f903e4a41cf8bd Mon Sep 17 00:00:00 2001 From: pawelPrzywara Date: Fri, 13 Mar 2026 16:36:18 +0100 Subject: [PATCH] feat: nonce generation and check --- src/config.ts | 3 +- src/features/fci/qtspNonceStore.ts | 56 ++++++++++++++++ src/features/messages/types/messagesConfig.ts | 4 +- .../features/fci/__tests__/index.test.ts | 65 ++++++++++++++++++- src/routers/features/fci/index.ts | 19 +++++- 5 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/features/fci/qtspNonceStore.ts diff --git a/src/config.ts b/src/config.ts index 21b73eb3f..c5df6e97e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -103,7 +103,8 @@ const defaultConfig: IoDevServerConfig = { canceledCount: 0, noSignatureFieldsCount: 0, response: { - getFciResponseCode: 200 + getFciResponseCode: 200, + nonceDuration: 300 // 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..ba5e675ed --- /dev/null +++ b/src/features/fci/qtspNonceStore.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "crypto"; +import { ioDevServerConfig } from "../../config"; + +export const QTSP_NONCE_EXPIRING_MS = + ioDevServerConfig.messages.fci.response.nonceDuration * 1000; + +const qtspNonceExpirations = new Map(); + +const cleanupExpiredQtspNonces = (referenceDate: Date) => { + qtspNonceExpirations.forEach((expiresAt, nonce) => { + if (expiresAt <= referenceDate) { + qtspNonceExpirations.delete(nonce); + } + }); +}; + +const generateQtspNonce = () => + `devnonce-${randomUUID({ disableEntropyCache: true })}`; + +export const generateAndStoreQtspNonce = (now = new Date()) => { + cleanupExpiredQtspNonces(now); + const nonce = generateQtspNonce(); + qtspNonceExpirations.set( + nonce, + new Date(now.getTime() + QTSP_NONCE_EXPIRING_MS) + ); + return nonce; +}; + +export type QtspNonceValidationResult = "valid" | "expired" | "missing"; + +export const getQtspNonceValidationResult = ( + nonce: string, + now = new Date() +): QtspNonceValidationResult => { + const expiration = qtspNonceExpirations.get(nonce); + + if (expiration === undefined) { + cleanupExpiredQtspNonces(now); + return "missing"; + } + + if (expiration <= now) { + qtspNonceExpirations.delete(nonce); + cleanupExpiredQtspNonces(now); + return "expired"; + } + + cleanupExpiredQtspNonces(now); + return "valid"; +}; + +export const isStoredQtspNonceValid = (nonce: string, now = new Date()) => + getQtspNonceValidationResult(nonce, now) === "valid"; + +export const getQtspNonceExpirations = () => qtspNonceExpirations; diff --git a/src/features/messages/types/messagesConfig.ts b/src/features/messages/types/messagesConfig.ts index 9c8116eb6..742ce9f09 100644 --- a/src/features/messages/types/messagesConfig.ts +++ b/src/features/messages/types/messagesConfig.ts @@ -36,7 +36,9 @@ export const MessagesConfig = t.intersection([ noSignatureFieldsCount: t.number, response: t.type({ // 200 success with payload - getFciResponseCode: HttpResponseCode + getFciResponseCode: HttpResponseCode, + // qtsp nonce duration in seconds + nonceDuration: t.number }) }), // if true, messages (all available) with nested CTA will be included diff --git a/src/routers/features/fci/__tests__/index.test.ts b/src/routers/features/fci/__tests__/index.test.ts index 6b03fa3f9..a928979aa 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,12 +87,53 @@ describe("io-sign API", () => { }); }); describe("POST create signature", () => { + 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 500", async () => { const response = await request .post(addFciPrefix(`/signatures`)) .send(createSignatureBody); - expect(response.status).toBe(200); + expect(response.status).toBe(500); + }); + }); + describe("when the signer request a signature with an expired nonce", () => { + it("should return 500", 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(500); }); }); describe("when the signer request signature detail with a not valid body", () => { diff --git a/src/routers/features/fci/index.ts b/src/routers/features/fci/index.ts index 1caff7ee2..e1974e647 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, + getQtspNonceValidationResult +} from "../../../features/fci/qtspNonceStore"; export const fciRouter = Router(); const configResponse = ioDevServerConfig.messages.fci.response; @@ -128,7 +132,7 @@ addHandler( ); addHandler(fciRouter, "get", addFciPrefix("/qtsp/clauses"), (_, res) => { - res.status(200).json(qtspClauses); + res.status(200).json({ ...qtspClauses, nonce: generateAndStoreQtspNonce() }); }); addHandler( @@ -151,9 +155,20 @@ 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 => getQtspNonceValidationResult(nonce)) + ) + ), O.fold( () => res.sendStatus(400), - _ => res.status(200).json(mockSignatureDetailView) + nonceValidationResult => { + if (nonceValidationResult === "valid") { + return res.status(200).json(mockSignatureDetailView); + } + return res.sendStatus(500); + } ) ); });