diff --git a/push-server/apps/api/src/services/apns.ts b/push-server/apps/api/src/services/apns.ts index 2568f84..dfeb563 100644 --- a/push-server/apps/api/src/services/apns.ts +++ b/push-server/apps/api/src/services/apns.ts @@ -1,4 +1,5 @@ import http2 from "node:http2"; +import { createPrivateKey, type KeyObject } from "node:crypto"; import jwt from "jsonwebtoken"; import { env } from "../config/env.js"; @@ -21,6 +22,9 @@ export type ApnsResult = | { ok: false; code: string; message: string; permanent: boolean }; export class ApnsClient { + private signingKey?: KeyObject; + private signingKeyError?: string; + isConfigured(): boolean { return Boolean(env.APNS_TEAM_ID && env.APNS_KEY_ID && env.APNS_PRIVATE_KEY && env.IOS_BUNDLE_ID); } @@ -30,10 +34,20 @@ export class ApnsClient { return { ok: false, code: "apns_not_configured", message: "APNs ist nicht vollständig konfiguriert.", permanent: false }; } + const signingKey = this.getSigningKey(); + if (!signingKey) { + return { + ok: false, + code: "apns_invalid_private_key", + message: this.signingKeyError || "APNs Private Key ist ungültig.", + permanent: false, + }; + } + const host = env.APNS_PRODUCTION ? "https://api.push.apple.com" : "https://api.sandbox.push.apple.com"; const token = jwt.sign( { iss: env.APNS_TEAM_ID, iat: Math.floor(Date.now() / 1000) }, - env.APNS_PRIVATE_KEY.replace(/\\n/g, "\n"), + signingKey, { algorithm: "ES256", header: { alg: "ES256", kid: env.APNS_KEY_ID } }, ); @@ -88,6 +102,23 @@ export class ApnsClient { req.end(JSON.stringify(payload)); }); } + + private getSigningKey(): KeyObject | null { + if (this.signingKey) return this.signingKey; + + try { + this.signingKey = createPrivateKey(normalizePem(env.APNS_PRIVATE_KEY)); + if (this.signingKey.asymmetricKeyType !== "ec") { + this.signingKeyError = "APNs Private Key muss ein elliptic-curve Private Key aus App Store Connect sein."; + this.signingKey = undefined; + return null; + } + return this.signingKey; + } catch { + this.signingKeyError = "APNs Private Key konnte nicht gelesen werden. Bitte den .p8 Key mit Zeilenumbrüchen oder escaped \\n hinterlegen."; + return null; + } + } } function safeReason(body: string): string | null { @@ -98,3 +129,15 @@ function safeReason(body: string): string | null { return null; } } + +function normalizePem(value: string): string { + const withEscapedNewlines = value.trim().replace(/\\n/g, "\n"); + if (withEscapedNewlines.includes("\n")) return withEscapedNewlines; + + const match = withEscapedNewlines.match(/^(-----BEGIN [^-]+-----)(.+)(-----END [^-]+-----)$/); + if (!match) return withEscapedNewlines; + + const body = match[2].replace(/\s+/g, ""); + const wrappedBody = body.match(/.{1,64}/g)?.join("\n") || body; + return `${match[1]}\n${wrappedBody}\n${match[3]}`; +}