feat(eims): accept private key/cert as raw PEM env vars

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
This commit is contained in:
Hagernesh
2026-08-17 09:14:38 +00:00
parent 13b0274da2
commit 13f7bea590
5 changed files with 191 additions and 19 deletions

View File

@@ -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<string, string | undefined>, fn: () => void) => {
const prior: Record<string, string | undefined> = {};
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();
},
);
});
});

View File

@@ -33,11 +33,20 @@ export interface EimsConfig {
/** /**
* Inline alternative to `privateKeyPath` — the key file's own bytes, base64-encoded, so a * 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. * 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; privateKeyBase64: string;
/** Inline alternative to `certificatePath`, same precedence rule. */ /** Inline alternative to `certificatePath`, same precedence rule as the key. */
certificateBase64: string; 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; httpTimeoutMs: number;
/** Re-authenticate this many ms before the access token actually expires. */ /** Re-authenticate this many ms before the access token actually expires. */
tokenSkewMs: number; 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; 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 // Key/cert each have three ways in (file path, inline base64, or raw PEM) — checked separately
// REQUIRED_VARS since it's "at least one of", not "this exact var". // from REQUIRED_VARS since it's "at least one of", not "this exact var".
const REQUIRED_EITHER_OR: Array<[string, string]> = [ const REQUIRED_ANY_OF: string[][] = [
["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64"], ["EIMS_PRIVATE_KEY_PATH", "EIMS_PRIVATE_KEY_BASE64", "EIMS_PRIVATE_KEY"],
["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64"], ["EIMS_CERTIFICATE_PATH", "EIMS_CERTIFICATE_BASE64", "EIMS_CERTIFICATE"],
]; ];
const positiveInt = (raw: string | undefined, fallback: number, name: string): number => { const positiveInt = (raw: string | undefined, fallback: number, name: string): number => {
@@ -171,6 +180,14 @@ const parseCodeMap = (raw: string | undefined): Record<string, string> => {
return map; 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. */ /** 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 => { const optionalNumber = (raw: string | undefined, name: string): number | null => {
if (raw === undefined || raw === "") return null; if (raw === undefined || raw === "") return null;
@@ -198,6 +215,8 @@ export default registerAs("eims", (): EimsConfig => {
privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "", privateKeyPath: process.env.EIMS_PRIVATE_KEY_PATH ?? "",
certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "", certificatePath: process.env.EIMS_CERTIFICATE_PATH ?? "",
privateKeyBase64: process.env.EIMS_PRIVATE_KEY_BASE64 ?? "", 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 ?? "", certificateBase64: process.env.EIMS_CERTIFICATE_BASE64 ?? "",
httpTimeoutMs, httpTimeoutMs,
tokenSkewMs, tokenSkewMs,
@@ -256,8 +275,8 @@ export default registerAs("eims", (): EimsConfig => {
if (!enabled) return base; if (!enabled) return base;
const missing: string[] = 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) { for (const vars of REQUIRED_ANY_OF) {
if (!process.env[pathVar] && !process.env[base64Var]) missing.push(`${pathVar} or ${base64Var}`); if (vars.every((name) => !process.env[name])) missing.push(vars.join(" or "));
} }
if (missing.length > 0) { if (missing.length > 0) {
throw new Error( throw new Error(

View File

@@ -5,6 +5,17 @@ import { ConfigService } from "@nestjs/config";
import { EimsConfig } from "../../config/eims.config"; import { EimsConfig } from "../../config/eims.config";
import { EimsConfigException } from "./eims.errors"; 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. * 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 * RSA private key, parsed once. Three ways in, checked in this order: `privateKeyPem` (the PEM
* container that can't be given a host bind mount); otherwise falls back to `privateKeyPath`. * text itself, no encoding step to get wrong), `privateKeyBase64` (for stores that can't hold a
* Throws a config error if neither is usable. * literal newline), `privateKeyPath` (the original file-on-disk form). Throws a config error if
* none is usable.
*/ */
getPrivateKey(): KeyObject { getPrivateKey(): KeyObject {
if (this.privateKey) return this.privateKey; if (this.privateKey) return this.privateKey;
const { privateKeyBase64, privateKeyPath: path } = this.cfg; const { privateKeyPem, privateKeyBase64, privateKeyPath: path } = this.cfg;
const source = privateKeyBase64 ? "EIMS_PRIVATE_KEY_BASE64" : `EIMS_PRIVATE_KEY_PATH (${path})`; const source = privateKeyPem
if (!privateKeyBase64 && !path) throw new EimsConfigException("EIMS_PRIVATE_KEY_PATH is not set"); ? "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; let key: KeyObject;
try { try {
const bytes = privateKeyBase64 ? Buffer.from(privateKeyBase64, "base64") : readFileSync(path);
key = createPrivateKey(bytes); key = createPrivateKey(bytes);
} catch (err) { } catch (err) {
// The source is operational information, not a secret; the key material never appears. // 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` * Base64 of the certificate file's exact bytes. No parsing, no re-encoding of what MoR issued.
* config wins when set (already base64, used as-is); otherwise read from `certificatePath`. * `certificatePem`/`certificateBase64` config win when set (used as-is, or re-encoded from the
* pasted text respectively); otherwise read from `certificatePath`.
*/ */
getCertificateBase64(): string { getCertificateBase64(): string {
if (this.certificateBase64) return this.certificateBase64; 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) { if (inline) {
this.certificateBase64 = inline; this.certificateBase64 = inline;
this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`); this.logger.log(`EIMS certificate bundle loaded from EIMS_CERTIFICATE_BASE64`);

View File

@@ -102,6 +102,8 @@ describe("EimsCredentialsProvider", () => {
certificatePath?: string; certificatePath?: string;
privateKeyBase64?: string; privateKeyBase64?: string;
certificateBase64?: string; certificateBase64?: string;
privateKeyPem?: string;
certificatePem?: string;
}) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService); }) => new EimsCredentialsProvider({ get: () => cfg } as unknown as ConfigService);
it("fails clearly when the key path is unset", () => { it("fails clearly when the key path is unset", () => {
@@ -137,4 +139,34 @@ describe("EimsCredentialsProvider", () => {
const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64"); const certBase64 = Buffer.from(CERTIFICATE_FIXTURE, "utf8").toString("base64");
expect(providerFor({ certificateBase64: certBase64 }).getCertificateBase64()).toBe(certBase64); 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"));
});
}); });

View File

@@ -61,6 +61,8 @@ export const eimsConfig = (over: Partial<EimsConfig> = {}): EimsConfig => ({
certificatePath: "/dev/null", certificatePath: "/dev/null",
privateKeyBase64: "", privateKeyBase64: "",
certificateBase64: "", certificateBase64: "",
privateKeyPem: "",
certificatePem: "",
httpTimeoutMs: 30_000, httpTimeoutMs: 30_000,
tokenSkewMs: 45_000, tokenSkewMs: 45_000,
autoSubmit: false, autoSubmit: false,