mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
be433c31ca
commit
0be10edc66
4 files changed
+157
-2
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user