Files
edr-platform/apps/edr-freight-api/src/modules/eims/eims-signer.service.ts
Hagernesh 2644d5e52d feat(eims): add invoice mapper and signed EIMS transport
Map EDR invoices onto the MoR EIMS /v1/register document and add the
cryptographic transport needed to talk to core.mor.gov.et.

Mapper: DTOs mirror the supplied Postman collection section by section.
Tax is resolved per line via a caller-supplied resolver and throws when
unresolved -- the app models no tax at all (invoice.taxAmount is always 0,
invoice_lines and the rate catalogue carry no fiscal columns), so a
zero-rated default would assert a tax position the codebase cannot support.
Seller identity, document number, counters and previous IRN are passed in
explicitly; the mapper stays pure.

Transport: config, credential loading, RSA-SHA512 signing and /auth/login
with an in-memory token cache. Signing reproduces the process that produced
a working live token -- compact JSON of the inner request only, exact UTF-8
bytes, base64 signature, and base64 of the certificate file's exact bytes
with no parsing or re-encoding. Concurrent callers share one login via an
in-flight promise. Refresh is deliberately unimplemented: the collection
shows an unsigned refresh body but also ships unsigned examples of calls
that do require signing, so an expired token re-logs in instead.

Errors normalise to EimsApiException carrying only the gateway's own error
fields; secrets, signature, certificate and tokens never reach logs.
Key and certificate file patterns are gitignored.

Nothing calls EIMS automatically and no invoice entity, migration or UI is
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:08:40 +00:00

38 lines
1.5 KiB
TypeScript

import { createSign } from "node:crypto";
import { Injectable } from "@nestjs/common";
import { EimsCredentialsProvider } from "./eims-credentials.provider";
import { EimsSignedRequest } from "./eims.types";
/**
* Signs EIMS request objects, reproducing the process that produced a working live access token:
*
* 1. compact `JSON.stringify` of the **inner** request object only,
* 2. those exact UTF-8 bytes,
* 3. RSA + SHA-512 (`SHA512withRSA`, PKCS#1 v1.5 — Node's default RSA padding),
* 4. base64 of the raw signature bytes (256 bytes for an RSA-2048 key),
* 5. base64 of the certificate file's exact bytes.
*
* The outer `{request, signature, certificate}` envelope is never itself signed, and the request
* object is never mutated after serialization.
*/
@Injectable()
export class EimsSignerService {
constructor(private readonly credentials: EimsCredentialsProvider) {}
signRequest<T>(request: T): EimsSignedRequest<T> {
const payload = JSON.stringify(request);
const signature = createSign("RSA-SHA512")
.update(payload, "utf8")
.sign(this.credentials.getPrivateKey(), "base64");
return { request, signature, certificate: this.credentials.getCertificateBase64() };
}
}
/**
* Exact wire body for a signed envelope. Serializing here (rather than handing axios an object)
* keeps one serializer in play: the `request` segment of this string is byte-identical to the
* string that was signed.
*/
export const toSignedBody = <T>(signed: EimsSignedRequest<T>): string => JSON.stringify(signed);