diff --git a/README.md b/README.md index 811d103..495881d 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,11 @@ const program = Effect.gen(function* () { shop's eDavki cert in production). ZOI is `MD5(RSA-SHA256/PKCS#1 v1.5(…))`; messages are RS256 JWS over mutual TLS. +Pass `fursResponseCertPem` (FURS's response-signing cert) to authenticate FURS's +replies — the client then verifies each response's JWS signature before trusting +the EOR (raising `FursResponseSignatureError` on a spoofed/tampered response). +Strongly recommended for production. + > **Runtime note:** outbound mutual-TLS client certs do **not** work under bun > 1.3.6 — run the FURS client under **Node** until bun supports them. The logic > is unit-verified; the live FURS-test round-trip is the one deferred step diff --git a/ROADMAP.md b/ROADMAP.md index ddb07ef..e8866d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -57,9 +57,14 @@ JSON/JWS FURS protocol; cross-checked against `node-furs-fiscal-verification`, serial emitted as an exact integer literal). - **Messages** — `InvoiceRequest` + immovable `BusinessPremiseRequest`; client `echo` / `registerBusinessPremise` / `reportInvoice` (→ ZOI + EOR + printable). +- **Response authentication** — `verifyFursResponse` verifies FURS's response JWS + (RS256) against FURS's public certificate before the EOR is trusted; the client + enforces it when `fursResponseCertPem` is set (raises `FursResponseSignatureError` + on a spoofed/tampered response). The engine is now safe for real use. -Unit-verified (ZOI cross-validated vs independent `node:crypto`; JWS signature -verifies; loads the real FURS demo test cert). +Unit-verified (ZOI cross-validated vs independent `node:crypto`; request JWS +signature verifies; response verification accepts genuine + rejects +spoofed/tampered/wrong-key responses; loads the real FURS demo test cert). ### Deferred / blocked @@ -68,8 +73,6 @@ verifies; loads the real FURS demo test cert). legacy FURS test endpoint rejects modern-OpenSSL TLS from a proxied network. Run the opt-in `furs-live.test.ts` under a **Node** runtime on an unproxied network (with a FURS test p12) to confirm end-to-end — see the test's header. -- **Verify FURS's response JWS signature** against the FURS public cert (the - reference clients skip this; currently decoded without verification). - **Refunds / corrective receipts**, movable premises (A/B/C), sales-book mode. ## P3 — integration surface diff --git a/src/lib/furs/client.ts b/src/lib/furs/client.ts index 5658065..6b4952f 100644 --- a/src/lib/furs/client.ts +++ b/src/lib/furs/client.ts @@ -4,8 +4,8 @@ import * as v from "valibot"; import { InvalidInvoiceError } from "../foundation/errors"; import { type FursCert, loadP12 } from "./cert"; import { DEFAULT_TIMEZONE, formatFursDateTime } from "./datetime"; -import { FursConnectionError, FursError } from "./errors"; -import { decodeJwsPayload, signFursJws } from "./jws"; +import { FursConnectionError, FursError, FursResponseSignatureError } from "./errors"; +import { decodeJwsPayload, signFursJws, verifyFursResponse } from "./jws"; import { buildBusinessPremiseRequest, buildInvoiceRequest, @@ -39,6 +39,13 @@ export interface FursClientConfig { * supply the CA out of band) for hardened production use. */ rejectUnauthorized?: boolean; + /** + * FURS's response-signing certificate (PEM). When set, every signed response + * has its JWS signature verified against it before the EOR is trusted — + * failures raise `FursResponseSignatureError`. Strongly recommended for + * production. When omitted, responses are decoded without verification. + */ + fursResponseCertPem?: string; requestTimeoutMs?: number; } @@ -57,11 +64,14 @@ export interface FursClient { /** Register an immovable business premise. Resolves `true` on success. */ registerBusinessPremise( premise: FursBusinessPremise, - ): Effect.Effect; + ): Effect.Effect; /** Fiscally verify an invoice → ZOI + EOR + printable mark. */ reportInvoice( invoice: FursInvoice, - ): Effect.Effect; + ): Effect.Effect< + InvoiceResult, + FursConnectionError | FursError | FursResponseSignatureError | InvalidInvoiceError + >; /** The loaded certificate's identity (subject/issuer/serial). */ readonly cert: FursCert; } @@ -107,7 +117,17 @@ export const makeFursClient = Effect.fn("makeFursClient")(function* (config: Fur if (typeof responseToken !== "string") { return yield* Effect.fail(new FursConnectionError("FURS response missing token")); } - const decoded = decodeJwsPayload>>(responseToken); + // Verify FURS's signature when a cert is configured; otherwise decode unverified. + const decoded = config.fursResponseCertPem + ? yield* Effect.try({ + try: () => + verifyFursResponse>>( + responseToken, + config.fursResponseCertPem as string, + ), + catch: (cause) => new FursResponseSignatureError(undefined, cause), + }) + : decodeJwsPayload>>(responseToken); const envelope = decoded[Object.keys(decoded)[0] ?? ""]; if (envelope?.Error) { return yield* Effect.fail(new FursError(envelope.Error.ErrorCode, envelope.Error.ErrorMessage)); diff --git a/src/lib/furs/errors.ts b/src/lib/furs/errors.ts index 46b3afa..2c4499e 100644 --- a/src/lib/furs/errors.ts +++ b/src/lib/furs/errors.ts @@ -36,4 +36,23 @@ export class FursError extends Data.TaggedError("FursError")<{ } } -export type FursFailure = FursCertError | FursConnectionError | FursError; +/** + * FURS's response could not be authenticated: its JWS signature did not verify + * against the configured FURS public certificate. A potential spoof / MITM — + * the response (and any EOR in it) must NOT be trusted. + */ +export class FursResponseSignatureError extends Data.TaggedError("FursResponseSignatureError")<{ + message: string; + status: number; + cause?: unknown; +}> { + constructor(message = "FURS response signature did not verify", cause?: unknown) { + super({ message, status: 502, cause }); + } +} + +export type FursFailure = + | FursCertError + | FursConnectionError + | FursError + | FursResponseSignatureError; diff --git a/src/lib/furs/jws.ts b/src/lib/furs/jws.ts index d18a652..df796e4 100644 --- a/src/lib/furs/jws.ts +++ b/src/lib/furs/jws.ts @@ -1,4 +1,4 @@ -import { createSign } from "node:crypto"; +import { createSign, createVerify, X509Certificate } from "node:crypto"; export interface JwsIdentity { subjectName: string; @@ -33,9 +33,9 @@ export function signFursJws(payload: unknown, privateKeyPem: string, id: JwsIden } /** - * Decode a compact JWS payload without verifying the signature. The reference - * FURS clients do not verify FURS's response signature; verifying it against the - * FURS public cert is a hardening follow-up (see ROADMAP). + * Decode a compact JWS payload **without** verifying the signature. Use + * {@link verifyFursResponse} when you have FURS's public certificate — an + * unverified response (and its EOR) must not be trusted in production. */ export function decodeJwsPayload(token: string): T { const segment = token.split(".")[1]; @@ -43,6 +43,35 @@ export function decodeJwsPayload(token: string): T { return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as T; } +/** + * Verify a FURS response's compact JWS (RS256) against FURS's public + * certificate and return its decoded payload. Confirms the response was signed + * by FURS (not spoofed / tampered). + * + * `fursCertPem` is FURS's response-signing certificate (PEM) — the test-env one + * is published with the FURS reference clients; production has its own. Throws + * if the token is malformed or the signature does not verify. + */ +export function verifyFursResponse(token: string, fursCertPem: string): T { + const [headerB64, payloadB64, signatureB64] = token.split("."); + if (!headerB64 || !payloadB64 || !signatureB64) { + throw new Error("malformed JWS: expected three segments"); + } + + const header = JSON.parse(Buffer.from(headerB64, "base64url").toString("utf8")) as { alg?: string }; + if (header.alg !== "RS256") { + throw new Error(`unexpected JWS alg "${header.alg}" (expected RS256)`); + } + + const publicKey = new X509Certificate(fursCertPem).publicKey; + const verified = createVerify("RSA-SHA256") + .update(`${headerB64}.${payloadB64}`, "utf8") + .verify(publicKey, signatureB64, "base64url"); + if (!verified) throw new Error("signature did not verify against the FURS certificate"); + + return JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")) as T; +} + function b64url(s: string): string { return Buffer.from(s, "utf8").toString("base64url"); } diff --git a/src/test/furs-fixtures.ts b/src/test/furs-fixtures.ts index f62dc2a..52c2e22 100644 --- a/src/test/furs-fixtures.ts +++ b/src/test/furs-fixtures.ts @@ -6,6 +6,8 @@ export interface TestCert { passphrase: string; privateKeyPem: string; publicKeyPem: string; + /** Self-signed certificate PEM (e.g. to stand in for FURS's response cert). */ + certPem: string; serialDecimal: string; } @@ -40,6 +42,7 @@ export function makeTestCert(): TestCert { passphrase, privateKeyPem: forge.pki.privateKeyToPem(keys.privateKey), publicKeyPem: forge.pki.publicKeyToPem(keys.publicKey), + certPem: forge.pki.certificateToPem(cert), serialDecimal: BigInt("0x0a1b2c3d4e5f6071").toString(10), }; } diff --git a/src/test/furs-verify.test.ts b/src/test/furs-verify.test.ts new file mode 100644 index 0000000..6bbf479 --- /dev/null +++ b/src/test/furs-verify.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { signFursJws, verifyFursResponse } from "../lib/furs/jws"; +import { makeTestCert } from "./furs-fixtures"; + +// A second key-pair/cert stands in for FURS's response-signing certificate. +const furs = makeTestCert(); + +const responsePayload = { + InvoiceResponse: { + Header: { MessageID: "m-1", DateTime: "2026-05-25T14:30:01" }, + UniqueInvoiceID: "a1b2c3d4-e5f6-7890-abcd-ef0123456789", + }, +}; + +/** Mint a JWS as if FURS signed it (RS256 with FURS's key). */ +function fursSignedToken(payload: unknown = responsePayload): string { + return signFursJws(payload, furs.privateKeyPem, { + subjectName: "CN=blagajne.fu.gov.si", + issuerName: "CN=Tax CA Test", + serial: "1", + }); +} + +describe("verifyFursResponse", () => { + test("accepts a genuinely FURS-signed response and returns the payload", () => { + const decoded = verifyFursResponse(fursSignedToken(), furs.certPem); + expect(decoded.InvoiceResponse.UniqueInvoiceID).toBe(responsePayload.InvoiceResponse.UniqueInvoiceID); + }); + + test("rejects a tampered payload (spoofed EOR)", () => { + const [h, , s] = fursSignedToken().split("."); + const forged = { InvoiceResponse: { ...responsePayload.InvoiceResponse, UniqueInvoiceID: "ATTACKER-EOR" } }; + const forgedPayloadB64 = Buffer.from(JSON.stringify(forged), "utf8").toString("base64url"); + const tampered = `${h}.${forgedPayloadB64}.${s}`; + expect(() => verifyFursResponse(tampered, furs.certPem)).toThrow(); + }); + + test("rejects a response signed by a different (non-FURS) key", () => { + const impostor = makeTestCert(); + const token = signFursJws(responsePayload, impostor.privateKeyPem, { + subjectName: "CN=evil", + issuerName: "CN=evil", + serial: "2", + }); + expect(() => verifyFursResponse(token, furs.certPem)).toThrow(); + }); + + test("rejects a non-RS256 / malformed token", () => { + expect(() => verifyFursResponse("not.a.jws", furs.certPem)).toThrow(); + expect(() => verifyFursResponse("onlyonesegment", furs.certPem)).toThrow(); + }); +});