Skip to content

Commit eee5454

Browse files
authored
feat: [WPN-11, WPN-12, WPN-13] Derive Encrypt payload methods using HKDF and ECDH (#24)
* feat: derive shared secret method * feat: [WPN-12] Derive encoding and decoding methods for base64 which supports Browser and Node * feat: [WPN-13] Derive methods to create JWT using ES256 * docs: updated the docs for jwt and formatting * feat: [WPN-11, WPN-12, WPN-13] Derive Encrypt payload methods using HKDF and ECDH
1 parent 502dc7b commit eee5454

6 files changed

Lines changed: 446 additions & 3 deletions

File tree

packages/builder/lib/base64.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { stringFromArrayBuffer } from './utils.js';
2+
3+
/**
4+
* Encodes a string, ArrayBuffer, or Uint8Array into a Base64 URL format.
5+
*
6+
* This function handles both browser and Node.js environments by using
7+
* the appropriate encoding method based on the environment.
8+
*
9+
* @param {string | ArrayBuffer | Uint8Array} input - The input to encode.
10+
* @returns {string} The Base64 URL encoded string.
11+
*/
12+
export const base64UrlEncode = (
13+
input: string | ArrayBuffer | Uint8Array,
14+
): string => {
15+
// Convert input to string if it's binary
16+
const text = typeof input === 'string' ? input : stringFromArrayBuffer(input);
17+
18+
// Use environment-specific encoding
19+
let base64: string;
20+
if (typeof globalThis !== 'undefined' && 'btoa' in globalThis) {
21+
// Browser environment
22+
base64 = globalThis.btoa(text);
23+
} else {
24+
// Node.js environment
25+
base64 = Buffer.from(text, 'binary').toString('base64');
26+
}
27+
28+
// Convert base64 to base64url format
29+
return base64.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
30+
};
31+
32+
/**
33+
* Decodes a Base64 URL encoded string back to its original format.
34+
*
35+
* This function handles both browser and Node.js environments by using
36+
* the appropriate decoding method based on the environment.
37+
*
38+
* @param {string | undefined} s - The Base64 URL encoded string to decode.
39+
* @returns {string} The decoded data as a string.
40+
* @throws {Error} Throws an error if the input string is invalid.
41+
*/
42+
export const base64UrlDecodeString = (s: string | undefined): string => {
43+
if (!s) throw new Error('Invalid input');
44+
return (
45+
s.replace(/-/g, '+').replace(/_/g, '/') +
46+
'='.repeat((4 - (s.length % 4)) % 4)
47+
);
48+
};
49+
50+
/**
51+
* Decodes a Base64 URL encoded string into an ArrayBuffer.
52+
*
53+
* This function handles both browser and Node.js environments by using
54+
* the appropriate decoding method based on the environment.
55+
*
56+
* @param {string} input - The Base64 URL encoded string to decode.
57+
* @returns {ArrayBuffer} The decoded data as an ArrayBuffer.
58+
*/
59+
export const base64UrlDecode = (input: string): ArrayBuffer => {
60+
// Convert from base64url to base64
61+
const base64 = base64UrlDecodeString(input);
62+
63+
// Decode based on environment
64+
if (typeof globalThis !== 'undefined' && 'atob' in globalThis) {
65+
// Browser environment
66+
const binaryString = globalThis.atob(base64);
67+
const bytes = new Uint8Array(binaryString.length);
68+
for (let i = 0; i < binaryString.length; i++) {
69+
bytes[i] = binaryString.charCodeAt(i);
70+
}
71+
return bytes.buffer;
72+
}
73+
// Node.js environment
74+
return Buffer.from(base64, 'base64').buffer;
75+
};

packages/builder/lib/jwt.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { base64UrlEncode } from './base64.js';
2+
import { crypto } from './crypto.js';
3+
import type { JwtData } from './types.js';
4+
5+
/**
6+
* Creates a JSON Web Token (JWT) using the ECDSA algorithm.
7+
*
8+
* This function takes a JSON Web Key (JWK) and JWT data, encodes them,
9+
* and signs the token using the specified algorithm and hash function.
10+
*
11+
* @param {JsonWebKey} jwk - The JSON Web Key used for signing the JWT.
12+
* @param {JwtData} jwtData - The data to be included in the JWT payload.
13+
* @returns {Promise<string>} A promise that resolves to the signed JWT as a string.
14+
*
15+
* @throws {Error} Throws an error if the key import or signing process fails.
16+
*/
17+
export const createJwt = async (
18+
jwk: JsonWebKey,
19+
jwtData: JwtData,
20+
): Promise<string> => {
21+
// JWT header information
22+
const jwtInfo = {
23+
typ: 'JWT', // Type of the token
24+
alg: 'ES256', // Algorithm used for signing
25+
};
26+
27+
// Encode the JWT header and payload
28+
const base64JwtInfo = base64UrlEncode(JSON.stringify(jwtInfo));
29+
const base64JwtData = base64UrlEncode(JSON.stringify(jwtData));
30+
const unsignedToken = `${base64JwtInfo}.${base64JwtData}`;
31+
32+
// Import the private key from the JWK
33+
const privateKey = await crypto.subtle.importKey(
34+
'jwk',
35+
jwk,
36+
{ name: 'ECDSA', namedCurve: 'P-256' },
37+
true,
38+
['sign'],
39+
);
40+
41+
// Sign the token using the ECDSA algorithm and SHA-256 hash
42+
const signature = await crypto.subtle
43+
.sign(
44+
{ name: 'ECDSA', hash: { name: 'SHA-256' } },
45+
privateKey,
46+
new TextEncoder().encode(unsignedToken),
47+
)
48+
.then((token) => base64UrlEncode(token));
49+
50+
// Return the complete JWT
51+
return `${base64JwtInfo}.${base64JwtData}.${signature}`;
52+
};

packages/builder/lib/payload.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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+
};

packages/builder/lib/request.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { crypto } from './crypto.js';
2+
import { encryptPayload } from './payload.js';
23
import type { BuilderOptions, PushOptions } from './types.js';
34

45
/**
@@ -8,7 +9,6 @@ import type { BuilderOptions, PushOptions } from './types.js';
89
* @param {JsonWebKey | string} options.privateJWK - The private JSON Web Key (JWK) used for signing.
910
* @param {PushMessage} options.message - The message to be sent in the push notification with user defined options.
1011
* @param {PushSubscription} options.subscription - The subscription details for the push notification.
11-
* @returns {Promise<Response>} A promise that resolves to the response of the HTTP request.
1212
*
1313
* @throws {Error} Throws an error if the privateJWK is invalid or if the request fails.
1414
*/
@@ -50,4 +50,10 @@ export async function buildPushHTTPRequest({
5050
true,
5151
['deriveBits'],
5252
);
53+
const body = await encryptPayload(
54+
localKeys,
55+
salt,
56+
options.payload,
57+
subscription,
58+
);
5359
}

0 commit comments

Comments
 (0)