diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index c5565cbc1..7091c52ef 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -30,6 +30,14 @@ export interface EimsConfig { privateKeyPath: string; /** Filesystem path to the INSA-issued certificate bundle; sent as base64 of its exact bytes. */ certificatePath: string; + /** + * Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a + * container that can't be given a host bind mount can still receive it as a plain env var. + * Takes precedence over the path when set. Either one must be present when EIMS is enabled. + */ + privateKeyBase64: string; + /** Inline alternative to `certificatePath`, same precedence rule. */ + certificateBase64: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -135,14 +143,14 @@ export interface EimsInvoiceConfig { buyerIdNumber: string | null; } -const REQUIRED_VARS = [ - "EIMS_CLIENT_ID", - "EIMS_CLIENT_SECRET", - "EIMS_API_KEY", - "EIMS_TIN", - "EIMS_PRIVATE_KEY_PATH", - "EIMS_CERTIFICATE_PATH", -] as const; +const REQUIRED_VARS = ["EIMS_CLIENT_ID", "EIMS_CLIENT_SECRET", "EIMS_API_KEY", "EIMS_TIN"] as const; + +// Key/cert each have two ways in (file path or inline base64) — checked separately from +// REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_EITHER_OR: Array<[string, string]> = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"], +]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { if (raw === undefined || raw === "") return fallback; @@ -189,6 +197,8 @@ export default registerAs("eims", (): EimsConfig => { systemType: process.env.EIMS_SYSTEM_TYPE ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", + privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, autoSubmit: (process.env.EIMS_AUTO_SUBMIT ?? "false").toLowerCase() === "true", @@ -245,7 +255,10 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; - const missing = REQUIRED_VARS.filter((name) => !process.env[name]); + const missing: string[] = REQUIRED_VARS.filter((name) => !process.env[name]); + for (const [pathVar, base64Var] of REQUIRED_EITHER_OR) { + if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`); + } if (missing.length > 0) { throw new Error( `EIMS integration is enabled (EIMS_ENABLED=true) but the following env vars are missing: ${missing.join(", ")}`, diff --git a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts index b68a4a32f..18725df37 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-credentials.provider.ts @@ -24,25 +24,31 @@ export class EimsCredentialsProvider { return this.config.get("eims")!; } - /** RSA private key, parsed once. Throws a config error if the path is missing or unusable. */ + /** + * RSA private key, parsed once. `privateKeyBase64` wins when set (no file I/O at all — for a + * container that can't be given a host bind mount); otherwise falls back to `privateKeyPath`. + * Throws a config error if neither is usable. + */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - const path = this.cfg.privateKeyPath; - if (!path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + const { privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyBase64 ? "EIMS_PRIVATE_KEY_BASE64" : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyBase64 && !path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); let key: KeyObject; try { - key = createPrivateKey(readFileSync(path)); + const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path); + key = createPrivateKey(bytes); } catch (err) { - // The path is operational information, not a secret; the key material never appears. + // The source is operational information, not a secret; the key material never appears. throw new EimsConfigException( - `EIMS private key at ${path} could not be read or parsed: ${(err as Error).message}`, + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, ); } if (key.asymmetricKeyType !== "rsa") { throw new EimsConfigException( - `EIMS private key at ${path} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, + `EIMS private key from ${source} is ${key.asymmetricKeyType ?? "of unknown type"}; EIMS requires RSA`, ); } @@ -51,11 +57,20 @@ export class EimsCredentialsProvider { return key; } - /** Base64 of the certificate file's exact bytes. No parsing, no re-encoding. */ + /** + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding. `certificateBase64` + * config wins when set (already base64, used as-is); otherwise read from `certificatePath`. + */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const path = this.cfg.certificatePath; + const { certificateBase64: inline, certificatePath: path } = this.cfg; + if (inline) { + this.certificateBase64 = inline; + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); + return this.certificateBase64; + } + if (!path) throw new EimsConfigException("EIMS_CERTIFICATE_PATH is not set"); let bytes: Buffer; diff --git a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts index 5e408ddf7..d2f190172 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-signer.service.spec.ts @@ -97,8 +97,12 @@ describe("EimsSignerService", () => { }); describe("EimsCredentialsProvider", () => { - const providerFor = (paths: { privateKeyPath?: string; certificatePath?: string }) => - new EimsCredentialsProvider({ get: () => paths } as unknown as ConfigService); + const providerFor = (cfg: { + privateKeyPath?: string; + certificatePath?: string; + privateKeyBase64?: string; + certificateBase64?: string; + }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { expect(() => providerFor({}).getPrivateKey()).toThrow(/EIMS_PRIVATE_KEY_PATH is not set/); @@ -115,4 +119,22 @@ describe("EimsCredentialsProvider", () => { writeFileSync(emptyPath, ""); expect(() => providerFor({ certificatePath: emptyPath }).getCertificateBase64()).toThrow(/is empty/); }); + + it("loads the key from inline base64, no file involved", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + const key = providerFor({ privateKeyBase64: keyBase64 }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers inline base64 over the path when both are set", () => { + const keyBase64 = readFileSync(keyPath).toString("base64"); + // A path that would fail if it were ever actually read. + const key = providerFor({ privateKeyBase64: keyBase64, privateKeyPath: join(dir, "nope.key") }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from inline base64 as-is, no re-encoding", () => { + const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); + expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); + }); }); diff --git a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts index 650a834b8..662436d50 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-test-fixtures.ts @@ -59,6 +59,8 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ systemType: EIMS_SYSTEM_TYPE, privateKeyPath: "/dev/null", certificatePath: "/dev/null", + privateKeyBase64: "", + certificateBase64: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false,