From 0be10edc66ea95d44ff34e84fa248579afac133a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 11:17:20 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 38 +++++++++++ package.json | 2 +- .../senders/__tests__/apns.test.ts | 51 +++++++++++++- src/lib/notifications/senders/apns.ts | 68 +++++++++++++++++++ 4 files changed, 157 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d5089dca..177b6a123 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/package.json b/package.json index f2b673ab7..e0988fac4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index 3645ae8b3..acee7ee49 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -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 = {}): 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----------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); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index f032e2ef7..82c4d6bfc 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -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.