1// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no createPrivateKey.
2import * as NodeCrypto from "node:crypto";
3
4import { p256 } from "@noble/curves/nist";
5import { sha256 } from "@noble/hashes/sha2";
6import * as Effect from "effect/Effect";
7import * as Base64Url from "effect/encoding/Base64Url";
8import * as Redacted from "effect/Redacted";
9import * as Result from "effect/Result";
10import * as Schema from "effect/Schema";
11
12import type { ApnsCredentials } from "../Config.ts";
13
14export class ApnsJwtEncodingError extends Schema.TaggedError<ApnsJwtEncodingError>()(
15 "ApnsJwtEncodingError",
16 {
17 component: Schema.Literals(["header", "payload"]),
18 teamId: Schema.String,
19 keyId: Schema.String,
20 issuedAtUnixSeconds: Schema.Number,
21 cause: Schema.Defect(),
22 },
23) {
24 override get message(): string {
25 return `Failed to encode APNs JWT ${this.component} for key ${this.keyId}.`;
26 }
27}
28
29export class ApnsJwtSigningError extends Schema.TaggedError<ApnsJwtSigningError>()(
30 "ApnsJwtSigningError",
31 {
32 teamId: Schema.String,
33 keyId: Schema.String,
34 issuedAtUnixSeconds: Schema.Number,
35 cause: Schema.Defect(),
36 },
37) {
38 override get message(): string {
39 return `Failed to sign APNs JWT for key ${this.keyId}.`;
40 }
41}
42
43export type ApnsJwtError = ApnsJwtEncodingError | ApnsJwtSigningError;
44
45const encodeApnsJwtHeaderJson = Schema.encodeEffect(
46 Schema.fromJsonString(
47 Schema.Struct({
48 alg: Schema.Literal("ES256"),
49 kid: Schema.String,
50 }),
51 ),
52);
53const encodeApnsJwtPayloadJson = Schema.encodeEffect(
54 Schema.fromJsonString(
55 Schema.Struct({
56 iss: Schema.String,
57 iat: Schema.Number,
58 }),
59 ),
60);
61
62export interface ApnsJwtSigningInput {
63 readonly teamId: ApnsCredentials["teamId"];
64 readonly keyId: ApnsCredentials["keyId"];
65 readonly privateKey: ApnsCredentials["privateKey"];
66 readonly issuedAtUnixSeconds: number;
67}
68
69export const makeApnsJwt = Effect.fn("relay.apns.make_jwt")(function* (input: ApnsJwtSigningInput) {
70 const headerJson = yield* encodeApnsJwtHeaderJson({ alg: "ES256", kid: input.keyId }).pipe(
71 Effect.mapError(
72 (cause) =>
73 new ApnsJwtEncodingError({
74 component: "header",
75 teamId: input.teamId,
76 keyId: input.keyId,
77 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
78 cause,
79 }),
80 ),
81 );
82 const payloadJson = yield* encodeApnsJwtPayloadJson({
83 iss: input.teamId,
84 iat: input.issuedAtUnixSeconds,
85 }).pipe(
86 Effect.mapError(
87 (cause) =>
88 new ApnsJwtEncodingError({
89 component: "payload",
90 teamId: input.teamId,
91 keyId: input.keyId,
92 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
93 cause,
94 }),
95 ),
96 );
97
98 const privateKey = Redacted.value(input.privateKey);
99 const header = Base64Url.encode(headerJson);
100 const payload = Base64Url.encode(payloadJson);
101 const signingInput = `${header}.${payload}`;
102
103 return yield* Effect.try({
104 try: () => {
105 // Deterministic ES256 (RFC 6979 via noble) instead of node's randomized
106 // signer: identical (key, iat) yields the byte-identical JWT on every
107 // worker isolate, so the fleet presents one stable provider token to
108 // APNs without any shared storage. Node crypto only converts the PEM to
109 // the raw scalar noble signs with.
110 const scalar = apnsSigningScalar(privateKey);
111 const signature = p256
112 .sign(sha256(new TextEncoder().encode(signingInput)), scalar, { prehash: false })
113 .toCompactRawBytes();
114 return `${signingInput}.${Base64Url.encode(signature)}`;
115 },
116 catch: (cause) =>
117 new ApnsJwtSigningError({
118 teamId: input.teamId,
119 keyId: input.keyId,
120 issuedAtUnixSeconds: input.issuedAtUnixSeconds,
121 cause,
122 }),
123 });
124});
125
126// PEM parsing is pure and the key set is static per deployment; memoize the
127// extracted P-256 scalar so signing never re-parses the PKCS8 document.
128const signingScalarCache = new Map<string, Uint8Array>();
129
130function apnsSigningScalar(privateKeyPem: string): Uint8Array {
131 const cached = signingScalarCache.get(privateKeyPem);
132 if (cached) {
133 return cached;
134 }
135 const jwk = NodeCrypto.createPrivateKey(privateKeyPem.replace(/\\n/g, "\n")).export({
136 format: "jwk",
137 });
138 if (jwk.crv !== "P-256" || typeof jwk.d !== "string") {
139 throw new Error("APNs signing key is not a P-256 private key.");
140 }
141 const scalar = Result.getOrThrowWith(
142 Base64Url.decode(jwk.d),
143 () => new Error("APNs signing key scalar is not valid base64url."),
144 );
145 signingScalarCache.set(privateKeyPem, scalar);
146 return scalar;
147}
148
149// Fingerprint the key material so rotated credentials never reuse a JWT
150// signed by the previous key.
151export function apnsProviderTokenCacheKey(input: {
152 readonly teamId: string;
153 readonly keyId: string;
154 readonly privateKey: ApnsCredentials["privateKey"];
155}): string {
156 const keyFingerprint = NodeCrypto.createHash("sha256")
157 .update(Redacted.value(input.privateKey))
158 .digest("hex")
159 .slice(0, 16);
160 return `${input.teamId}:${input.keyId}:${keyFingerprint}`;
161}