@ultimat3/pwa 25.2.0 → 26.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +16 -5
- package/README.md +96 -3
- package/package.json +9 -3
- package/src/client.ts +37 -0
- package/src/errors.ts +156 -1
- package/src/index.ts +64 -20
- package/src/install.ts +2 -1
- package/src/push-actions.ts +104 -0
- package/src/push-bytes.ts +39 -0
- package/src/push-client.ts +182 -0
- package/src/push-decrypt.ts +92 -0
- package/src/push-encrypt.ts +161 -0
- package/src/push-hosts.ts +34 -0
- package/src/push-runtime.ts +87 -0
- package/src/push-schema.ts +27 -0
- package/src/push-send.ts +191 -0
- package/src/push-store-pg.ts +86 -0
- package/src/push-store.ts +54 -0
- package/src/skew.ts +39 -0
- package/src/vapid-keys.ts +72 -0
- package/src/vapid.ts +167 -0
- package/src/version-skew.ts +2 -32
- package/src/web-push.ts +124 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Single responsibility: the subscription surface, as two FACTORIES OVER `action` — never a ninth
|
|
2
|
+
// primitive and never a hand-mounted route. What they return is an action an app exports from its
|
|
3
|
+
// own actions module, so each one has its route, OpenAPI operation, typed client, contract tests
|
|
4
|
+
// and policy like any other write:
|
|
5
|
+
//
|
|
6
|
+
// // apps/web/app/push/actions.ts
|
|
7
|
+
// export const subscribePush = pushSubscribe({ permission: 'push:subscribe' });
|
|
8
|
+
// export const unsubscribePush = pushUnsubscribe({ permission: 'push:subscribe' });
|
|
9
|
+
//
|
|
10
|
+
// The subscription is stored against `ctx.actor.id`, the person the request ran as — never an id
|
|
11
|
+
// the body names, so a caller can subscribe only themselves and unsubscribe only their own device.
|
|
12
|
+
|
|
13
|
+
import type { Action } from '@ultimat3/action';
|
|
14
|
+
import { action } from '@ultimat3/action';
|
|
15
|
+
import type { KnownPermission, PolicyArgs, PolicyDecision } from '@ultimat3/policy';
|
|
16
|
+
import { can, denied } from '@ultimat3/policy';
|
|
17
|
+
import { t } from '@ultimat3/schema';
|
|
18
|
+
import { subscriptionKeyBytes } from './push-encrypt';
|
|
19
|
+
import { webPushRuntime } from './push-runtime';
|
|
20
|
+
import { assertPushEndpoint } from './push-send';
|
|
21
|
+
|
|
22
|
+
/** `PushSubscription.toJSON()`, plus the two facts a server-rendered notification needs. */
|
|
23
|
+
const subscribeInput = () =>
|
|
24
|
+
t.object({
|
|
25
|
+
endpoint: t.string.min(1).max(2048),
|
|
26
|
+
expirationTime: t.nullable(t.number),
|
|
27
|
+
keys: t.object({ p256dh: t.string.max(128), auth: t.string.max(64) }),
|
|
28
|
+
/** BCP-47 — every notification to this device renders in it (`renderPushPayload`). */
|
|
29
|
+
locale: t.locale,
|
|
30
|
+
/** IANA — the zone a notification formatting a date would need. */
|
|
31
|
+
timeZone: t.timezone,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const subscribeOutput = () => t.object({ endpoint: t.string });
|
|
35
|
+
const unsubscribeInput = () => t.object({ endpoint: t.string.min(1).max(2048) });
|
|
36
|
+
const unsubscribeOutput = () => t.object({ removed: t.boolean });
|
|
37
|
+
|
|
38
|
+
type SubscribeInput = ReturnType<typeof subscribeInput>;
|
|
39
|
+
type SubscribeOutput = ReturnType<typeof subscribeOutput>;
|
|
40
|
+
type UnsubscribeInput = ReturnType<typeof unsubscribeInput>;
|
|
41
|
+
type UnsubscribeOutput = ReturnType<typeof unsubscribeOutput>;
|
|
42
|
+
|
|
43
|
+
export interface PushSubscriptionActionOptions {
|
|
44
|
+
/** What the subscribing actor must hold — read by `can()`, like every action's permission. */
|
|
45
|
+
readonly permission: KnownPermission;
|
|
46
|
+
/**
|
|
47
|
+
* The app's further rule, over the actor. Absent, any actor holding the permission subscribes
|
|
48
|
+
* themselves. An AGENT never does, whatever this says: a notification is for a person's device.
|
|
49
|
+
*/
|
|
50
|
+
readonly check?: ((args: PolicyArgs<unknown, null>) => boolean | PolicyDecision) | undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const policyOf = (options: PushSubscriptionActionOptions) =>
|
|
54
|
+
can<unknown, null>(options.permission, (args) => {
|
|
55
|
+
if (args.actor?.kind === 'agent') return denied('push.agent-subscriber');
|
|
56
|
+
return options.check === undefined ? true : options.check(args);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/** Store this browser's subscription for the person signed in. Idempotent per endpoint. */
|
|
60
|
+
export function pushSubscribe(
|
|
61
|
+
options: PushSubscriptionActionOptions,
|
|
62
|
+
): Action<SubscribeInput, SubscribeOutput> {
|
|
63
|
+
return action({
|
|
64
|
+
input: subscribeInput(),
|
|
65
|
+
output: subscribeOutput(),
|
|
66
|
+
policy: policyOf(options),
|
|
67
|
+
idempotent: true,
|
|
68
|
+
async handle({ input, ctx }) {
|
|
69
|
+
const runtime = webPushRuntime('pushSubscribe');
|
|
70
|
+
// Refused before it is stored: an endpoint is where this server will POST, and only a push
|
|
71
|
+
// service the app sends to is ever one (`push-hosts.ts`).
|
|
72
|
+
assertPushEndpoint(input.endpoint, runtime.pushHosts);
|
|
73
|
+
// Refused HERE rather than at the first send: a key no message can be encrypted for is a
|
|
74
|
+
// subscription that fails every notification, silently, on a device that thinks it is on.
|
|
75
|
+
subscriptionKeyBytes(input.keys);
|
|
76
|
+
await runtime.store.save({
|
|
77
|
+
endpoint: input.endpoint,
|
|
78
|
+
keys: { p256dh: input.keys.p256dh, auth: input.keys.auth },
|
|
79
|
+
locale: input.locale,
|
|
80
|
+
timeZone: input.timeZone,
|
|
81
|
+
actorId: ctx.actor.id,
|
|
82
|
+
createdAt: ctx.now().getTime(),
|
|
83
|
+
expirationTime: input.expirationTime,
|
|
84
|
+
});
|
|
85
|
+
return { endpoint: input.endpoint };
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Forget this browser's subscription — the caller's own only; another person's is absent. */
|
|
91
|
+
export function pushUnsubscribe(
|
|
92
|
+
options: PushSubscriptionActionOptions,
|
|
93
|
+
): Action<UnsubscribeInput, UnsubscribeOutput> {
|
|
94
|
+
return action({
|
|
95
|
+
input: unsubscribeInput(),
|
|
96
|
+
output: unsubscribeOutput(),
|
|
97
|
+
policy: policyOf(options),
|
|
98
|
+
idempotent: true,
|
|
99
|
+
async handle({ input, ctx }) {
|
|
100
|
+
const runtime = webPushRuntime('pushUnsubscribe');
|
|
101
|
+
return { removed: await runtime.store.remove(input.endpoint, ctx.actor.id) };
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Single responsibility: the byte vocabulary Web Push is spelled in — base64url without padding
|
|
2
|
+
// (every key, salt and JWT segment on the wire) and concatenation. `btoa`/`atob`, standard
|
|
3
|
+
// globals, never `node:buffer`; `Uint8Array.fromBase64` is in Bun but not in the TypeScript lib.
|
|
4
|
+
|
|
5
|
+
/** Unpadded base64url, the alphabet RFC 8291 and RFC 8292 print every value in. */
|
|
6
|
+
export function encodeBase64Url(bytes: Uint8Array): string {
|
|
7
|
+
let binary = '';
|
|
8
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
9
|
+
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `undefined` for text that is not base64url, rather than a throw: every caller is holding a value
|
|
14
|
+
* someone else wrote (a browser's subscription, an environment variable) and refuses it under its
|
|
15
|
+
* own code, naming which value it was.
|
|
16
|
+
*/
|
|
17
|
+
export function tryDecodeBase64Url(text: string): Uint8Array<ArrayBuffer> | undefined {
|
|
18
|
+
if (!/^[A-Za-z0-9_-]*={0,2}$/.test(text)) return undefined;
|
|
19
|
+
const standard = text.replace(/=+$/, '').replaceAll('-', '+').replaceAll('_', '/');
|
|
20
|
+
if (standard.length % 4 === 1) return undefined;
|
|
21
|
+
try {
|
|
22
|
+
const binary = atob(standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), '='));
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
25
|
+
return bytes;
|
|
26
|
+
} catch {
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array<ArrayBuffer> {
|
|
32
|
+
const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0));
|
|
33
|
+
let at = 0;
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
out.set(part, at);
|
|
36
|
+
at += part.byteLength;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Single responsibility: the BROWSER half of Web Push — ask for permission, subscribe with the
|
|
2
|
+
// server's VAPID public key, hand the subscription to the app's `pushSubscribe()` action, and undo
|
|
3
|
+
// it. Re-exported by `@ultimat3/pwa/client` (`client.ts`); this file imports NOTHING, so an island
|
|
4
|
+
// that offers a "notify me" button pays for these lines and nothing of the server package.
|
|
5
|
+
//
|
|
6
|
+
// import { subscribeToPush } from '@ultimat3/pwa/client';
|
|
7
|
+
// import { client } from '../../shared/browser-client';
|
|
8
|
+
// onClick={() => subscribeToPush({ save: client.subscribePush })}
|
|
9
|
+
//
|
|
10
|
+
// The key is read from `<meta name="x-push-key">`, which the web role writes into every document
|
|
11
|
+
// once push is configured — so no app passes, bundles or fetches it, and a key rotation reaches
|
|
12
|
+
// every page on its next render.
|
|
13
|
+
|
|
14
|
+
/** The `<meta name>` the web role emits; `@ultimat3/cli` writes it, this reads it. */
|
|
15
|
+
export const PUSH_KEY_META = 'x-push-key';
|
|
16
|
+
|
|
17
|
+
export type PushPermission = 'granted' | 'denied' | 'prompt' | 'unsupported';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* What `subscribeToPush` answers. Every branch is an outcome the app renders, not an exception:
|
|
21
|
+
* `denied` is a person's decision, `unsupported` a browser's (iOS before it is installed to the
|
|
22
|
+
* home screen), `unconfigured` a page served with no push key.
|
|
23
|
+
*/
|
|
24
|
+
export type PushSubscribeOutcome =
|
|
25
|
+
| { readonly status: 'subscribed'; readonly endpoint: string }
|
|
26
|
+
| { readonly status: 'denied' }
|
|
27
|
+
| { readonly status: 'unsupported' }
|
|
28
|
+
| { readonly status: 'unconfigured' };
|
|
29
|
+
|
|
30
|
+
/** What the `pushSubscribe()` action takes — `PushSubscription.toJSON()` plus locale and zone. */
|
|
31
|
+
export interface PushSubscriptionInput {
|
|
32
|
+
readonly endpoint: string;
|
|
33
|
+
readonly expirationTime: number | null;
|
|
34
|
+
readonly keys: { readonly p256dh: string; readonly auth: string };
|
|
35
|
+
readonly locale: string;
|
|
36
|
+
readonly timeZone: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The browser surface this module touches — injectable, so a test runs it without a browser. */
|
|
40
|
+
export interface PushClientHost {
|
|
41
|
+
readonly serviceWorker: { readonly ready: Promise<PushRegistrationLike> } | undefined;
|
|
42
|
+
readonly notification:
|
|
43
|
+
| {
|
|
44
|
+
readonly permission: 'default' | 'denied' | 'granted';
|
|
45
|
+
requestPermission(): Promise<'default' | 'denied' | 'granted'>;
|
|
46
|
+
}
|
|
47
|
+
| undefined;
|
|
48
|
+
/** `<meta name="x-push-key">`'s content, or `null`. */
|
|
49
|
+
readonly pushKey: string | null;
|
|
50
|
+
/** `<html lang>` — the locale this page was rendered in, so the one a notification renders in. */
|
|
51
|
+
readonly locale: string;
|
|
52
|
+
/** The device's IANA zone. */
|
|
53
|
+
readonly timeZone: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface PushRegistrationLike {
|
|
57
|
+
readonly pushManager: {
|
|
58
|
+
getSubscription(): Promise<PushSubscriptionLike | null>;
|
|
59
|
+
subscribe(options: {
|
|
60
|
+
userVisibleOnly: boolean;
|
|
61
|
+
applicationServerKey: Uint8Array<ArrayBuffer>;
|
|
62
|
+
}): Promise<PushSubscriptionLike>;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface PushSubscriptionLike {
|
|
67
|
+
readonly endpoint: string;
|
|
68
|
+
/** The key it was made with — a browser always says; absent here means "cannot tell". */
|
|
69
|
+
readonly options?: { readonly applicationServerKey?: ArrayBuffer | null } | undefined;
|
|
70
|
+
toJSON(): {
|
|
71
|
+
endpoint?: string;
|
|
72
|
+
expirationTime?: number | null;
|
|
73
|
+
keys?: Record<string, string>;
|
|
74
|
+
};
|
|
75
|
+
unsubscribe(): Promise<boolean>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The real browser, read at call time — never at import, which may run during a server render. */
|
|
79
|
+
export function browserPushHost(): PushClientHost {
|
|
80
|
+
const nav = globalThis.navigator as Navigator | undefined;
|
|
81
|
+
const supported =
|
|
82
|
+
nav !== undefined && 'serviceWorker' in nav && typeof globalThis.PushManager === 'function';
|
|
83
|
+
// Read only where push can happen at all: a runtime with no service worker answers
|
|
84
|
+
// `unsupported`, never a throw from a document that is not a browser's.
|
|
85
|
+
const meta = supported
|
|
86
|
+
? globalThis.document?.querySelector(`meta[name="${PUSH_KEY_META}"]`)
|
|
87
|
+
: undefined;
|
|
88
|
+
return {
|
|
89
|
+
serviceWorker: supported
|
|
90
|
+
? (nav.serviceWorker as unknown as PushClientHost['serviceWorker'])
|
|
91
|
+
: undefined,
|
|
92
|
+
notification:
|
|
93
|
+
typeof globalThis.Notification === 'function'
|
|
94
|
+
? (globalThis.Notification as unknown as PushClientHost['notification'])
|
|
95
|
+
: undefined,
|
|
96
|
+
pushKey: meta?.getAttribute('content') ?? null,
|
|
97
|
+
locale: globalThis.document?.documentElement?.lang || 'en',
|
|
98
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Where this browser stands, without asking anything. */
|
|
103
|
+
export function pushPermission(host: PushClientHost = browserPushHost()): PushPermission {
|
|
104
|
+
if (host.serviceWorker === undefined || host.notification === undefined) return 'unsupported';
|
|
105
|
+
const state = host.notification.permission;
|
|
106
|
+
return state === 'default' ? 'prompt' : state;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function keyBytes(text: string): Uint8Array<ArrayBuffer> {
|
|
110
|
+
const standard = text.replaceAll('-', '+').replaceAll('_', '/');
|
|
111
|
+
const binary = atob(standard.padEnd(standard.length + ((4 - (standard.length % 4)) % 4), '='));
|
|
112
|
+
const bytes = new Uint8Array(binary.length);
|
|
113
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
114
|
+
return bytes;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Whether `subscription` was made with `key`; one that does not say is taken at its word. */
|
|
118
|
+
function madeWith(subscription: PushSubscriptionLike, key: Uint8Array): boolean {
|
|
119
|
+
const held = subscription.options?.applicationServerKey;
|
|
120
|
+
if (held === undefined || held === null) return true;
|
|
121
|
+
const bytes = new Uint8Array(held);
|
|
122
|
+
return bytes.length === key.length && bytes.every((byte, i) => byte === key[i]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Ask, subscribe, save. Call it from a click: browsers refuse a permission prompt that no user
|
|
127
|
+
* gesture asked for. An existing subscription is saved again rather than replaced — it re-binds
|
|
128
|
+
* the device to whoever is signed in now, and keeps the locale current — UNLESS it was made with
|
|
129
|
+
* another server key (a VAPID rotation): no message signed with the new key can reach it, so it is
|
|
130
|
+
* unsubscribed and replaced, never re-saved as if it worked.
|
|
131
|
+
*/
|
|
132
|
+
export async function subscribeToPush(
|
|
133
|
+
options: { readonly save: (input: PushSubscriptionInput) => Promise<unknown> },
|
|
134
|
+
host: PushClientHost = browserPushHost(),
|
|
135
|
+
): Promise<PushSubscribeOutcome> {
|
|
136
|
+
if (host.serviceWorker === undefined || host.notification === undefined) {
|
|
137
|
+
return { status: 'unsupported' };
|
|
138
|
+
}
|
|
139
|
+
if (host.pushKey === null || host.pushKey === '') return { status: 'unconfigured' };
|
|
140
|
+
const permission =
|
|
141
|
+
host.notification.permission === 'default'
|
|
142
|
+
? await host.notification.requestPermission()
|
|
143
|
+
: host.notification.permission;
|
|
144
|
+
if (permission !== 'granted') return { status: 'denied' };
|
|
145
|
+
const registration = await host.serviceWorker.ready;
|
|
146
|
+
const key = keyBytes(host.pushKey);
|
|
147
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
148
|
+
if (subscription !== null && !madeWith(subscription, key)) {
|
|
149
|
+
await subscription.unsubscribe();
|
|
150
|
+
subscription = null;
|
|
151
|
+
}
|
|
152
|
+
subscription ??= await registration.pushManager.subscribe({
|
|
153
|
+
userVisibleOnly: true,
|
|
154
|
+
applicationServerKey: key,
|
|
155
|
+
});
|
|
156
|
+
const json = subscription.toJSON();
|
|
157
|
+
await options.save({
|
|
158
|
+
endpoint: subscription.endpoint,
|
|
159
|
+
expirationTime: json.expirationTime ?? null,
|
|
160
|
+
keys: { p256dh: json.keys?.['p256dh'] ?? '', auth: json.keys?.['auth'] ?? '' },
|
|
161
|
+
locale: host.locale,
|
|
162
|
+
timeZone: host.timeZone,
|
|
163
|
+
});
|
|
164
|
+
return { status: 'subscribed', endpoint: subscription.endpoint };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Tell the server first, then the browser: the other order leaves a row the sender meets as a 410
|
|
169
|
+
* at best, and as notifications to a device the person turned off at worst, if the call fails.
|
|
170
|
+
* `false` when this browser held no subscription.
|
|
171
|
+
*/
|
|
172
|
+
export async function unsubscribeFromPush(
|
|
173
|
+
options: { readonly remove: (input: { readonly endpoint: string }) => Promise<unknown> },
|
|
174
|
+
host: PushClientHost = browserPushHost(),
|
|
175
|
+
): Promise<boolean> {
|
|
176
|
+
if (host.serviceWorker === undefined) return false;
|
|
177
|
+
const registration = await host.serviceWorker.ready;
|
|
178
|
+
const subscription = await registration.pushManager.getSubscription();
|
|
179
|
+
if (subscription === null) return false;
|
|
180
|
+
await options.remove({ endpoint: subscription.endpoint });
|
|
181
|
+
return subscription.unsubscribe();
|
|
182
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Single responsibility: RFC 8291 from the USER AGENT's side — open one `aes128gcm` push record
|
|
2
|
+
// with the subscriber's private key and auth secret. A browser does this itself; the framework's
|
|
3
|
+
// caller is `@ultimat3/testing`'s `push` fixture, which plays the browser so a test asserts the
|
|
4
|
+
// notification a device would show rather than the bytes a push service received.
|
|
5
|
+
|
|
6
|
+
import { PwaPushSubscriptionInvalidError } from './errors';
|
|
7
|
+
import { concatBytes } from './push-bytes';
|
|
8
|
+
|
|
9
|
+
/** A subscriber: its ECDH key pair (the private half never leaves it) and its auth secret. */
|
|
10
|
+
export interface PushReceiverKeys {
|
|
11
|
+
readonly privateKey: CryptoKey;
|
|
12
|
+
/** The 65-byte uncompressed point the subscription's `p256dh` carries. */
|
|
13
|
+
readonly publicKey: Uint8Array<ArrayBuffer>;
|
|
14
|
+
/** The 16 bytes the subscription's `auth` carries. */
|
|
15
|
+
readonly authSecret: Uint8Array<ArrayBuffer>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const text = new TextEncoder();
|
|
19
|
+
|
|
20
|
+
async function hkdf(
|
|
21
|
+
salt: Uint8Array<ArrayBuffer>,
|
|
22
|
+
ikm: Uint8Array<ArrayBuffer>,
|
|
23
|
+
info: Uint8Array<ArrayBuffer>,
|
|
24
|
+
bytes: number,
|
|
25
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
26
|
+
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
27
|
+
return new Uint8Array(
|
|
28
|
+
await crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, bytes * 8),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The plaintext of one record, delimiter stripped. A body that is not one well-formed record for
|
|
34
|
+
* these keys — truncated, another subscriber's, tampered — is refused by code, as a browser drops it.
|
|
35
|
+
*/
|
|
36
|
+
export async function decryptPushMessage(
|
|
37
|
+
body: Uint8Array<ArrayBuffer>,
|
|
38
|
+
receiver: PushReceiverKeys,
|
|
39
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
40
|
+
const idLength = body[20] ?? 0;
|
|
41
|
+
if (body.byteLength < 21 + idLength + 17 || idLength !== 65) {
|
|
42
|
+
throw new PwaPushSubscriptionInvalidError({
|
|
43
|
+
field: 'body',
|
|
44
|
+
reason: 'is not one aes128gcm record with a 65-byte sender key',
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const opened = await open(body, receiver, idLength);
|
|
48
|
+
if (opened === undefined) {
|
|
49
|
+
throw new PwaPushSubscriptionInvalidError({
|
|
50
|
+
field: 'body',
|
|
51
|
+
reason: 'does not open with these keys: another subscriber’s, truncated or tampered',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return opened;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The record's plaintext, or `undefined` for every way it fails to be one for these keys. */
|
|
58
|
+
async function open(
|
|
59
|
+
body: Uint8Array<ArrayBuffer>,
|
|
60
|
+
receiver: PushReceiverKeys,
|
|
61
|
+
idLength: number,
|
|
62
|
+
): Promise<Uint8Array<ArrayBuffer> | undefined> {
|
|
63
|
+
const salt = body.slice(0, 16);
|
|
64
|
+
const asPublic = body.slice(21, 21 + idLength);
|
|
65
|
+
let padded: Uint8Array<ArrayBuffer>;
|
|
66
|
+
try {
|
|
67
|
+
const asKey = await crypto.subtle.importKey(
|
|
68
|
+
'raw',
|
|
69
|
+
asPublic,
|
|
70
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
71
|
+
false,
|
|
72
|
+
[],
|
|
73
|
+
);
|
|
74
|
+
const ecdh = new Uint8Array(
|
|
75
|
+
await crypto.subtle.deriveBits({ name: 'ECDH', public: asKey }, receiver.privateKey, 256),
|
|
76
|
+
);
|
|
77
|
+
const keyInfo = concatBytes(text.encode('WebPush: info\0'), receiver.publicKey, asPublic);
|
|
78
|
+
const ikm = await hkdf(receiver.authSecret, ecdh, keyInfo, 32);
|
|
79
|
+
const cek = await hkdf(salt, ikm, text.encode('Content-Encoding: aes128gcm\0'), 16);
|
|
80
|
+
const nonce = await hkdf(salt, ikm, text.encode('Content-Encoding: nonce\0'), 12);
|
|
81
|
+
const key = await crypto.subtle.importKey('raw', cek, 'AES-GCM', false, ['decrypt']);
|
|
82
|
+
padded = new Uint8Array(
|
|
83
|
+
await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, body.slice(21 + idLength)),
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
// RFC 8188: strip zero padding back to the delimiter; 0x02 marks the last (only) record.
|
|
89
|
+
let end = padded.byteLength - 1;
|
|
90
|
+
while (end >= 0 && padded[end] === 0) end -= 1;
|
|
91
|
+
return padded[end] === 2 ? padded.slice(0, end) : undefined;
|
|
92
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Single responsibility: RFC 8291 — encrypt one push message for one subscription, as a single
|
|
2
|
+
// `aes128gcm` record (RFC 8188). WebCrypto only: ECDH P-256 for the shared secret, HKDF-SHA-256
|
|
3
|
+
// for the key schedule, AES-128-GCM for the record.
|
|
4
|
+
//
|
|
5
|
+
// RANDOMNESS IS THE POINT HERE, and it is injectable. Each message needs a fresh salt and a fresh
|
|
6
|
+
// ephemeral key pair — reusing either across messages to one subscription reuses a GCM nonce under
|
|
7
|
+
// one key. Production rolls both; a test hands in RFC 8291 Appendix A's, and gets its exact bytes.
|
|
8
|
+
|
|
9
|
+
import { PwaPushPayloadTooLargeError, PwaPushSubscriptionInvalidError } from './errors';
|
|
10
|
+
import { concatBytes, tryDecodeBase64Url } from './push-bytes';
|
|
11
|
+
|
|
12
|
+
/** The one record size this encoder writes: the whole message is one record (RFC 8291 §4). */
|
|
13
|
+
export const PUSH_RECORD_SIZE = 4096;
|
|
14
|
+
|
|
15
|
+
/** salt(16) + rs(4) + idlen(1) + keyid(65): the `aes128gcm` header with the sender's public key. */
|
|
16
|
+
const HEADER_BYTES = 86;
|
|
17
|
+
const TAG_BYTES = 16;
|
|
18
|
+
/** RFC 8188's last-record delimiter, appended to the plaintext before it is sealed. */
|
|
19
|
+
const LAST_RECORD = 0x02;
|
|
20
|
+
|
|
21
|
+
/** Plaintext bytes one record holds: 4096 − 86 − 16 − 1 = 3993, RFC 8291 §4's number. */
|
|
22
|
+
export const pushMessageCapacity = (): number => PUSH_RECORD_SIZE - HEADER_BYTES - TAG_BYTES - 1;
|
|
23
|
+
|
|
24
|
+
/** What a browser's `PushSubscription.toJSON().keys` carries. */
|
|
25
|
+
export interface PushEncryptionKeys {
|
|
26
|
+
/** The user agent's ECDH public key: 65-byte uncompressed P-256 point, base64url. */
|
|
27
|
+
readonly p256dh: string;
|
|
28
|
+
/** The 16-byte authentication secret, base64url. */
|
|
29
|
+
readonly auth: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PushEncryptOptions {
|
|
33
|
+
/** The application server's ONE-MESSAGE key pair (ECDH). Rolled per message when omitted. */
|
|
34
|
+
readonly ephemeral?: CryptoKeyPair | undefined;
|
|
35
|
+
/** 16 bytes. Rolled per message when omitted. */
|
|
36
|
+
readonly salt?: Uint8Array<ArrayBuffer> | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ContentKeys {
|
|
40
|
+
readonly ecdhSecret: Uint8Array<ArrayBuffer>;
|
|
41
|
+
readonly ikm: Uint8Array<ArrayBuffer>;
|
|
42
|
+
readonly cek: Uint8Array<ArrayBuffer>;
|
|
43
|
+
readonly nonce: Uint8Array<ArrayBuffer>;
|
|
44
|
+
/** The sender's public key as the header's `keyid` carries it. */
|
|
45
|
+
readonly asPublic: Uint8Array<ArrayBuffer>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const text = new TextEncoder();
|
|
49
|
+
|
|
50
|
+
/** HKDF-Extract then HKDF-Expand in one WebCrypto call — exactly RFC 5869's two steps. */
|
|
51
|
+
async function hkdf(
|
|
52
|
+
salt: Uint8Array<ArrayBuffer>,
|
|
53
|
+
ikm: Uint8Array<ArrayBuffer>,
|
|
54
|
+
info: Uint8Array<ArrayBuffer>,
|
|
55
|
+
bytes: number,
|
|
56
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
57
|
+
const key = await crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
58
|
+
const bits = await crypto.subtle.deriveBits(
|
|
59
|
+
{ name: 'HKDF', hash: 'SHA-256', salt, info },
|
|
60
|
+
key,
|
|
61
|
+
bytes * 8,
|
|
62
|
+
);
|
|
63
|
+
return new Uint8Array(bits);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** RFC 8291 §3.3–3.4 and RFC 8188 §2.2: the shared secret, then CEK and NONCE. */
|
|
67
|
+
export async function deriveContentKeys(input: {
|
|
68
|
+
readonly ephemeral: CryptoKeyPair;
|
|
69
|
+
readonly uaPublic: Uint8Array<ArrayBuffer>;
|
|
70
|
+
readonly authSecret: Uint8Array<ArrayBuffer>;
|
|
71
|
+
readonly salt: Uint8Array<ArrayBuffer>;
|
|
72
|
+
}): Promise<ContentKeys> {
|
|
73
|
+
const uaKey = await crypto.subtle.importKey(
|
|
74
|
+
'raw',
|
|
75
|
+
input.uaPublic,
|
|
76
|
+
{ name: 'ECDH', namedCurve: 'P-256' },
|
|
77
|
+
false,
|
|
78
|
+
[],
|
|
79
|
+
);
|
|
80
|
+
const ecdhSecret = new Uint8Array(
|
|
81
|
+
await crypto.subtle.deriveBits(
|
|
82
|
+
{ name: 'ECDH', public: uaKey },
|
|
83
|
+
input.ephemeral.privateKey,
|
|
84
|
+
256,
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
const asPublic = new Uint8Array(await crypto.subtle.exportKey('raw', input.ephemeral.publicKey));
|
|
88
|
+
const keyInfo = concatBytes(text.encode('WebPush: info\0'), input.uaPublic, asPublic);
|
|
89
|
+
const ikm = await hkdf(input.authSecret, ecdhSecret, keyInfo, 32);
|
|
90
|
+
const cek = await hkdf(input.salt, ikm, text.encode('Content-Encoding: aes128gcm\0'), 16);
|
|
91
|
+
const nonce = await hkdf(input.salt, ikm, text.encode('Content-Encoding: nonce\0'), 12);
|
|
92
|
+
return { ecdhSecret, ikm, cek, nonce, asPublic };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The subscription's two keys, decoded and size-checked, or the refusal naming which one. */
|
|
96
|
+
export function subscriptionKeyBytes(keys: PushEncryptionKeys): {
|
|
97
|
+
readonly uaPublic: Uint8Array<ArrayBuffer>;
|
|
98
|
+
readonly authSecret: Uint8Array<ArrayBuffer>;
|
|
99
|
+
} {
|
|
100
|
+
const uaPublic = tryDecodeBase64Url(keys.p256dh);
|
|
101
|
+
if (uaPublic === undefined || uaPublic.byteLength !== 65 || uaPublic[0] !== 4) {
|
|
102
|
+
throw new PwaPushSubscriptionInvalidError({
|
|
103
|
+
field: 'keys.p256dh',
|
|
104
|
+
reason: 'is not a 65-byte uncompressed P-256 point in base64url',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const authSecret = tryDecodeBase64Url(keys.auth);
|
|
108
|
+
if (authSecret === undefined || authSecret.byteLength !== 16) {
|
|
109
|
+
throw new PwaPushSubscriptionInvalidError({
|
|
110
|
+
field: 'keys.auth',
|
|
111
|
+
reason: 'is not a 16-byte secret in base64url',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return { uaPublic, authSecret };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The request body: `salt | rs | idlen | keyid | ciphertext`. One record, so the plaintext gets
|
|
119
|
+
* the LAST-record delimiter and no padding — padding hides a length, and every message here is a
|
|
120
|
+
* notification the device shows in full.
|
|
121
|
+
*/
|
|
122
|
+
export async function encryptPushMessage(
|
|
123
|
+
keys: PushEncryptionKeys,
|
|
124
|
+
plaintext: Uint8Array,
|
|
125
|
+
options: PushEncryptOptions = {},
|
|
126
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
127
|
+
const capacity = pushMessageCapacity();
|
|
128
|
+
if (plaintext.byteLength > capacity) {
|
|
129
|
+
throw new PwaPushPayloadTooLargeError({ bytes: plaintext.byteLength, limit: capacity });
|
|
130
|
+
}
|
|
131
|
+
const { uaPublic, authSecret } = subscriptionKeyBytes(keys);
|
|
132
|
+
// why: the salt and the one-message key ARE randomness by specification (RFC 8291 §3.3); the
|
|
133
|
+
// options above are the seam a test pins them through.
|
|
134
|
+
const salt = options.salt ?? crypto.getRandomValues(new Uint8Array(16));
|
|
135
|
+
const ephemeral =
|
|
136
|
+
options.ephemeral ??
|
|
137
|
+
(await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']));
|
|
138
|
+
let derived: ContentKeys;
|
|
139
|
+
try {
|
|
140
|
+
derived = await deriveContentKeys({ ephemeral, uaPublic, authSecret, salt });
|
|
141
|
+
} catch {
|
|
142
|
+
// A 65-byte string starting 0x04 that is not ON the curve: WebCrypto refuses the import.
|
|
143
|
+
throw new PwaPushSubscriptionInvalidError({
|
|
144
|
+
field: 'keys.p256dh',
|
|
145
|
+
reason: 'is not a point on the P-256 curve',
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const key = await crypto.subtle.importKey('raw', derived.cek, 'AES-GCM', false, ['encrypt']);
|
|
149
|
+
const sealed = new Uint8Array(
|
|
150
|
+
await crypto.subtle.encrypt(
|
|
151
|
+
{ name: 'AES-GCM', iv: derived.nonce },
|
|
152
|
+
key,
|
|
153
|
+
concatBytes(plaintext, Uint8Array.of(LAST_RECORD)),
|
|
154
|
+
),
|
|
155
|
+
);
|
|
156
|
+
const header = new Uint8Array(HEADER_BYTES - 65);
|
|
157
|
+
header.set(salt, 0);
|
|
158
|
+
new DataView(header.buffer).setUint32(16, PUSH_RECORD_SIZE, false);
|
|
159
|
+
header[20] = derived.asPublic.byteLength;
|
|
160
|
+
return concatBytes(header, derived.asPublic, sealed);
|
|
161
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single responsibility: which hosts the push sender may dial. An endpoint is a URL from a REQUEST
|
|
2
|
+
// BODY, and a hostname resolves wherever its owner points it — a private address, the cloud
|
|
3
|
+
// metadata service — so checking the name's shape (`https:`, not an IP literal) is not a screen.
|
|
4
|
+
// The one that holds is a list of the push services browsers actually hand out, checked when a
|
|
5
|
+
// subscription is stored AND before every send; an app adds its own with `pwa.vapid.pushHosts`.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Every push service a current browser subscribes with, `As of 2026-10`. Each matches itself and
|
|
9
|
+
* its subdomains, on a dot boundary — `fcm.googleapis.com.evil.com` and `evilpush.apple.com` match
|
|
10
|
+
* nothing.
|
|
11
|
+
*
|
|
12
|
+
* | Host | Browser | Endpoints look like |
|
|
13
|
+
* |---|---|---|
|
|
14
|
+
* | `fcm.googleapis.com` | Chrome, Edge on Android, every Chromium | `https://fcm.googleapis.com/fcm/send/…`, `…/wp/…` |
|
|
15
|
+
* | `push.services.mozilla.com` | Firefox | `https://updates.push.services.mozilla.com/wpush/v2/…` |
|
|
16
|
+
* | `push.apple.com` | Safari (macOS, iOS 16.4+) | `https://web.push.apple.com/…` |
|
|
17
|
+
* | `notify.windows.com` | Edge on Windows (WNS) | `https://wns2-<region>.notify.windows.com/w/?token=…` |
|
|
18
|
+
*/
|
|
19
|
+
export const PUSH_SERVICE_HOSTS: readonly string[] = Object.freeze([
|
|
20
|
+
'fcm.googleapis.com',
|
|
21
|
+
'push.services.mozilla.com',
|
|
22
|
+
'push.apple.com',
|
|
23
|
+
'notify.windows.com',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** `host` is `listed` itself or a subdomain of it — compared lower-case, on a dot boundary. */
|
|
27
|
+
const under = (host: string, listed: string): boolean =>
|
|
28
|
+
host === listed || host.endsWith(`.${listed}`);
|
|
29
|
+
|
|
30
|
+
/** Whether `host` is a built-in push service or one the app listed (`pwa.vapid.pushHosts`). */
|
|
31
|
+
export function pushHostAllowed(host: string, extra: readonly string[] = []): boolean {
|
|
32
|
+
const name = host.toLowerCase().replace(/\.$/, '');
|
|
33
|
+
return [...PUSH_SERVICE_HOSTS, ...extra].some((listed) => under(name, listed.toLowerCase()));
|
|
34
|
+
}
|