fix(apns): defensive PEM normalisation + ES256 verify (v1.4.47.2)

Coolify runtime logs surfaced
"secretOrPrivateKey must be an asymmetric key when using ES256"
on every push attempt. The channel auto-paused after 3 consecutive
failures.

Root cause: the .p8 PEM stored in the Coolify env-var arrived at
process.env.APNS_KEY without parseable newlines around the BEGIN/END
markers (env_file round-trip artefact). openssl pkey accepts it,
jsonwebtoken@9 used by node-apn is strict.

Normalise the PEM after the 12-factor \n unescape: strip whitespace
between markers, force 64-char line wrap, rebuild canonical PEM.
Idempotent on already-correct inputs. Verify via crypto.createPrivateKey
before handing to node-apn.
This commit is contained in:
Marc-André Bombeck authored and Marc-André Bombeck committed 2026-05-23 09:59:21 +02:00
1 parent be433c31ca
commit 0be10edc66
4 files changed
+157 -2

No files matched your search

+38
View File
@@ -1,5 +1,43 @@
# Changelog
## [1.4.47.2] — 2026-05-22 — APNs JWT signing repair (defensive PEM normalisation)
Same-day follow-up to v1.4.47.1. Coolify runtime logs surfaced
```
Notification sender threw for APNS:
Failed to generate token: secretOrPrivateKey must be an asymmetric
key when using ES256
```
on every push attempt. The channel had auto-paused after 3 consecutive failures (admin UI showed `sender_threw · Versand pausiert · Nächster Versuch 11:10`).
Root cause: the .p8 PEM stored in the Coolify env-var arrived at `process.env.APNS_KEY` without parseable newlines around the BEGIN / END markers — likely a `docker-compose env_file` round-trip artefact. `openssl pkey` could still parse it, but `jsonwebtoken@9` (used by `@parse/node-apn`) is strict and refused to sign.
### Changed
- `src/lib/notifications/senders/apns.ts:loadApnsConfig` — after the `.replace(/\\n/g, "\n")` 12-factor unescape, normalise the PEM body: strip whitespace between markers, force a 64-char line wrap of the base64 payload, and rebuild a canonical PEM. Idempotent on already-correct PEMs; recovers bare-base64 inputs without markers too. Then verify the result parses as an asymmetric EC key via `crypto.createPrivateKey` before handing to node-apn. On verification failure, return `null` with a one-time warning — beats `sender_threw` on every push.
### Effect
- APNs channel returns to active on the next push attempt (the channel-state machine clears the cooldown on the first success).
- No env-var change required; the existing `APNS_KEY` value is normalised in-process.
- Operators with already-correct multi-line PEMs see no behavioural difference.
### Test plan
- [x] 3 new unit tests cover the v1.4.47.2 normalisation paths: collapsed single-line PEM, bare base64 body without markers, unparseable garbage returns null + warning.
- [x] Updated `VALID_ENV.APNS_KEY` fixture to a real EC P-256 key so the existing tests exercise the verification path end-to-end.
- [x] `pnpm typecheck` clean, `pnpm lint` clean, full suite green.
### Risk
Low. Adds defensive normalisation + a parse check. Bad PEMs that previously caused `sender_threw` on every push now cleanly disable the APNs channel with a single warning at load time. Good PEMs are passed through unchanged after the canonical re-wrap (which produces an identical PEM for already-correct inputs).
### Operator notes
Standard image roll. No `prisma migrate deploy` step required.
## [1.4.47.1] — 2026-05-22 — Slim summaries slice 9 s → ~0.5-1 s cold
Same-day hotfix on top of v1.4.47. Dashboard cold mount on power-user accounts was firing `/api/analytics?slice=summaries` at ~9 s TTFB; the time was almost entirely in one `$queryRaw` against the `measurements` table.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.47.1",
"version": "1.4.47.2",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -74,11 +74,26 @@ import {
} from "@/lib/notifications/senders/apns";
import { prisma } from "@/lib/db";
// v1.4.47.2 — real EC P-256 .p8 (test-only, generated for this suite).
// loadApnsConfig now verifies the key parses as an ES256-compatible
// asymmetric key via `crypto.createPrivateKey`, so the fixture cannot
// be a mock string — node-apn would later fail JWT signing on a non-EC
// payload with the same error this hotfix exists to prevent.
const TEST_EC_PEM_LINES = [
"-----BEGIN PRIVATE KEY-----",
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
"bw81BG89",
"-----END PRIVATE KEY-----",
];
const VALID_ENV = {
APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios",
APNS_KEY: "-----BEGIN PRIVATE KEY-----\\nMOCKKEY\\n-----END PRIVATE KEY-----",
// Mimic the 12-factor `\n`-escaped single-line form a typical
// Coolify / docker-compose `env_file` round-trip produces.
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
};
function setEnv(over: Record<string, string | undefined> = {}): void {
@@ -161,6 +176,40 @@ describe("loadApnsConfig — all-or-none env-var guard", () => {
expect(a).toBe(b);
});
it("normalises a single-line PEM that lost its newlines", () => {
// Reproduce the v1.4.47.2 failure mode: the .env round-trip
// stripped the `\n` escapes, arriving as a single line
// `-----BEGIN PRIVATE KEY-----<base64-body>-----END PRIVATE KEY-----`.
// jsonwebtoken@9 rejects this as "not an asymmetric key"; the
// normaliser re-wraps the body so node-apn's JWT signer accepts it.
const collapsed = TEST_EC_PEM_LINES.join("");
setEnv({ ...VALID_ENV, APNS_KEY: collapsed });
const config = loadApnsConfig();
expect(config).toBeTruthy();
expect(config?.signingKey.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
expect(config?.signingKey.endsWith("\n-----END PRIVATE KEY-----")).toBe(true);
});
it("accepts a bare base64 body without BEGIN/END markers", () => {
// Some operator workflows paste just the base64 payload between
// the markers (Apple's portal does this for some clipboard paths).
// Wrap and re-emit a canonical PEM.
const bareBase64 = TEST_EC_PEM_LINES.slice(1, -1).join("");
setEnv({ ...VALID_ENV, APNS_KEY: bareBase64 });
const config = loadApnsConfig();
expect(config).toBeTruthy();
expect(config?.signingKey.startsWith("-----BEGIN PRIVATE KEY-----\n")).toBe(true);
});
it("returns null + warns when APNS_KEY does not parse as an asymmetric key", () => {
setEnv({
...VALID_ENV,
APNS_KEY: "-----BEGIN PRIVATE KEY-----\\nNOTAREALKEY\\n-----END PRIVATE KEY-----",
});
const config = loadApnsConfig();
expect(config).toBeNull();
});
it("APNS_PRODUCTION=true sets forceProduction", () => {
setEnv({ ...VALID_ENV, APNS_PRODUCTION: "true" });
expect(loadApnsConfig()?.forceProduction).toBe(true);
+68
View File
@@ -21,6 +21,7 @@
* separately — that's what the library is for, and pinning a single
* token to the process lifetime would expire ~50 minutes after boot.
*/
import { createPrivateKey } from "node:crypto";
import { readFileSync } from "node:fs";
import apn from "@parse/node-apn";
@@ -188,6 +189,40 @@ export function loadApnsConfig(): ApnsConfig | null {
// when the env block is a single line. Convert them back here so
// node-apn sees a real PEM. No-op for already-multiline values.
signingKey = inlineKey.replace(/\\n/g, "\n");
// v1.4.47.2 — defensive PEM normalisation.
//
// jsonwebtoken@9 (used by @parse/node-apn) throws
// `secretOrPrivateKey must be an asymmetric key when using ES256`
// when the PEM body has no newlines between BEGIN/END markers and
// the base64 payload — even though openssl can still parse it.
// Coolify + docker-compose `env_file` round-trips have at least
// three known failure modes that drop newlines: the env value
// arriving without `\n` escapes, an editor stripping the trailing
// newline at the marker boundary, or a copy-paste from the Apple
// Developer portal that lost line breaks. Force-rewrap the
// base64 body at 64-char lines if we cannot parse the key as-is.
signingKey = normaliseApnsPem(signingKey);
// Belt-and-suspenders: verify the key actually parses as an
// asymmetric EC key before we hand it to node-apn. A single
// warning per process beats hundreds of `sender_threw` retries
// every time the worker tries to dispatch a push.
try {
const keyObject = createPrivateKey({ key: signingKey, format: "pem" });
if (keyObject.asymmetricKeyType !== "ec") {
getEvent()?.addWarning(
`APNs key parsed as ${keyObject.asymmetricKeyType ?? "unknown"}; expected "ec" for ES256`,
);
cachedConfig = null;
return null;
}
} catch (err) {
const message = err instanceof Error ? err.message : "parse_failed";
getEvent()?.addWarning(`APNs key did not parse as PEM: ${message}`);
cachedConfig = null;
return null;
}
} else {
try {
signingKey = readFileSync(/* turbopackIgnore: true */ keyFile, "utf8");
@@ -203,6 +238,39 @@ export function loadApnsConfig(): ApnsConfig | null {
return cachedConfig;
}
/**
* v1.4.47.2 — repair PEM strings that lost newlines along the
* env-var → docker-compose → process.env pipeline.
*
* If the input already has real newlines around the BEGIN/END markers
* AND the body wraps at <=64 chars per line (Apple's .p8 default),
* it is returned unchanged. Otherwise we strip everything outside
* the BEGIN/END markers, rewrap the base64 body at 64-char lines,
* and rebuild a canonical PEM. Idempotent on already-correct PEMs.
*/
function normaliseApnsPem(raw: string): string {
const beginMarker = "-----BEGIN PRIVATE KEY-----";
const endMarker = "-----END PRIVATE KEY-----";
const beginIdx = raw.indexOf(beginMarker);
const endIdx = raw.indexOf(endMarker);
if (beginIdx < 0 || endIdx <= beginIdx) {
// Bare base64 body without markers — wrap it.
const body = raw.replace(/\s+/g, "");
if (!/^[A-Za-z0-9+/=]+$/.test(body)) {
// Not recognisable; return as-is and let downstream complain.
return raw;
}
const wrapped = body.replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
return `${beginMarker}\n${wrapped}\n${endMarker}`;
}
const body = raw
.slice(beginIdx + beginMarker.length, endIdx)
.replace(/\s+/g, "");
if (!body) return raw;
const wrapped = body.replace(/(.{64})/g, "$1\n").replace(/\n$/, "");
return `${beginMarker}\n${wrapped}\n${endMarker}`;
}
/**
* Reset the cached config + Provider singletons. Tests use this to
* re-read env vars between cases; production never calls it.