diff --git a/CHANGELOG.md b/CHANGELOG.md index fa3b800..d42ff84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,53 @@ All notable changes to `@mmmike/web-push` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`SubscribeResult.replacedEndpoint`.** When `subscribe` rotates a stale-key + subscription, the old endpoint is now returned so the server can delete its record. + Before, it was discarded, and the record lingered until a send happened to report it + gone, which never happens while the push service answers the new key with 401/403. + The property is absent when nothing was replaced, or when the push service reissued + the same endpoint (deleting it would remove the record just saved). + +### Fixed + +- **`subscribe` no longer discards a working subscription when given a malformed VAPID + key.** An empty or wrong-length key (an unset env var, the private key by mistake) + never matched the existing subscription's key, so it was unsubscribed as a stale-key + rotation before `pushManager.subscribe` rejected the key. The key is now validated + first and throws `VAPID public key must be a 65-byte uncompressed P-256 point` + before the permission prompt. +- **`getCurrentSubscription`, `unsubscribe` and `unsubscribeFromPush` resolve when no + service worker is registered** (`null`, `null`, `true`) instead of waiting on + `navigator.serviceWorker.ready` forever. +- **A VAPID public key or subscription `p256dh` key that is not base64url** (an env + var with its quotes left on, say) now throws the same descriptive error as a + wrong-length key, instead of `atob`'s `InvalidCharacterError`. +- **Send options that could only fail on the wire are now rejected up front.** + `timeoutMs` must be a whole number of milliseconds up to 2^31 - 1: Node fired a + larger one after 1ms and threw per send on a fraction. `ttl` must be a whole number + of seconds, 0 or more, instead of reaching the `TTL` header as `NaN` or `-1` and + drawing a 400 per subscription. A NaN `vapidExpiration` no longer signs a token + with `"exp": null` that every push service rejects. `sendPushBatch` throws once for + each of these, before any send, rather than filling `failed`. +- **`sendPushBatch` re-signs its per-origin VAPID token at half its lifetime.** One + token was cached for the whole batch, so a batch that outlasted `vapidExpiration` + sent expired tokens and filled `failed` with 401/403s. +- **A `null` row no longer rejects the whole `sendPushBatch`.** The error handler read + `.endpoint` off the row, threw, and rejected the batch while its workers kept + sending unobserved. The row now lands in `failed` with an empty `endpoint`. + +### Security + +- **Push service responses are read up to 4 KB, then the stream is cancelled.** + Endpoints come from subscribers, so a hostile one could stream an unbounded body that + was buffered whole on every send. `WebPushError.body` now holds at most the first 4 KB. +- **The per-message ECDH key pair is generated non-extractable.** Only its public + half is ever exported, and public keys stay exportable regardless. + ## [1.3.0] - 2026-08-14 ### Added diff --git a/README.md b/README.md index 661533c..93dc338 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Sending a push notification should not require Node, a compatibility shim, or th - **All four pieces.** VAPID key generation, a copy-paste service worker to display the notification, the client subscribe helpers, and the server send, one at a time or fanned out to a list. - **No Node built-ins, no polyfills.** If it has `fetch` and `crypto.subtle`, it works. No `node:crypto`, no `node:https`, no compat layer. Cloudflare Workers, Bun, Deno, Node, and the browser. - **Ratified specs.** RFC 8291 `aes128gcm` encryption and RFC 8292 VAPID, pinned byte-for-byte against RFC 8291's Appendix A test vector. -- **Small.** Minified and gzipped per subpath entry: 1.0 kB for the browser client, 2.9 kB for the server. The badge above measures the whole package, ~3.6 kB. +- **Small.** Minified and gzipped per subpath entry: 1.1 kB for the browser client, 3.1 kB for the server. The badge above measures the whole package, ~3.9 kB. ## Installation @@ -67,20 +67,38 @@ This is the receiving end of `PushPayload`: `title` and `body` become the notifi ### 3. Client: subscribe ```typescript -import { subscribe, sendSubscriptionToServer } from "@mmmike/web-push/client"; +import { + subscribe, + sendSubscriptionToServer, + removeSubscriptionFromServer, +} from "@mmmike/web-push/client"; await navigator.serviceWorker.register("/sw.js"); const result = await subscribe(vapidPublicKey); if (result.status === "subscribed") { await sendSubscriptionToServer(result.subscription, "/api/push/subscribe"); + if (result.replacedEndpoint) { + await removeSubscriptionFromServer(result.replacedEndpoint, "/api/push/subscribe"); + } } ``` -`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. +`subscribe` resolves to `{ status: "unsupported" }` when the browser can't do push, `{ status: "denied" }` when the user declines the permission prompt, and `{ status: "subscribed", subscription, isNew, replacedEndpoint? }` otherwise. `isNew` is `true` when this call created the subscription: a first subscribe, or a VAPID key rotation replacing the stale one. `replacedEndpoint` is present only after a rotation that issued a new endpoint, and names the stale subscription your server should delete. POST the subscription on every visit rather than gating on `isNew`. Your endpoint has to upsert by endpoint URL anyway, since the browser hands back the same subscription on every call, and gating means one failed upload strands a subscription your server never hears about. Use `isNew` for what it does tell you: counting fresh subscribes and key rotations. +To turn push off, `unsubscribe` resolves to the endpoint your server keyed the record by, or `null` when there was nothing to unsubscribe: + +```typescript +import { unsubscribe, removeSubscriptionFromServer } from "@mmmike/web-push/client"; + +const endpoint = await unsubscribe(); +if (endpoint) { + await removeSubscriptionFromServer(endpoint, "/api/push/subscribe"); +} +``` + Requirements: - a secure context (HTTPS or `localhost`) @@ -89,7 +107,7 @@ Requirements: Encrypted-payload push works in Chrome, Edge, Firefox, Opera and Samsung Internet, and in Safari 16+ on macOS 13+ and iOS 16.4+. `isPushSupported()` is the runtime check. -Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look. +Rotating VAPID keys is handled for you: when the existing subscription was created with a different key, `subscribe` unsubscribes it and creates a fresh one (`isNew: true`), since the push service would reject the old one anyway. The old endpoint comes back as `replacedEndpoint`; delete its record rather than waiting for a send to report it gone, because a push service that still holds the old subscription answers sends signed with the new key with 401/403, not 404/410. The one gap is a browser that doesn't expose which key a subscription was bound to (`subscription.options.applicationServerKey` is null): with nothing to compare against, the existing subscription is kept. If it was in fact bound to a retired key, every send to it fails with a 401/403 `WebPushError`, and nothing in that error names the rotation as the cause; if you rotate keys and then see those, this is where to look. ### 4. Server: send @@ -236,7 +254,7 @@ Every type ships with per-field documentation, so your editor is the reference. | Type | Purpose | Exported from | | ---------------------- | ------------------------------------------------------------------------------------- | ------------------------ | | `PushSubscriptionData` | A subscription in transit, endpoint plus the `p256dh`/`auth` keys | root, `/send`, `/client` | -| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (with `isNew`), `unsupported`, or `denied` | root, `/client` | +| `SubscribeResult` | Outcome of `subscribe`: `subscribed` (`isNew`, `replacedEndpoint`), or why not | root, `/client` | | `PushPayload` | Notification contents: title (the only required field), body, click URL, grouping tag | root, `/send` | | `RawPushPayload` | A caller-serialized payload from `rawPayload`, accepted wherever `PushPayload` is | root, `/send` | | `VapidConfig` | Your VAPID key pair and contact subject | root, `/send` | diff --git a/src/client.ts b/src/client.ts index 58a15dd..593e675 100644 --- a/src/client.ts +++ b/src/client.ts @@ -4,7 +4,7 @@ */ import type { PushSubscriptionData } from "./types"; -import { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./vapid"; +import { decodeVapidPublicKey, uint8ArrayToUrlBase64 } from "./encoding"; export type { PushSubscriptionData }; @@ -34,6 +34,8 @@ export type SubscribeResult = subscription: PushSubscription; /** Whether this call created the subscription: first subscribe, or key rotation */ isNew: boolean; + /** Endpoint a key rotation unsubscribed, when it differs from the new one; delete its record */ + replacedEndpoint?: string; } | { status: "unsupported" } | { status: "denied" }; @@ -54,12 +56,17 @@ const isBoundToKey = (subscription: PushSubscription, key: Uint8Array): boolean * * Reuses an existing subscription when it was created with the same VAPID key. * If the key has changed, the stale subscription is unsubscribed and replaced, - * since the push service would reject it anyway. `isNew` reports whether this + * since the push service would reject it anyway, and its endpoint comes back as + * `replacedEndpoint` so the server can delete that record. `isNew` reports whether this * call created the subscription (first subscribe or key rotation). Send the * subscription to your server either way: gating the upload on `isNew` means * one failed request strands a subscription your server never hears about. * * @param vapidPublicKey - The server's VAPID public key (URL-safe base64) + * @throws {Error} if `vapidPublicKey` is not a 65-byte uncompressed P-256 point; + * checked before the permission prompt and before any existing subscription is touched. + * @throws {DOMException} if the browser or push service refuses the subscription, or + * the stale-key subscription can't be unsubscribed during a rotation. * @example * ```ts * const result = await subscribe(vapidPublicKey); @@ -73,14 +80,16 @@ export const subscribe = async (vapidPublicKey: string): Promise => { if (!isPushSupported()) { return null; } - const registration = await navigator.serviceWorker.ready; - return registration.pushManager.getSubscription(); + // Not `ready`: it never settles without a registered worker, and no + // registration means no subscription. + const registration = await navigator.serviceWorker.getRegistration(); + return registration ? registration.pushManager.getSubscription() : null; }; /** @@ -125,6 +143,9 @@ export const getCurrentSubscription = async (): Promise * @deprecated Use {@link unsubscribe} instead: it returns the * unsubscribed endpoint, which the server needs to prune its matching * subscription record — this boolean drops it. + * @throws {DOMException} if the browser refuses the subscription lookup, or + * rejects the unsubscribe itself (e.g. the push service is unreachable). In + * that second case `unsubscribe` resolves the endpoint instead. */ export const unsubscribeFromPush = async (): Promise => { const subscription = await getCurrentSubscription(); @@ -140,6 +161,9 @@ export const unsubscribeFromPush = async (): Promise => { * returned even if the browser reports the unsubscribe as failed or rejects * (e.g. the push service is unreachable): the caller's intent is to stop * receiving pushes, and removing the server record achieves that regardless. + * + * @throws {DOMException} if the browser refuses the subscription lookup, before + * anything is unsubscribed. */ export const unsubscribe = async (): Promise => { const subscription = await getCurrentSubscription(); @@ -148,11 +172,7 @@ export const unsubscribe = async (): Promise => { } const { endpoint } = subscription; - try { - await subscription.unsubscribe(); - } catch { - // The endpoint is the contract; a failed deregistration must not hide it. - } + await subscription.unsubscribe().catch(() => false); return endpoint; }; @@ -188,6 +208,9 @@ const sendJson = async (endpoint: string, method: string, body: unknown): Promis /** * Send a push subscription to a server endpoint. + * + * @throws {Error} if the subscription is missing its `p256dh` or `auth` key. + * @throws {TypeError} if the request never reaches the server. */ export const sendSubscriptionToServer = async ( subscription: PushSubscription, @@ -196,6 +219,8 @@ export const sendSubscriptionToServer = async ( /** * Remove a push subscription from a server endpoint. + * + * @throws {TypeError} if the request never reaches the server. */ export const removeSubscriptionFromServer = ( subscriptionEndpoint: string, diff --git a/src/encoding.ts b/src/encoding.ts new file mode 100644 index 0000000..93ec494 --- /dev/null +++ b/src/encoding.ts @@ -0,0 +1,78 @@ +/** + * Not a package entry point: `./vapid` re-exports the base64url codec, and the + * key decoders stay internal. + */ + +const P256_PUBLIC_KEY_LENGTH = 65; +const UNCOMPRESSED_POINT_TAG = 0x04; + +/** + * Convert a URL-safe base64 string to a Uint8Array. + * + * @throws {DOMException} `InvalidCharacterError` if the input is not base64url. + */ +export const urlBase64ToUint8Array = (base64String: string): Uint8Array => { + if (!base64String) { + return new Uint8Array(0); + } + + const padding = "=".repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); + + const rawData = atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +}; + +/** + * Convert a Uint8Array to a URL-safe base64 string (no padding). + */ +export const uint8ArrayToUrlBase64 = (array: Uint8Array): string => { + // Build the binary string in chunks — String.fromCharCode(...array) spreads + // every byte as a call argument and overflows the stack for large inputs. + let binary = ""; + const chunkSize = 0x8000; + for (let i = 0; i < array.length; i += chunkSize) { + binary += String.fromCharCode(...array.subarray(i, i + chunkSize)); + } + const base64 = btoa(binary); + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +}; + +const decodeOrUndefined = (base64: string): Uint8Array | undefined => { + try { + return urlBase64ToUint8Array(base64); + } catch { + return undefined; + } +}; + +/** + * Decode a URL-safe base64 uncompressed P-256 public key: 65 bytes, `0x04` first. + * + * @param errorMessage Thrown for anything else, including input that is not base64url. + * @throws {Error} with `errorMessage` if the input does not decode to such a key. + */ +export const decodeP256PublicKey = ( + base64: string, + errorMessage: string, +): Uint8Array => { + const key = decodeOrUndefined(base64); + if (key?.length !== P256_PUBLIC_KEY_LENGTH || key[0] !== UNCOMPRESSED_POINT_TAG) { + throw new Error(errorMessage); + } + return key; +}; + +/** + * Decode an application server's VAPID public key. + * + * @throws {Error} if it is not a base64url 65-byte uncompressed P-256 point. + */ +export const decodeVapidPublicKey = (publicKey: string): Uint8Array => + decodeP256PublicKey(publicKey, "VAPID public key must be a 65-byte uncompressed P-256 point"); diff --git a/src/encrypt.ts b/src/encrypt.ts index 6be41e7..e69c6f4 100644 --- a/src/encrypt.ts +++ b/src/encrypt.ts @@ -8,7 +8,7 @@ * directly — never by consumers of the published package. */ -import { urlBase64ToUint8Array } from "./vapid"; +import { decodeP256PublicKey, urlBase64ToUint8Array } from "./encoding"; const SALT_LENGTH = 16; const RECORD_SIZE_LENGTH = 4; @@ -16,7 +16,6 @@ const ID_LENGTH_LENGTH = 1; const KEY_ID_LENGTH = 65; const GCM_TAG_LENGTH = 16; const PADDING_DELIMITER = 0x02; -const UNCOMPRESSED_POINT_TAG = 0x04; /** aes128gcm content-coding header length, 86 octets (RFC 8188 §2.1). */ const HEADER_LENGTH = SALT_LENGTH + RECORD_SIZE_LENGTH + ID_LENGTH_LENGTH + KEY_ID_LENGTH; @@ -37,8 +36,9 @@ const MAX_PAYLOAD_BYTES = RECORD_SIZE - HEADER_LENGTH - 1 - GCM_TAG_LENGTH; const encoder = new TextEncoder(); /** - * Split from {@link validatePushInputs} so a batch send can reject an - * oversized payload once, up front, rather than once per subscription. + * Kept apart from {@link decodeSubscriptionKeys} so a batch send rejects an + * oversized payload once, up front, rather than once per subscription. The + * encryption core trusts its caller to have run it. * * @throws {Error} if the payload exceeds the single-record limit. */ @@ -62,31 +62,26 @@ const concat = (...parts: Uint8Array[]): Uint8Array => { return out; }; +/** A subscription's `p256dh` key and auth secret, decoded and checked. */ +export type SubscriptionKeyBytes = { + clientPublicKeyBytes: Uint8Array; + authSecretBytes: Uint8Array; +}; + /** - * Validate the payload size and the subscription's public key and auth secret, - * returning the decoded key bytes. Exported so the send path can reject invalid - * input *before* paying for the ECDSA VAPID signature (see `send.ts`). The - * encryption core re-checks as a self-contained guard for direct callers. + * Decode and check a subscription's public key and auth secret, once per send, + * before the send path pays for the ECDSA VAPID signature (see `send.ts`). * - * @throws {Error} if the payload exceeds the single-record limit, or either - * subscription key is malformed. + * @throws {Error} if either subscription key is malformed. */ -export const validatePushInputs = ( - payload: Uint8Array, +export const decodeSubscriptionKeys = ( p256dhKey: string, authSecret: string, -): { clientPublicKeyBytes: Uint8Array; authSecretBytes: Uint8Array } => { - assertPayloadWithinLimit(payload); - - const clientPublicKeyBytes = urlBase64ToUint8Array(p256dhKey); - if ( - clientPublicKeyBytes.length !== KEY_ID_LENGTH || - clientPublicKeyBytes[0] !== UNCOMPRESSED_POINT_TAG - ) { - throw new Error( - "Invalid subscription p256dh key: expected a 65-byte uncompressed P-256 public key", - ); - } +): SubscriptionKeyBytes => { + const clientPublicKeyBytes = decodeP256PublicKey( + p256dhKey, + "Invalid subscription p256dh key: expected a 65-byte uncompressed P-256 public key", + ); const authSecretBytes = urlBase64ToUint8Array(authSecret); if (authSecretBytes.length < 16) { @@ -190,21 +185,20 @@ const contentCodingHeader = ( * Encrypt a payload as a single aes128gcm record (RFC 8291 / RFC 8188), * generating a fresh ephemeral key pair and salt for this message. * - * @throws {Error} if the payload is too large or the subscription keys are - * malformed (see {@link validatePushInputs}). + * Expects a payload already held to {@link assertPayloadWithinLimit}. */ export const encryptPayload = async ( payload: Uint8Array, - p256dhKey: string, - authSecret: string, + keys: SubscriptionKeyBytes, ): Promise> => { + // Public keys are always exportable; `false` locks only the private half. const serverKeyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, - true, + false, ["deriveBits"], ); const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH)); - return encryptRecord(payload, p256dhKey, authSecret, serverKeyPair, salt); + return encryptRecord(payload, keys, serverKeyPair, salt); }; /** @@ -218,17 +212,10 @@ export const encryptPayload = async ( */ export const encryptRecord = async ( payload: Uint8Array, - p256dhKey: string, - authSecret: string, + { clientPublicKeyBytes, authSecretBytes }: SubscriptionKeyBytes, serverKeyPair: CryptoKeyPair, salt: Uint8Array, ): Promise> => { - const { clientPublicKeyBytes, authSecretBytes } = validatePushInputs( - payload, - p256dhKey, - authSecret, - ); - const clientPublicKey = await importClientPublicKey(clientPublicKeyBytes); const sharedSecret = await deriveSharedSecret(serverKeyPair.privateKey, clientPublicKey); const serverPublicKey = await exportRawPublicKey(serverKeyPair.publicKey); diff --git a/src/send.ts b/src/send.ts index 20b75ac..a588408 100644 --- a/src/send.ts +++ b/src/send.ts @@ -3,7 +3,7 @@ * body (RFC 8291), plus the status handling that decides delivered / gone / error. */ -import { assertPayloadWithinLimit, encryptPayload, validatePushInputs } from "./encrypt"; +import { assertPayloadWithinLimit, decodeSubscriptionKeys, encryptPayload } from "./encrypt"; import type { PushPayload, PushSubscriptionData, @@ -44,6 +44,7 @@ const parseRetryAfterMs = (retryAfter: string | null): number | null => { */ export class WebPushError extends Error { readonly statusCode: number; + /** The push service's response body, cut off after its first 4 KB */ readonly body: string; /** * The full subscription endpoint, a capability URL: anyone holding it can @@ -168,12 +169,33 @@ const isSubscriptionGone = (status: number): boolean => status === 404 || status const DEFAULT_TIMEOUT_MS = 30000; +// setTimeout's ceiling, which AbortSignal.timeout shares: Node silently fires +// anything larger after 1ms, and throws on fractions. +const MAX_TIMEOUT_MS = 2 ** 31 - 1; + const assertValidTimeout = (timeoutMs: number): void => { - if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { - throw new Error("timeoutMs must be a positive number of milliseconds"); + if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) { + throw new Error(`timeoutMs must be a whole number of milliseconds from 1 to ${MAX_TIMEOUT_MS}`); + } +}; + +/** RFC 8030 §5.2: `TTL` is delta-seconds, a non-negative integer. */ +const assertValidTtl = (ttl: number | undefined): void => { + if (ttl !== undefined && (!Number.isSafeInteger(ttl) || ttl < 0)) { + throw new Error("ttl must be a whole number of seconds, 0 or more"); } }; +const assertValidSendOptions = ({ + topic, + ttl, + timeoutMs = DEFAULT_TIMEOUT_MS, +}: SendPushOptions): void => { + assertValidTopic(topic); + assertValidTtl(ttl); + assertValidTimeout(timeoutMs); +}; + const requestSignal = (signal: AbortSignal | undefined, timeoutMs: number): AbortSignal => signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) @@ -203,32 +225,56 @@ const buildPushHeaders = ({ return headers; }; +/** + * Push services answer in a few hundred bytes. The endpoint is chosen by the + * subscriber, so without a cap a hostile one could stream an unbounded body + * into memory on every send. + */ +const MAX_RESPONSE_BODY_BYTES = 4096; + +const readBodyPrefix = async (response: Response): Promise => { + if (!response.body) { + return ""; + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let received = 0; + while (received < MAX_RESPONSE_BODY_BYTES) { + // oxlint-disable-next-line no-await-in-loop -- a stream yields its chunks in order + const { done, value } = await reader.read(); + if (done) { + return text + decoder.decode(); + } + text += decoder.decode(value.subarray(0, MAX_RESPONSE_BODY_BYTES - received), { + stream: true, + }); + received += value.length; + } + await reader.cancel(); + return text + decoder.decode(); +}; + const postToPushService = async ( - subscription: PushSubscriptionData, - payloadBytes: Uint8Array, + endpoint: string, + encryptedPayload: Uint8Array, jwt: string, vapidPublicKey: string, options: SendPushOptions, ): Promise => { const { logger, ttl = 86400, urgency, topic, signal, timeoutMs = DEFAULT_TIMEOUT_MS } = options; - const encryptedPayload = await encryptPayload( - payloadBytes, - subscription.keys.p256dh, - subscription.keys.auth, - ); - - const response = await fetch(subscription.endpoint, { + const response = await fetch(endpoint, { method: "POST", headers: buildPushHeaders({ jwt, vapidPublicKey, ttl, urgency, topic }), body: encryptedPayload, signal: requestSignal(signal, timeoutMs), }); - const responseText = await response.text(); + const responseText = await readBodyPrefix(response); logger?.debug?.("Push response", { - endpoint: subscription.endpoint.slice(0, 50), + endpoint: endpoint.slice(0, 50), status: response.status, statusText: response.statusText, body: responseText.slice(0, 200), @@ -248,7 +294,7 @@ const postToPushService = async ( : `Push service error: ${response.status} ${response.statusText}`, response.status, responseText, - subscription.endpoint, + endpoint, response.headers.get("retry-after"), ); }; @@ -263,7 +309,7 @@ const postToPushService = async ( * wrapper is encrypted and sent verbatim * @returns true if successful, false if subscription is invalid (should be deleted) * @throws {WebPushError} on rate limits (429) and other push service errors - * @throws {Error} on invalid input: VAPID config, payload size, `topic`, + * @throws {Error} on invalid input: VAPID config, payload size, `topic`, `ttl`, * `timeoutMs`, or a non-`https:` endpoint * @example * ```ts @@ -277,13 +323,13 @@ export const sendPushNotification = async ( vapid: VapidConfig, options: SendPushOptions = {}, ): Promise => { - assertValidTopic(options.topic); - assertValidTimeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS); + assertValidSendOptions(options); // Reject bad input *before* signing the VAPID JWT — no point paying for an // ECDSA signature on a request we'll refuse. const payloadBytes = encodePayload(payload); - validatePushInputs(payloadBytes, subscription.keys.p256dh, subscription.keys.auth); + assertPayloadWithinLimit(payloadBytes); + const keys = decodeSubscriptionKeys(subscription.keys.p256dh, subscription.keys.auth); // JWT expiry is independent of the message TTL: push services reject tokens // valid for more than 24h, whereas TTL (message retention) is routinely longer. @@ -295,7 +341,8 @@ export const sendPushNotification = async ( expiration: options.vapidExpiration ?? 43200, }); - return postToPushService(subscription, payloadBytes, jwt, vapid.publicKey, options); + const encryptedPayload = await encryptPayload(payloadBytes, keys); + return postToPushService(subscription.endpoint, encryptedPayload, jwt, vapid.publicKey, options); }; /** @@ -305,14 +352,15 @@ export const sendPushNotification = async ( * sends the push service accepted, endpoints it reported gone (404/410) come * back in `gone` for deletion, and every other failed send comes back in * `failed` with its error. One VAPID JWT is signed per push-service origin - * rather than per message, since the token is scoped to the origin and valid - * for the whole batch. Aborting `options.signal` stops workers from starting - * new sends; subscriptions never attempted count toward none of the three. + * rather than per message, since the token is scoped to the origin, and + * re-signed at half its lifetime, so a batch that outlasts `vapidExpiration` + * never sends an expired one. Aborting `options.signal` stops workers from + * starting new sends; subscriptions never attempted count toward none of the three. * * @param payload A `PushPayload` is JSON-serialized; a {@link rawPayload} * wrapper is encrypted and sent verbatim * @throws {Error} on caller input — invalid VAPID config, oversized payload, - * invalid `topic`, `concurrency`, or `timeoutMs` — before anything is sent. + * invalid `topic`, `ttl`, `concurrency`, or `timeoutMs` — before anything is sent. * A non-`https:` endpoint is per-subscription data and lands in `failed`. * @example * ```ts @@ -331,19 +379,19 @@ export const sendPushBatch = async ( if (!Number.isInteger(concurrency) || concurrency < 1) { throw new Error("concurrency must be a positive integer"); } - assertValidTopic(sendOptions.topic); - assertValidTimeout(sendOptions.timeoutMs ?? DEFAULT_TIMEOUT_MS); + assertValidSendOptions(sendOptions); const payloadBytes = encodePayload(payload); assertPayloadWithinLimit(payloadBytes); + const vapidExpiration = sendOptions.vapidExpiration ?? 43200; const vapidJwtFor = (audience: string): Promise => createVapidJwt({ audience, subject: vapid.subject, publicKey: vapid.publicKey, privateKey: vapid.privateKey, - expiration: sendOptions.vapidExpiration ?? 43200, + expiration: vapidExpiration, }); // Sign a throwaway token so a bad VAPID config (subject, expiration, key @@ -352,14 +400,14 @@ export const sendPushBatch = async ( // Caching the promise, not the token, so concurrent workers hitting the // same origin share one signature instead of racing to sign their own. - const jwtByOrigin = new Map>(); + const jwtByOrigin = new Map; refreshAt: number }>(); const cachedJwtFor = (origin: string): Promise => { const cached = jwtByOrigin.get(origin); - if (cached) { - return cached; + if (cached && Date.now() < cached.refreshAt) { + return cached.jwt; } const jwt = vapidJwtFor(origin); - jwtByOrigin.set(origin, jwt); + jwtByOrigin.set(origin, { jwt, refreshAt: Date.now() + (vapidExpiration * 1000) / 2 }); return jwt; }; @@ -369,11 +417,11 @@ export const sendPushBatch = async ( const sendOne = async (subscription: PushSubscriptionData): Promise => { try { - validatePushInputs(payloadBytes, subscription.keys.p256dh, subscription.keys.auth); + const keys = decodeSubscriptionKeys(subscription.keys.p256dh, subscription.keys.auth); const jwt = await cachedJwtFor(pushServiceOrigin(subscription.endpoint)); const accepted = await postToPushService( - subscription, - payloadBytes, + subscription.endpoint, + await encryptPayload(payloadBytes, keys), jwt, vapid.publicKey, sendOptions, @@ -384,7 +432,9 @@ export const sendPushBatch = async ( gone.push(subscription.endpoint); } } catch (error) { - failed.push({ endpoint: subscription.endpoint, error }); + // Rows are often cast from a database or JSON; reading `.endpoint` off a + // null one here would reject the whole batch. + failed.push({ endpoint: subscription?.endpoint ?? "", error }); } }; diff --git a/src/types.ts b/src/types.ts index 858b4b0..953309a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,7 +82,7 @@ export type SendPushBatchResult = { */ export type SendPushOptions = { logger?: Logger; - /** TTL in seconds (default: 86400 = 24 hours) */ + /** TTL in whole seconds, 0 or more (default: 86400 = 24 hours) */ ttl?: number; /** * VAPID JWT expiration in seconds from now (default: 43200 = 12 hours). @@ -101,9 +101,9 @@ export type SendPushOptions = { /** Aborts in-flight sends; a batch also stops starting new ones. */ signal?: AbortSignal; /** - * Per-request timeout in milliseconds (default: 30000). Without one, a push - * service that accepts the connection and never responds holds its pool - * slot for as long as the platform allows. + * Per-request timeout in whole milliseconds, at most 2^31 - 1 (default: + * 30000). Without one, a push service that accepts the connection and never + * responds holds its pool slot for as long as the platform allows. */ timeoutMs?: number; }; diff --git a/src/vapid.ts b/src/vapid.ts index 6d7ac8f..c82d664 100644 --- a/src/vapid.ts +++ b/src/vapid.ts @@ -4,46 +4,15 @@ * service, so it will accept pushes for its subscriptions. */ -/** - * Convert a URL-safe base64 string to a Uint8Array. - */ -export const urlBase64ToUint8Array = (base64String: string): Uint8Array => { - if (!base64String) { - return new Uint8Array(0); - } - - const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); - - const rawData = atob(base64); - const outputArray = new Uint8Array(rawData.length); - - for (let i = 0; i < rawData.length; ++i) { - outputArray[i] = rawData.charCodeAt(i); - } +import { decodeVapidPublicKey, uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./encoding"; - return outputArray; -}; - -/** - * Convert a Uint8Array to a URL-safe base64 string (no padding). - */ -export const uint8ArrayToUrlBase64 = (array: Uint8Array): string => { - // Build the binary string in chunks — String.fromCharCode(...array) spreads - // every byte as a call argument and overflows the stack for large inputs. - let binary = ""; - const chunkSize = 0x8000; - for (let i = 0; i < array.length; i += chunkSize) { - binary += String.fromCharCode(...array.subarray(i, i + chunkSize)); - } - const base64 = btoa(binary); - return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -}; +export { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "./encoding"; /** * Generate a new VAPID key pair using ECDSA P-256. * Returns keys as URL-safe base64 strings. * + * @throws {Error} if the runtime exports the private key without its scalar. * @example * ```ts * const { publicKey, privateKey } = await generateVapidKeys(); @@ -101,11 +70,8 @@ const encodeJwtSegment = (segment: object): string => * @throws {Error} if either key is the wrong length or encoding. */ const importSigningKey = (publicKey: string, privateKey: string): Promise => { - const publicKeyArray = urlBase64ToUint8Array(publicKey); + const publicKeyArray = decodeVapidPublicKey(publicKey); const privateKeyArray = urlBase64ToUint8Array(privateKey); - if (publicKeyArray.length !== 65 || publicKeyArray[0] !== 0x04) { - throw new Error("VAPID public key must be a 65-byte uncompressed P-256 point"); - } if (privateKeyArray.length !== 32) { throw new Error("VAPID private key must be a 32-byte P-256 scalar"); } @@ -125,14 +91,14 @@ const importSigningKey = (publicKey: string, privateKey: string): Promise => { const { audience, subject, publicKey, privateKey, expiration = 43200 } = options; // VAPID JWT lifetime is capped at 24h (RFC 8292 §2); services reject longer. - if (expiration <= 0 || expiration > 86400) { + if (Number.isNaN(expiration) || expiration <= 0 || expiration > 86400) { throw new Error("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); } // RFC 8292 §2.1: the subject must be a contact URI (mailto: or https:). diff --git a/test/client.test.ts b/test/client.test.ts index 879c05c..c78737f 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -42,6 +42,7 @@ type EnvOptions = { permission?: NotificationPermission; promptResult?: NotificationPermission; existing?: FakeSubscription | null; + registered?: boolean; }; function PushManager() {} @@ -54,6 +55,7 @@ const setupPushEnv = (opts: EnvOptions = {}) => { permission = "granted", promptResult = permission, existing = null, + registered = true, } = opts; const created = makeSubscription("https://push.example.com/sub/new"); @@ -97,8 +99,11 @@ const setupPushEnv = (opts: EnvOptions = {}) => { if (hasPushManager) { windowStub.PushManager = PushManager; } + // Unregistered mirrors the browser: `ready` never settles, `getRegistration` is undefined. if (serviceWorker) { - navigatorStub.serviceWorker = { ready: Promise.resolve(registration) }; + navigatorStub.serviceWorker = registered + ? { ready: Promise.resolve(registration), getRegistration: async () => registration } + : { ready: new Promise(() => {}), getRegistration: async () => undefined }; } vi.stubGlobal("window", windowStub); @@ -140,7 +145,11 @@ describe("feature detection & permission", () => { }); }); -const randomVapidKey = () => uint8ArrayToUrlBase64(crypto.getRandomValues(new Uint8Array(65))); +const randomVapidKey = () => { + const point = crypto.getRandomValues(new Uint8Array(65)); + point[0] = 0x04; // SEC1 uncompressed-point tag + return uint8ArrayToUrlBase64(point); +}; const subscriptionBoundTo = (vapidPublicKey: string) => makeSubscription( "https://push.example.com/existing", @@ -161,7 +170,7 @@ describe("subscribe", () => { it("reports denied when the permission prompt is declined", async () => { setupPushEnv({ permission: "default", promptResult: "denied" }); - expect(await subscribe("BPk")).toEqual({ status: "denied" }); + expect(await subscribe(randomVapidKey())).toEqual({ status: "denied" }); }); it("prompts for permission and subscribes once granted", async () => { @@ -169,7 +178,7 @@ describe("subscribe", () => { permission: "default", promptResult: "granted", }); - expect(await subscribe("BPk")).toEqual({ + expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, @@ -201,30 +210,64 @@ describe("subscribe", () => { expect(pushManager.subscribe).not.toHaveBeenCalled(); }); - it("rotates a subscription bound to a different VAPID key and flags it as new", async () => { + it("rotates a subscription bound to a different VAPID key, returning the replaced endpoint", async () => { const existing = subscriptionBoundTo(randomVapidKey()); const { pushManager, created } = setupPushEnv({ existing }); expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, + replacedEndpoint: existing.endpoint, }); expect(existing.unsubscribe).toHaveBeenCalledOnce(); expect(pushManager.subscribe).toHaveBeenCalledOnce(); }); + it("omits replacedEndpoint when a rotation reissues the same endpoint", async () => { + const existing = makeSubscription( + "https://push.example.com/sub/new", + undefined, + undefined, + urlBase64ToUint8Array(randomVapidKey()).buffer, + ); + const { created } = setupPushEnv({ existing }); + const result = await subscribe(randomVapidKey()); + + expect(result).toStrictEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).not.toHaveProperty("replacedEndpoint"); + }); + it("subscribes with the decoded applicationServerKey when none exists", async () => { const vapidPublicKey = randomVapidKey(); const { pushManager, created } = setupPushEnv({ existing: null }); const result = await subscribe(vapidPublicKey); - expect(result).toEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).toStrictEqual({ status: "subscribed", subscription: created, isNew: true }); + expect(result).not.toHaveProperty("replacedEndpoint"); expect(pushManager.subscribe).toHaveBeenCalledOnce(); expect(pushManager.subscribe).toHaveBeenCalledWith({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); }); + + it.each([ + { kind: "an empty", key: "" }, + { kind: "a 32-byte (private-key-sized)", key: uint8ArrayToUrlBase64(new Uint8Array(32)) }, + { kind: "a still-quoted (not base64url)", key: `"${randomVapidKey()}"` }, + ])( + "rejects $kind VAPID key before prompting, keeping the existing subscription", + async ({ key }) => { + const existing = subscriptionBoundTo(randomVapidKey()); + const { pushManager, NotificationStub } = setupPushEnv({ existing }); + await expect(subscribe(key)).rejects.toThrow( + "VAPID public key must be a 65-byte uncompressed P-256 point", + ); + expect(NotificationStub.requestPermission).not.toHaveBeenCalled(); + expect(existing.unsubscribe).not.toHaveBeenCalled(); + expect(pushManager.subscribe).not.toHaveBeenCalled(); + }, + ); }); describe("unsubscribe & getCurrentSubscription", () => { @@ -285,6 +328,16 @@ describe("unsubscribe & getCurrentSubscription", () => { expect(existing.unsubscribe).toHaveBeenCalledOnce(); }); + it("unsubscribe resolves null instead of hanging when no service worker is registered", async () => { + setupPushEnv({ registered: false }); + expect(await unsubscribe()).toBeNull(); + }); + + it("getCurrentSubscription resolves null when no service worker is registered", async () => { + setupPushEnv({ registered: false }); + expect(await getCurrentSubscription()).toBeNull(); + }); + it("getCurrentSubscription returns null when unsupported", async () => { setupPushEnv({ serviceWorker: false }); expect(await getCurrentSubscription()).toBeNull(); @@ -299,7 +352,7 @@ describe("unsubscribe & getCurrentSubscription", () => { it("supports the full lifecycle: subscribe, read back, unsubscribe, gone", async () => { const { created } = setupPushEnv(); expect(await getCurrentSubscription()).toBeNull(); - expect(await subscribe("BPk")).toEqual({ + expect(await subscribe(randomVapidKey())).toEqual({ status: "subscribed", subscription: created, isNew: true, diff --git a/test/rfc8291-vector.test.ts b/test/rfc8291-vector.test.ts index 17bb969..7b23faa 100644 --- a/test/rfc8291-vector.test.ts +++ b/test/rfc8291-vector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { encryptRecord } from "../src/encrypt"; +import { decodeSubscriptionKeys, encryptRecord } from "../src/encrypt"; import { uint8ArrayToUrlBase64, urlBase64ToUint8Array } from "../src/vapid"; /** @@ -62,7 +62,8 @@ describe("RFC 8291 Appendix A vector", () => { const salt = urlBase64ToUint8Array(SALT); const payload = new TextEncoder().encode(PLAINTEXT); - const body = await encryptRecord(payload, UA_PUBLIC, AUTH_SECRET, keyPair, salt); + const keys = decodeSubscriptionKeys(UA_PUBLIC, AUTH_SECRET); + const body = await encryptRecord(payload, keys, keyPair, salt); expect(uint8ArrayToUrlBase64(body)).toBe(EXPECTED_BODY); }); diff --git a/test/send-push-batch.test.ts b/test/send-push-batch.test.ts index 8873abb..7d72b77 100644 --- a/test/send-push-batch.test.ts +++ b/test/send-push-batch.test.ts @@ -36,6 +36,7 @@ const authHeaderOf = (call: [string | URL | Request, RequestInit?]): string => afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); }); describe("sendPushBatch — result partitioning", () => { @@ -139,6 +140,22 @@ describe("sendPushBatch — result partitioning", () => { expect(mock.mock.calls[0][0]).toBe("https://push.example/ok"); }); + it("puts a null row in failed instead of rejecting the whole batch", async () => { + stubFetch(201); + const rows: PushSubscriptionData[] = JSON.parse( + JSON.stringify([ + subscription("https://push.example/a"), + null, + subscription("https://push.example/b"), + ]), + ); + + const result = await sendPushBatch(rows, payload, vapid); + + expect(result.delivered).toBe(2); + expect(result.failed).toEqual([{ endpoint: "", error: expect.any(TypeError) }]); + }); + it("delivers a rawPayload-wrapped payload", async () => { const mock = stubFetch(201); const { delivered } = await sendPushBatch( @@ -204,6 +221,35 @@ describe("sendPushBatch — VAPID JWT reuse", () => { ); expect(new Set(mock.mock.calls.map(authHeaderOf)).size).toBe(2); }); + + it("never sends an expired JWT, even when the batch outlasts vapidExpiration", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const sends: { sentAt: number; exp: number }[] = []; + const mock = vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + async (_input, init) => { + const jwt = ((init as RequestInit).headers as Record).Authorization.slice( + "vapid t=".length, + ).split(", k=")[0]; + const { exp } = decodeJwtSegment(jwt.split(".")[1]) as { exp: number }; + sends.push({ sentAt: Date.now(), exp: exp * 1000 }); + vi.setSystemTime(Date.now() + 31_000); + return new Response(null, { status: 201 }); + }, + ); + vi.stubGlobal("fetch", mock); + + await sendPushBatch( + [1, 2, 3].map((i) => subscription(`https://push.example/sub-${i}`)), + payload, + vapid, + { vapidExpiration: 60, concurrency: 1 }, + ); + + expect(sends).toHaveLength(3); + for (const { sentAt, exp } of sends) { + expect(exp).toBeGreaterThan(sentAt); + } + }); }); describe("sendPushBatch — concurrency", () => { @@ -317,11 +363,30 @@ describe("sendPushBatch — caller input rejects up front", () => { expect(mock).not.toHaveBeenCalled(); }); - it("throws on a non-positive timeoutMs before any send", async () => { + it("throws on a timeoutMs AbortSignal.timeout would mishandle before any send", async () => { + const mock = stubFetch(201); + await expect( + sendPushBatch([subscription("https://push.example/a")], payload, vapid, { timeoutMs: 3e9 }), + ).rejects.toThrow("timeoutMs must be a whole number of milliseconds from 1 to 2147483647"); + expect(mock).not.toHaveBeenCalled(); + }); + + it("throws on an invalid ttl once, not once per subscription", async () => { + const mock = stubFetch(201); + const subs = [1, 2, 3].map((i) => subscription(`https://push.example/sub-${i}`)); + await expect(sendPushBatch(subs, payload, vapid, { ttl: -1 })).rejects.toThrow( + "ttl must be a whole number of seconds, 0 or more", + ); + expect(mock).not.toHaveBeenCalled(); + }); + + it("throws on a NaN vapidExpiration once, instead of signing exp: null", async () => { const mock = stubFetch(201); await expect( - sendPushBatch([subscription("https://push.example/a")], payload, vapid, { timeoutMs: 0 }), - ).rejects.toThrow("timeoutMs must be a positive number of milliseconds"); + sendPushBatch([subscription("https://push.example/a")], payload, vapid, { + vapidExpiration: Number.NaN, + }), + ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); expect(mock).not.toHaveBeenCalled(); }); }); diff --git a/test/send.test.ts b/test/send.test.ts index c968517..b4a2443 100644 --- a/test/send.test.ts +++ b/test/send.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test"; -import { encryptPayload } from "../src/encrypt"; +import { decodeSubscriptionKeys, encryptPayload } from "../src/encrypt"; import { rawPayload, sendPushNotification, topicFromString, WebPushError } from "../src/send"; import type { PushSubscriptionData, VapidConfig } from "../src/types"; import { generateVapidKeys, uint8ArrayToUrlBase64 } from "../src/vapid"; @@ -125,6 +125,27 @@ describe("sendPushNotification — status handling", () => { expect(err.message).toMatch(/rate limit/i); }); + it("keeps only the first 4 KB of an endless error body, then cancels the stream", async () => { + let cancelled = false; + const endless = new ReadableStream({ + pull: (controller) => controller.enqueue(new Uint8Array(1000).fill(0x78)), + cancel: () => { + cancelled = true; + }, + }); + vi.stubGlobal( + "fetch", + vi.fn<(input: string | URL | Request, init?: RequestInit) => Promise>( + async () => new Response(endless, { status: 500 }), + ), + ); + + const err = await sendPushNotification(subscription(), payload, vapid).catch((e) => e); + expect(err).toBeInstanceOf(WebPushError); + expect(err.body).toBe("x".repeat(4096)); + expect(cancelled).toBe(true); + }); + it("throws WebPushError with the status on other server errors", async () => { stubFetch(500, "Internal Server Error"); const err = await sendPushNotification(subscription(), payload, vapid).catch((e) => e); @@ -195,12 +216,21 @@ describe("sendPushNotification — abort & timeout", () => { ).rejects.toThrow(/aborted/i); }); - it("rejects a non-positive timeoutMs before sending", async () => { + it.each([0, -1, 1500.5, 3e9, Number.NaN])( + "rejects timeoutMs %s before sending, since AbortSignal.timeout takes whole ms up to 2^31 - 1", + async (timeoutMs) => { + const mock = stubFetch(201); + await expect( + sendPushNotification(subscription(), payload, vapid, { timeoutMs }), + ).rejects.toThrow("timeoutMs must be a whole number of milliseconds from 1 to 2147483647"); + expect(mock).not.toHaveBeenCalled(); + }, + ); + + it("accepts the largest timeoutMs AbortSignal.timeout honours", async () => { const mock = stubFetch(201); - await expect( - sendPushNotification(subscription(), payload, vapid, { timeoutMs: 0 }), - ).rejects.toThrow("timeoutMs must be a positive number of milliseconds"); - expect(mock).not.toHaveBeenCalled(); + await sendPushNotification(subscription(), payload, vapid, { timeoutMs: 2 ** 31 - 1 }); + expect((mock.mock.calls[0][1] as RequestInit).signal?.aborted).toBe(false); }); }); @@ -217,6 +247,17 @@ describe("sendPushNotification — options", () => { expect(headersOf(mock).TTL).toBe("0"); }); + it.each([-1, 1.5, Number.NaN, 1e21])( + "rejects ttl %s before sending: TTL is delta-seconds (RFC 8030 §5.2)", + async (ttl) => { + const mock = stubFetch(201); + await expect(sendPushNotification(subscription(), payload, vapid, { ttl })).rejects.toThrow( + "ttl must be a whole number of seconds, 0 or more", + ); + expect(mock).not.toHaveBeenCalled(); + }, + ); + it("logs the response via logger.debug", async () => { stubFetch(201); const debug = vi.fn<(message: string, data?: Record) => void>(); @@ -458,7 +499,10 @@ describe("encryptPayload — round-trip across payload sizes", () => { // random binary bytes rule out any ASCII/JSON assumption in the record path. it.each([0, 1, 1337, 3992, 3993])("round-trips a %i-byte random payload", async (size) => { const plaintext = crypto.getRandomValues(new Uint8Array(size)); - const body = await encryptPayload(plaintext, client.p256dh, client.auth); + const body = await encryptPayload( + plaintext, + decodeSubscriptionKeys(client.p256dh, client.auth), + ); expect(await decryptAes128gcm(body, client)).toEqual(plaintext); }); }); diff --git a/test/vapid.test.ts b/test/vapid.test.ts index 923d975..475a1e6 100644 --- a/test/vapid.test.ts +++ b/test/vapid.test.ts @@ -204,6 +204,19 @@ describe("createVapidJwt", () => { ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); }); + it("rejects a NaN expiration, as Number() of an unset env var gives", async () => { + const { publicKey, privateKey } = await generateVapidKeys(); + await expect( + createVapidJwt({ + audience: "https://example.com", + subject: "mailto:a@b.com", + publicKey, + privateKey, + expiration: Number.NaN, + }), + ).rejects.toThrow("VAPID JWT expiration must be between 1 and 86400 seconds (24 hours)"); + }); + it("accepts an expiration of exactly 24 hours (86400s)", async () => { const { publicKey, privateKey } = await generateVapidKeys(); const jwt = await createVapidJwt({ @@ -251,6 +264,18 @@ describe("createVapidJwt", () => { ).rejects.toThrow("VAPID public key must be a 65-byte uncompressed P-256 point"); }); + it("rejects a public key that is not base64url with the same message, not atob's", async () => { + const { publicKey, privateKey } = await generateVapidKeys(); + await expect( + createVapidJwt({ + audience: "https://example.com", + subject: "mailto:a@b.com", + publicKey: `"${publicKey}"`, + privateKey, + }), + ).rejects.toThrow("VAPID public key must be a 65-byte uncompressed P-256 point"); + }); + it("rejects a 65-byte public key without the 0x04 uncompressed prefix", async () => { const { privateKey } = await generateVapidKeys(); const compressedPrefix = new Uint8Array(65);