|
| 1 | +import { |
| 2 | + base64UrlDecode, |
| 3 | + base64UrlDecodeString, |
| 4 | + base64UrlEncode, |
| 5 | +} from './base64.js'; |
| 6 | +import { crypto } from './crypto.js'; |
| 7 | +import { deriveSharedSecret } from './shared-secret.js'; |
| 8 | +import type { PushSubscription, PushSubscriptionKey } from './types.js'; |
| 9 | +import { concatTypedArrays } from './utils.js'; |
| 10 | + |
| 11 | +/** |
| 12 | + * Imports and validates the client's public and authentication keys from a PushSubscriptionKey. |
| 13 | + * |
| 14 | + * @param {PushSubscriptionKey} keys - The keys associated with the push subscription. |
| 15 | + * @returns {Promise<{ auth: ArrayBuffer, p256: CryptoKey }>} A promise that resolves to an object containing the authentication key and the imported public key. |
| 16 | + * @throws {Error} Throws an error if the authentication key length is incorrect. |
| 17 | + */ |
| 18 | +const importClientKeys = async ( |
| 19 | + keys: PushSubscriptionKey, |
| 20 | +): Promise<{ auth: ArrayBuffer; p256: CryptoKey }> => { |
| 21 | + const auth = base64UrlDecode(keys.auth); |
| 22 | + if (auth.byteLength !== 16) { |
| 23 | + throw new Error( |
| 24 | + `Incorrect auth length, expected 16 bytes but got ${auth.byteLength}`, |
| 25 | + ); |
| 26 | + } |
| 27 | + |
| 28 | + let decodedKey: Uint8Array; |
| 29 | + const base64Key = base64UrlDecodeString(keys.p256dh); |
| 30 | + |
| 31 | + if (typeof globalThis !== 'undefined' && 'atob' in globalThis) { |
| 32 | + // Browser environment |
| 33 | + const binaryStr = globalThis.atob(base64Key); |
| 34 | + decodedKey = new Uint8Array(binaryStr.length); |
| 35 | + for (let i = 0; i < binaryStr.length; i++) { |
| 36 | + decodedKey[i] = binaryStr.charCodeAt(i); |
| 37 | + } |
| 38 | + } else { |
| 39 | + // Node.js environment |
| 40 | + decodedKey = new Uint8Array(Buffer.from(base64Key, 'base64')); |
| 41 | + } |
| 42 | + |
| 43 | + const p256 = await crypto.subtle.importKey( |
| 44 | + 'jwk', |
| 45 | + { |
| 46 | + kty: 'EC', |
| 47 | + crv: 'P-256', |
| 48 | + x: base64UrlEncode(decodedKey.slice(1, 33)), |
| 49 | + y: base64UrlEncode(decodedKey.slice(33, 65)), |
| 50 | + ext: true, |
| 51 | + }, |
| 52 | + { name: 'ECDH', namedCurve: 'P-256' }, |
| 53 | + true, |
| 54 | + [], |
| 55 | + ); |
| 56 | + |
| 57 | + return { auth, p256 }; |
| 58 | +}; |
| 59 | + |
| 60 | +/** |
| 61 | + * Derives a pseudo-random key using HKDF from the shared secret and authentication key. |
| 62 | + * |
| 63 | + * @param {ArrayBuffer} auth - The authentication key used as salt in the derivation process. |
| 64 | + * @param {CryptoKey} sharedSecret - The shared secret derived from the client's public key and local private key. |
| 65 | + * @returns {Promise<CryptoKey>} A promise that resolves to the derived pseudo-random key. |
| 66 | + */ |
| 67 | +const derivePseudoRandomKey = async ( |
| 68 | + auth: ArrayBuffer, |
| 69 | + sharedSecret: CryptoKey, |
| 70 | +): Promise<CryptoKey> => { |
| 71 | + const pseudoRandomKeyBytes = await crypto.subtle.deriveBits( |
| 72 | + { |
| 73 | + name: 'HKDF', |
| 74 | + hash: 'SHA-256', |
| 75 | + salt: auth, |
| 76 | + // Adding Content-Encoding data info here is required by the Web Push API |
| 77 | + info: new TextEncoder().encode('Content-Encoding: auth\0'), |
| 78 | + }, |
| 79 | + sharedSecret, |
| 80 | + 256, |
| 81 | + ); |
| 82 | + |
| 83 | + return crypto.subtle.importKey('raw', pseudoRandomKeyBytes, 'HKDF', false, [ |
| 84 | + 'deriveBits', |
| 85 | + ]); |
| 86 | +}; |
| 87 | + |
| 88 | +/** |
| 89 | + * Creates a context for the ECDH key exchange using the client's and local public keys. |
| 90 | + * |
| 91 | + * @param {CryptoKey} clientPublicKey - The client's public key. |
| 92 | + * @param {CryptoKey} localPublicKey - The local public key. |
| 93 | + * @returns {Promise<Uint8Array>} A promise that resolves to a concatenated context array. |
| 94 | + */ |
| 95 | +const createContext = async ( |
| 96 | + clientPublicKey: CryptoKey, |
| 97 | + localPublicKey: CryptoKey, |
| 98 | +): Promise<Uint8Array> => { |
| 99 | + const [clientKeyBytes, localKeyBytes] = await Promise.all([ |
| 100 | + crypto.subtle.exportKey('raw', clientPublicKey), |
| 101 | + crypto.subtle.exportKey('raw', localPublicKey), |
| 102 | + ]); |
| 103 | + |
| 104 | + return concatTypedArrays([ |
| 105 | + new TextEncoder().encode('P-256\0'), |
| 106 | + new Uint8Array([0, clientKeyBytes.byteLength]), |
| 107 | + new Uint8Array(clientKeyBytes), |
| 108 | + new Uint8Array([0, localKeyBytes.byteLength]), |
| 109 | + new Uint8Array(localKeyBytes), |
| 110 | + ]); |
| 111 | +}; |
| 112 | + |
| 113 | +/** |
| 114 | + * Derives a nonce for encryption using HKDF from the pseudo-random key, salt, and context. |
| 115 | + * |
| 116 | + * @param {CryptoKey} pseudoRandomKey - The pseudo-random key derived from the shared secret. |
| 117 | + * @param {Uint8Array} salt - The salt used in the derivation process. |
| 118 | + * @param {Uint8Array} context - The context for the nonce derivation. |
| 119 | + * @returns {Promise<ArrayBuffer>} A promise that resolves to the derived nonce. |
| 120 | + */ |
| 121 | +const deriveNonce = async ( |
| 122 | + pseudoRandomKey: CryptoKey, |
| 123 | + salt: Uint8Array, |
| 124 | + context: Uint8Array, |
| 125 | +): Promise<ArrayBuffer> => { |
| 126 | + const nonceInfo = concatTypedArrays([ |
| 127 | + new TextEncoder().encode('Content-Encoding: nonce\0'), |
| 128 | + context, |
| 129 | + ]); |
| 130 | + |
| 131 | + return crypto.subtle.deriveBits( |
| 132 | + { name: 'HKDF', hash: 'SHA-256', salt, info: nonceInfo }, |
| 133 | + pseudoRandomKey, |
| 134 | + 12 * 8, // 12 bytes for the nonce |
| 135 | + ); |
| 136 | +}; |
| 137 | + |
| 138 | +/** |
| 139 | + * Derives a content encryption key using HKDF from the pseudo-random key, salt, and context. |
| 140 | + * |
| 141 | + * @param {CryptoKey} pseudoRandomKey - The pseudo-random key derived from the shared secret. |
| 142 | + * @param {Uint8Array} salt - The salt used in the derivation process. |
| 143 | + * @param {Uint8Array} context - The context for the key derivation. |
| 144 | + * @returns {Promise<CryptoKey>} A promise that resolves to the derived content encryption key. |
| 145 | + */ |
| 146 | +const deriveContentEncryptionKey = async ( |
| 147 | + pseudoRandomKey: CryptoKey, |
| 148 | + salt: Uint8Array, |
| 149 | + context: Uint8Array, |
| 150 | +): Promise<CryptoKey> => { |
| 151 | + const info = concatTypedArrays([ |
| 152 | + new TextEncoder().encode('Content-Encoding: aesgcm\0'), |
| 153 | + context, |
| 154 | + ]); |
| 155 | + |
| 156 | + const bits = await crypto.subtle.deriveBits( |
| 157 | + { name: 'HKDF', hash: 'SHA-256', salt, info }, |
| 158 | + pseudoRandomKey, |
| 159 | + 16 * 8, // 16 bytes for the AES-GCM key |
| 160 | + ); |
| 161 | + |
| 162 | + return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt']); |
| 163 | +}; |
| 164 | + |
| 165 | +/** |
| 166 | + * Pads the payload to ensure it fits within the maximum allowed size for web push notifications. |
| 167 | + * |
| 168 | + * Web push payloads have an overall max size of 4KB (4096 bytes). With the |
| 169 | + * required overhead for encryption, the actual max payload size is 4078 bytes. |
| 170 | + * |
| 171 | + * @param {Uint8Array} payload - The original payload to be padded. |
| 172 | + * @returns {Uint8Array} The padded payload, including length information. |
| 173 | + */ |
| 174 | +const padPayload = (payload: Uint8Array): Uint8Array => { |
| 175 | + const MAX_PAYLOAD_SIZE = 4078; // Maximum payload size after encryption overhead |
| 176 | + |
| 177 | + let paddingSize = Math.round(Math.random() * 100); // Random padding size |
| 178 | + const payloadSizeWithPadding = payload.byteLength + 2 + paddingSize; |
| 179 | + |
| 180 | + if (payloadSizeWithPadding > MAX_PAYLOAD_SIZE) { |
| 181 | + // Adjust padding size if the total exceeds the maximum allowed size |
| 182 | + paddingSize -= payloadSizeWithPadding - MAX_PAYLOAD_SIZE; |
| 183 | + } |
| 184 | + |
| 185 | + const paddingArray = new ArrayBuffer(2 + paddingSize); |
| 186 | + new DataView(paddingArray).setUint16(0, paddingSize); // Store the length of the padding |
| 187 | + |
| 188 | + // Return the new payload with padding added |
| 189 | + return concatTypedArrays([new Uint8Array(paddingArray), payload]); |
| 190 | +}; |
| 191 | + |
| 192 | +/** |
| 193 | + * Encrypts the payload for a push notification using the provided keys and context. |
| 194 | + * |
| 195 | + * @param {CryptoKeyPair} localKeys - The local key pair used for encryption. |
| 196 | + * @param {Uint8Array} salt - The salt used in the encryption process. |
| 197 | + * @param {string} payload - The original payload to encrypt. |
| 198 | + * @param {PushSubscription} target - The target push subscription containing client keys. |
| 199 | + * @returns {Promise<ArrayBuffer>} A promise that resolves to the encrypted payload. |
| 200 | + */ |
| 201 | +export const encryptPayload = async ( |
| 202 | + localKeys: CryptoKeyPair, |
| 203 | + salt: Uint8Array, |
| 204 | + payload: string, |
| 205 | + target: PushSubscription, |
| 206 | +): Promise<ArrayBuffer> => { |
| 207 | + const clientKeys = await importClientKeys(target.keys); |
| 208 | + |
| 209 | + const sharedSecret = await deriveSharedSecret( |
| 210 | + clientKeys.p256, |
| 211 | + localKeys.privateKey, |
| 212 | + ); |
| 213 | + const pseudoRandomKey = await derivePseudoRandomKey( |
| 214 | + clientKeys.auth, |
| 215 | + sharedSecret, |
| 216 | + ); |
| 217 | + |
| 218 | + const context = await createContext(clientKeys.p256, localKeys.publicKey); |
| 219 | + const nonce = await deriveNonce(pseudoRandomKey, salt, context); |
| 220 | + const contentEncryptionKey = await deriveContentEncryptionKey( |
| 221 | + pseudoRandomKey, |
| 222 | + salt, |
| 223 | + context, |
| 224 | + ); |
| 225 | + |
| 226 | + const encodedPayload = new TextEncoder().encode(payload); |
| 227 | + const paddedPayload = padPayload(encodedPayload); |
| 228 | + |
| 229 | + return crypto.subtle.encrypt( |
| 230 | + { name: 'AES-GCM', iv: nonce }, |
| 231 | + contentEncryptionKey, |
| 232 | + paddedPayload, |
| 233 | + ); |
| 234 | +}; |
0 commit comments