mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
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:
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal file
72
apps/edr-freight-api/src/config/eims.config.spec.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> => {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user