From 13f7bea590b914acd91b484c8b0b4dc4f52bd5df Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Mon, 17 Aug 2026 09:14:38 +0000 Subject: [PATCH] feat(eims): accept private key/cert as raw PEM env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EIMS_PRIVATE_KEY / EIMS_CERTIFICATE — the PEM text pasted directly, no encode/decode step at all. Precedence: raw PEM > base64 > path. Motivated by the base64 path hitting a DECODER::unsupported error in a live deployment with no way to tell whether the cause was transport truncation, double-encoding, or an actually-bad file. Two fixes for that class of problem together: - the raw-PEM var removes the encode/decode step entirely, so there's nothing left to corrupt in transit - a literal \\n (two chars) is unescaped to a real newline, for env stores that can't hold a literal line break - getPrivateKey() now checks the decoded bytes look like a PEM header before handing them to OpenSSL, so a still-bad value fails with byte count + safe preview instead of an opaque decoder error --- .../src/config/eims.config.spec.ts | 72 +++++++++++++++++++ .../edr-freight-api/src/config/eims.config.ts | 37 +++++++--- .../modules/eims/eims-credentials.provider.ts | 67 ++++++++++++++--- .../modules/eims/eims-signer.service.spec.ts | 32 +++++++++ .../src/modules/eims/eims-test-fixtures.ts | 2 + 5 files changed, 191 insertions(+), 19 deletions(-) create mode 100644 apps/edr-freight-api/src/config/eims.config.spec.ts diff --git a/apps/edr-freight-api/src/config/eims.config.spec.ts b/apps/edr-freight-api/src/config/eims.config.spec.ts new file mode 100644 index 000000000..a6aa3895d --- /dev/null +++ b/apps/edr-freight-api/src/config/eims.config.spec.ts @@ -0,0 +1,72 @@ +import eimsConfigFactory from "./eims.config"; + +const REQUIRED = { + EIMS_ENABLED: "true", + EIMS_CLIENT_ID: "cid", + EIMS_CLIENT_SECRET: "secret", + EIMS_API_KEY: "apikey", + EIMS_TIN: "0000000000", +}; + +const withEnv = (vars: Record, fn: () => void) => { + const prior: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prior[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + fn(); + } finally { + for (const [key, value] of Object.entries(prior)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("eims.config — private key / certificate resolution", () => { + it("unescapes a literal \\n when the PEM was pasted without real newlines", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("leaves a PEM with real newlines untouched", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "line1\nline2", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(eimsConfigFactory().privateKeyPem).toBe("line1\nline2"); + }, + ); + }); + + it("throws naming all three key/cert options when none are set", () => { + withEnv( + { + ...REQUIRED, + EIMS_PRIVATE_KEY_PATH: undefined, + EIMS_PRIVATE_KEY_BASE64: undefined, + EIMS_PRIVATE_KEY: undefined, + EIMS_CERTIFICATE_PATH: "/dev/null", + }, + () => { + expect(() => eimsConfigFactory()).toThrow( + /EIMS_PRIVATE_KEY_PATH or EIMS_PRIVATE_KEY_BASE64 or EIMS_PRIVATE_KEY/, + ); + }, + ); + }); + + it("is satisfied by any single one of the three key options", () => { + withEnv( + { ...REQUIRED, EIMS_PRIVATE_KEY: "x", EIMS_CERTIFICATE_PATH: "/dev/null" }, + () => { + expect(() => eimsConfigFactory()).not.toThrow(); + }, + ); + }); +}); diff --git a/apps/edr-freight-api/src/config/eims.config.ts b/apps/edr-freight-api/src/config/eims.config.ts index 7091c52ef..9d99ae455 100644 --- a/apps/edr-freight-api/src/config/eims.config.ts +++ b/apps/edr-freight-api/src/config/eims.config.ts @@ -33,11 +33,20 @@ export interface EimsConfig { /** * 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. + * Either one must be present when EIMS is enabled. Precedence: `privateKeyPem` > `privateKeyBase64` + * > `privateKeyPath`. */ privateKeyBase64: string; - /** Inline alternative to `certificatePath`, same precedence rule. */ + /** Inline alternative to `certificatePath`, same precedence rule as the key. */ certificateBase64: string; + /** + * The PEM key pasted directly into the env var, no encoding step at all — the most direct of the + * three inline forms, and the hardest for a broken transport step to mangle since there's no + * decode stage to get wrong. Wins over `privateKeyBase64`/`privateKeyPath` when set. + */ + privateKeyPem: string; + /** Inline alternative to `certificateBase64`, same precedence rule. */ + certificatePem: string; httpTimeoutMs: number; /** Re-authenticate this many ms before the access token actually expires. */ tokenSkewMs: number; @@ -145,11 +154,11 @@ export interface EimsInvoiceConfig { 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"], +// Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately +// from REQUIRED_VARS since it's "at least one of", not "this exact var". +const REQUIRED_ANY_OF: string[][] = [ + ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"], + ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"], ]; const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { @@ -171,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record => { return map; }; +// Some env stores (single-line .env files, certain secret managers) can't hold a literal newline +// and expect the caller to write "\n" as two characters instead. If the raw value already has a +// real newline, leave it alone; otherwise unescape "\n" so a PEM pasted that way still parses. +const normalizePem = (raw: string | undefined): string => { + if (!raw) return ""; + return raw.includes("\n") ? raw : raw.replace(/\\n/g, "\n"); +}; + /** Unset stays null so the registration-time check can name it; a set-but-bogus value throws. */ const optionalNumber = (raw: string | undefined, name: string): number | null => { if (raw === undefined || raw === "") return null; @@ -198,6 +215,8 @@ export default registerAs("eims", (): EimsConfig => { privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", + privateKeyPem: normalizePem(process.env.EIMS_PRIVATE_KEY), + certificatePem: normalizePem(process.env.EIMS_CERTIFICATE), certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "", httpTimeoutMs, tokenSkewMs, @@ -256,8 +275,8 @@ export default registerAs("eims", (): EimsConfig => { if (!enabled) return base; 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}`); + for (const vars of REQUIRED_ANY_OF) { + if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or ")); } if (missing.length > 0) { throw new Error( 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 18725df37..a5f837c1e 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 @@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config"; import { EimsConfig } from "../../config/eims.config"; import { EimsConfigException } from "./eims.errors"; +const PEM_HEADER = /-----BEGIN [A-Z ]*(PRIVATE KEY|CERTIFICATE)-----/; + +/** + * A safe-to-log fingerprint of decoded key/cert bytes: length + a printable-only preview of the + * first line. Never the actual key material — PEM headers aren't secret, the base64 body is. + */ +const describeBytes = (bytes: Buffer): string => { + const preview = bytes.toString("utf8", 0, 40).replace(/[^\x20-\x7e]/g, "?"); + return `${bytes.length} bytes, starts with "${preview}"`; +}; + /** * Loads the INSA-issued EIMS credentials from disk, once, and keeps them in memory. * @@ -25,20 +36,50 @@ export class EimsCredentialsProvider { } /** - * 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. + * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM + * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a + * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if + * none is usable. */ getPrivateKey(): KeyObject { if (this.privateKey) return this.privateKey; - 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"); + const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg; + const source = privateKeyPem + ? "EIMS_PRIVATE_KEY" + : privateKeyBase64 + ? "EIMS_PRIVATE_KEY_BASE64" + : `EIMS_PRIVATE_KEY_PATH (${path})`; + if (!privateKeyPem && !privateKeyBase64 && !path) { + throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); + } + + let bytes: Buffer; + try { + bytes = privateKeyPem + ? Buffer.from(privateKeyPem, "utf8") + : privateKeyBase64 + ? Buffer.from(privateKeyBase64, "base64") + : readFileSync(path); + } catch (err) { + throw new EimsConfigException( + `EIMS private key from ${source} could not be read or parsed: ${(err as Error).message}`, + ); + } + + // Fail with a diagnosable message before handing possibly-garbled bytes to OpenSSL, whose own + // error ("unsupported") gives no hint whether the problem is truncation, double-encoding, or a + // genuinely wrong file — all indistinguishable from outside without seeing the decoded bytes. + if (!PEM_HEADER.test(bytes.toString("utf8", 0, 100))) { + throw new EimsConfigException( + `EIMS private key from ${source} does not look like a PEM key after decoding ` + + `(${describeBytes(bytes)}) — check it's base64 of the raw key file with no line-wrapping ` + + `or truncation, and not base64 applied twice.`, + ); + } let key: KeyObject; try { - const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path); key = createPrivateKey(bytes); } catch (err) { // The source is operational information, not a secret; the key material never appears. @@ -58,13 +99,19 @@ export class EimsCredentialsProvider { } /** - * 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`. + * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued. + * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the + * pasted text respectively); otherwise read from `certificatePath`. */ getCertificateBase64(): string { if (this.certificateBase64) return this.certificateBase64; - const { certificateBase64: inline, certificatePath: path } = this.cfg; + const { certificatePem: pem, certificateBase64: inline, certificatePath: path } = this.cfg; + if (pem) { + this.certificateBase64 = Buffer.from(pem, "utf8").toString("base64"); + this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE`); + return this.certificateBase64; + } if (inline) { this.certificateBase64 = inline; this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); 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 d2f190172..f8210cab6 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 @@ -102,6 +102,8 @@ describe("EimsCredentialsProvider", () => { certificatePath?: string; privateKeyBase64?: string; certificateBase64?: string; + privateKeyPem?: string; + certificatePem?: string; }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); it("fails clearly when the key path is unset", () => { @@ -137,4 +139,34 @@ describe("EimsCredentialsProvider", () => { const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); }); + + it("fails with a decoded-bytes preview when the base64 doesn't decode to a PEM key", () => { + // Simulates the real failure this guards against: a truncated/mangled env var still decodes + // as *some* bytes, but not a key — OpenSSL's own error here gives no hint why. + const notAKey = Buffer.from("not actually a pem file", "utf8").toString("base64"); + expect(() => providerFor({ privateKeyBase64: notAKey }).getPrivateKey()).toThrow( + /does not look like a PEM key.*23 bytes, starts with "not actually a pem file"/s, + ); + }); + + it("loads the key from the raw PEM env var directly, no encoding step", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ privateKeyPem: pem }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("prefers the raw PEM var over base64 and path when all three are set", () => { + const pem = readFileSync(keyPath).toString("utf8"); + const key = providerFor({ + privateKeyPem: pem, + privateKeyBase64: Buffer.from("garbage").toString("base64"), + privateKeyPath: join(dir, "nope.key"), + }).getPrivateKey(); + expect(key.asymmetricKeyType).toBe("rsa"); + }); + + it("loads the certificate from the raw PEM env var, re-encoded to base64", () => { + const base64 = providerFor({ certificatePem: CERTIFICATE_FIXTURE }).getCertificateBase64(); + expect(base64).toBe(Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64")); + }); }); 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 662436d50..a55951db4 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 @@ -61,6 +61,8 @@ export const eimsConfig = (over: Partial = {}): EimsConfig => ({ certificatePath: "/dev/null", privateKeyBase64: "", certificateBase64: "", + privateKeyPem: "", + certificatePem: "", httpTimeoutMs: 30_000, tokenSkewMs: 45_000, autoSubmit: false,