Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
30 changes: 25 additions & 5 deletions src/lib/furs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -57,11 +64,14 @@ export interface FursClient {
/** Register an immovable business premise. Resolves `true` on success. */
registerBusinessPremise(
premise: FursBusinessPremise,
): Effect.Effect<true, FursConnectionError | FursError | InvalidInvoiceError>;
): Effect.Effect<true, FursConnectionError | FursError | FursResponseSignatureError | InvalidInvoiceError>;
/** Fiscally verify an invoice → ZOI + EOR + printable mark. */
reportInvoice(
invoice: FursInvoice,
): Effect.Effect<InvoiceResult, FursConnectionError | FursError | InvalidInvoiceError>;
): Effect.Effect<
InvoiceResult,
FursConnectionError | FursError | FursResponseSignatureError | InvalidInvoiceError
>;
/** The loaded certificate's identity (subject/issuer/serial). */
readonly cert: FursCert;
}
Expand Down Expand Up @@ -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<Record<string, Record<string, any>>>(responseToken);
// Verify FURS's signature when a cert is configured; otherwise decode unverified.
const decoded = config.fursResponseCertPem
? yield* Effect.try({
try: () =>
verifyFursResponse<Record<string, Record<string, any>>>(
responseToken,
config.fursResponseCertPem as string,
),
catch: (cause) => new FursResponseSignatureError(undefined, cause),
})
: decodeJwsPayload<Record<string, Record<string, any>>>(responseToken);
const envelope = decoded[Object.keys(decoded)[0] ?? ""];
if (envelope?.Error) {
return yield* Effect.fail(new FursError(envelope.Error.ErrorCode, envelope.Error.ErrorMessage));
Expand Down
21 changes: 20 additions & 1 deletion src/lib/furs/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
37 changes: 33 additions & 4 deletions src/lib/furs/jws.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createSign } from "node:crypto";
import { createSign, createVerify, X509Certificate } from "node:crypto";

export interface JwsIdentity {
subjectName: string;
Expand Down Expand Up @@ -33,16 +33,45 @@ 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<T = unknown>(token: string): T {
const segment = token.split(".")[1];
if (!segment) throw new Error("malformed JWS: missing payload segment");
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<T = unknown>(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");
}
3 changes: 3 additions & 0 deletions src/test/furs-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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),
};
}
52 changes: 52 additions & 0 deletions src/test/furs-verify.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof responsePayload>(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();
});
});
Loading