PushCrypto.sys.mjs (25790B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 const lazy = {}; 6 7 ChromeUtils.defineLazyGetter(lazy, "gDOMBundle", () => 8 Services.strings.createBundle("chrome://global/locale/dom/dom.properties") 9 ); 10 11 // getCryptoParamsFromHeaders is exported for test purposes. 12 const UTF8 = new TextEncoder(); 13 14 const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" }; 15 const ECDSA_KEY = { name: "ECDSA", namedCurve: "P-256" }; 16 const HMAC_SHA256 = { name: "HMAC", hash: "SHA-256" }; 17 const NONCE_INFO = UTF8.encode("Content-Encoding: nonce"); 18 19 // A default keyid with a name that won't conflict with a real keyid. 20 const DEFAULT_KEYID = ""; 21 22 /** Localized error property names. */ 23 24 // `Encryption` header missing or malformed. 25 const BAD_ENCRYPTION_HEADER = "PushMessageBadEncryptionHeader"; 26 // `Crypto-Key` header missing. 27 const BAD_CRYPTO_KEY_HEADER = "PushMessageBadCryptoKeyHeader"; 28 // `Content-Encoding` header missing or contains unsupported encoding. 29 const BAD_ENCODING_HEADER = "PushMessageBadEncodingHeader"; 30 // `dh` parameter of `Crypto-Key` header missing or not base64url-encoded. 31 const BAD_DH_PARAM = "PushMessageBadSenderKey"; 32 // `salt` parameter of `Encryption` header missing or not base64url-encoded. 33 const BAD_SALT_PARAM = "PushMessageBadSalt"; 34 // `rs` parameter of `Encryption` header not a number or less than pad size. 35 const BAD_RS_PARAM = "PushMessageBadRecordSize"; 36 // Invalid or insufficient padding for encrypted chunk. 37 const BAD_PADDING = "PushMessageBadPaddingError"; 38 // Generic crypto error. 39 const BAD_CRYPTO = "PushMessageBadCryptoError"; 40 41 class CryptoError extends Error { 42 /** 43 * Creates an error object indicating an incoming push message could not be 44 * decrypted. 45 * 46 * @param {string} message A human-readable error message. This is only for 47 * internal module logging, and doesn't need to be localized. 48 * @param {string} property The localized property name from `dom.properties`. 49 * @param {String...} params Substitutions to insert into the localized 50 * string. 51 */ 52 constructor(message, property, ...params) { 53 super(message); 54 this.isCryptoError = true; 55 this.property = property; 56 this.params = params; 57 } 58 59 /** 60 * Formats a localized string for reporting decryption errors to the Web 61 * Console. 62 * 63 * @param {string} scope The scope of the service worker receiving the 64 * message, prepended to any other substitutions in the string. 65 * @returns {string} The localized string. 66 */ 67 format(scope) { 68 let params = [scope, ...this.params].map(String); 69 return lazy.gDOMBundle.formatStringFromName(this.property, params); 70 } 71 } 72 73 function getEncryptionKeyParams(encryptKeyField) { 74 if (!encryptKeyField) { 75 return null; 76 } 77 var params = encryptKeyField.split(","); 78 return params.reduce((m, p) => { 79 var pmap = p.split(";").reduce(parseHeaderFieldParams, {}); 80 if (pmap.keyid && pmap.dh) { 81 m[pmap.keyid] = pmap.dh; 82 } 83 if (!m[DEFAULT_KEYID] && pmap.dh) { 84 m[DEFAULT_KEYID] = pmap.dh; 85 } 86 return m; 87 }, {}); 88 } 89 90 function getEncryptionParams(encryptField) { 91 if (!encryptField) { 92 throw new CryptoError("Missing encryption header", BAD_ENCRYPTION_HEADER); 93 } 94 var p = encryptField.split(",", 1)[0]; 95 if (!p) { 96 throw new CryptoError( 97 "Encryption header missing params", 98 BAD_ENCRYPTION_HEADER 99 ); 100 } 101 return p.split(";").reduce(parseHeaderFieldParams, {}); 102 } 103 104 // Extracts the sender public key, salt, and record size from the payload for the 105 // aes128gcm scheme. 106 function getCryptoParamsFromPayload(payload) { 107 if (payload.byteLength < 21) { 108 // The value 21 is from https://datatracker.ietf.org/doc/html/rfc8188#section-2.1 109 // | salt (16) | rs (4) | idlen (1) | keyid (idlen) | 110 throw new CryptoError("Truncated header", BAD_CRYPTO); 111 } 112 let rs = 113 (payload[16] << 24) | 114 (payload[17] << 16) | 115 (payload[18] << 8) | 116 payload[19]; 117 if (rs < 18) { 118 // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1 119 throw new CryptoError( 120 "Record sizes smaller than 18 are invalid", 121 BAD_RS_PARAM 122 ); 123 } 124 let keyIdLen = payload[20]; 125 if (keyIdLen != 65) { 126 // https://datatracker.ietf.org/doc/html/rfc8291/#section-4 127 throw new CryptoError("Invalid sender public key", BAD_DH_PARAM); 128 } 129 if (payload.byteLength <= 21 + keyIdLen) { 130 throw new CryptoError("Truncated payload", BAD_CRYPTO); 131 } 132 return { 133 salt: payload.slice(0, 16), 134 rs, 135 senderKey: payload.slice(21, 21 + keyIdLen), 136 ciphertext: payload.slice(21 + keyIdLen), 137 }; 138 } 139 140 // Extracts the sender public key, salt, and record size from the `Crypto-Key` 141 // and `Encryption` headers for the aesgcm scheme. 142 export function getCryptoParamsFromHeaders(headers) { 143 if (!headers) { 144 return null; 145 } 146 147 if (headers.encoding !== AESGCM_ENCODING) { 148 throw new CryptoError("Unexpected encoding", BAD_CRYPTO); 149 } 150 151 // aesgcm uses the Crypto-Key header, 2 bytes for the pad length, and an 152 // authentication secret. 153 // https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01 154 let keymap = getEncryptionKeyParams(headers.crypto_key); 155 if (!keymap) { 156 throw new CryptoError("Missing Crypto-Key header", BAD_CRYPTO_KEY_HEADER); 157 } 158 159 var enc = getEncryptionParams(headers.encryption); 160 var dh = keymap[enc.keyid || DEFAULT_KEYID]; 161 var senderKey = base64URLDecode(dh); 162 if (!senderKey) { 163 throw new CryptoError("Invalid dh parameter", BAD_DH_PARAM); 164 } 165 166 var salt = base64URLDecode(enc.salt); 167 if (!salt) { 168 throw new CryptoError("Invalid salt parameter", BAD_SALT_PARAM); 169 } 170 var rs = enc.rs ? parseInt(enc.rs, 10) : 4096; 171 if (isNaN(rs) || rs < 1 || rs > 68719476705) { 172 // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-encryption-encoding-03#section-3.1 173 throw new CryptoError( 174 "rs parameter must be a number greater than 1 and smaller than 2^36-31", 175 BAD_RS_PARAM 176 ); 177 } 178 return { 179 salt, 180 rs, 181 senderKey, 182 }; 183 } 184 185 // Decodes an unpadded, base64url-encoded string. 186 function base64URLDecode(string) { 187 if (!string) { 188 return null; 189 } 190 try { 191 return ChromeUtils.base64URLDecode(string, { 192 // draft-ietf-httpbis-encryption-encoding-01 prohibits padding. 193 padding: "reject", 194 }); 195 } catch (ex) {} 196 return null; 197 } 198 199 var parseHeaderFieldParams = (m, v) => { 200 var i = v.indexOf("="); 201 if (i >= 0) { 202 // A quoted string with internal quotes is invalid for all the possible 203 // values of this header field. 204 m[v.substring(0, i).trim()] = v 205 .substring(i + 1) 206 .trim() 207 .replace(/^"(.*)"$/, "$1"); 208 } 209 return m; 210 }; 211 212 function chunkArray(array, size) { 213 var start = array.byteOffset || 0; 214 array = array.buffer || array; 215 var index = 0; 216 var result = []; 217 while (index + size <= array.byteLength) { 218 result.push(new Uint8Array(array, start + index, size)); 219 index += size; 220 } 221 if (index < array.byteLength) { 222 result.push(new Uint8Array(array, start + index)); 223 } 224 return result; 225 } 226 227 function concatArray(arrays) { 228 var size = arrays.reduce((total, a) => total + a.byteLength, 0); 229 var index = 0; 230 return arrays.reduce((result, a) => { 231 result.set(new Uint8Array(a), index); 232 index += a.byteLength; 233 return result; 234 }, new Uint8Array(size)); 235 } 236 237 function hmac(key) { 238 this.keyPromise = crypto.subtle.importKey("raw", key, HMAC_SHA256, false, [ 239 "sign", 240 ]); 241 } 242 243 hmac.prototype.hash = function (input) { 244 return this.keyPromise.then(k => crypto.subtle.sign("HMAC", k, input)); 245 }; 246 247 function hkdf(salt, ikm) { 248 this.prkhPromise = new hmac(salt).hash(ikm).then(prk => new hmac(prk)); 249 } 250 251 hkdf.prototype.extract = function (info, len) { 252 var input = concatArray([info, new Uint8Array([1])]); 253 return this.prkhPromise 254 .then(prkh => prkh.hash(input)) 255 .then(h => { 256 if (h.byteLength < len) { 257 throw new CryptoError("HKDF length is too long", BAD_CRYPTO); 258 } 259 return h.slice(0, len); 260 }); 261 }; 262 263 /* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */ 264 function generateNonce(base, index) { 265 if (index >= Math.pow(2, 48)) { 266 throw new CryptoError("Nonce index is too large", BAD_CRYPTO); 267 } 268 var nonce = base.slice(0, 12); 269 nonce = new Uint8Array(nonce); 270 for (var i = 0; i < 6; ++i) { 271 nonce[nonce.byteLength - 1 - i] ^= (index / Math.pow(256, i)) & 0xff; 272 } 273 return nonce; 274 } 275 276 function encodeLength(buffer) { 277 return new Uint8Array([0, buffer.byteLength]); 278 } 279 280 class Decoder { 281 /** 282 * Creates a decoder for decrypting an incoming push message. 283 * 284 * @param {JsonWebKey} privateKey The static subscription private key. 285 * @param {BufferSource} publicKey The static subscription public key. 286 * @param {BufferSource} authenticationSecret The subscription authentication 287 * secret, or `null` if not used by the scheme. 288 * @param {object} cryptoParams An object containing the ephemeral sender 289 * public key, salt, and record size. 290 * @param {BufferSource} ciphertext The encrypted message data. 291 */ 292 constructor( 293 privateKey, 294 publicKey, 295 authenticationSecret, 296 cryptoParams, 297 ciphertext 298 ) { 299 this.privateKey = privateKey; 300 this.publicKey = publicKey; 301 this.authenticationSecret = authenticationSecret; 302 this.senderKey = cryptoParams.senderKey; 303 this.salt = cryptoParams.salt; 304 this.rs = cryptoParams.rs; 305 this.ciphertext = ciphertext; 306 } 307 308 /** 309 * Derives the decryption keys and decodes the push message. 310 * 311 * @throws {CryptoError} if decryption fails. 312 * @returns {Uint8Array} The decrypted message data. 313 */ 314 async decode() { 315 if (this.ciphertext.byteLength === 0) { 316 // Zero length messages will be passed as null. 317 return null; 318 } 319 try { 320 let ikm = await this.computeSharedSecret(); 321 let [gcmBits, nonce] = await this.deriveKeyAndNonce(ikm); 322 let key = await crypto.subtle.importKey( 323 "raw", 324 gcmBits, 325 "AES-GCM", 326 false, 327 ["decrypt"] 328 ); 329 330 let r = await Promise.all( 331 chunkArray(this.ciphertext, this.chunkSize).map( 332 (slice, index, chunks) => 333 this.decodeChunk( 334 slice, 335 index, 336 nonce, 337 key, 338 index >= chunks.length - 1 339 ) 340 ) 341 ); 342 343 return concatArray(r); 344 } catch (error) { 345 if (error.isCryptoError) { 346 throw error; 347 } 348 // Web Crypto returns an unhelpful "operation failed for an 349 // operation-specific reason" error if decryption fails. We don't have 350 // context about what went wrong, so we throw a generic error instead. 351 throw new CryptoError("Bad encryption", BAD_CRYPTO); 352 } 353 } 354 355 /** 356 * Computes the ECDH shared secret, used as the input key material for HKDF. 357 * 358 * @throws if the static or ephemeral ECDH keys are invalid. 359 * @returns {ArrayBuffer} The shared secret. 360 */ 361 async computeSharedSecret() { 362 let [appServerKey, subscriptionPrivateKey] = await Promise.all([ 363 crypto.subtle.importKey("raw", this.senderKey, ECDH_KEY, false, []), 364 crypto.subtle.importKey("jwk", this.privateKey, ECDH_KEY, false, [ 365 "deriveBits", 366 ]), 367 ]); 368 return crypto.subtle.deriveBits( 369 { name: "ECDH", public: appServerKey }, 370 subscriptionPrivateKey, 371 256 372 ); 373 } 374 375 /** 376 * Derives the content encryption key and nonce. 377 * 378 * @param {BufferSource} ikm The ECDH shared secret. 379 * @returns {Array} A `[gcmBits, nonce]` tuple. 380 */ 381 async deriveKeyAndNonce() { 382 throw new Error("Missing `deriveKeyAndNonce` implementation"); 383 } 384 385 /** 386 * Decrypts and removes padding from an encrypted record. 387 * 388 * @throws {CryptoError} if decryption fails or padding is incorrect. 389 * @param {Uint8Array} slice The encrypted record. 390 * @param {number} index The record sequence number. 391 * @param {Uint8Array} nonce The nonce base, used to generate the IV. 392 * @param {Uint8Array} key The content encryption key. 393 * @param {boolean} last Indicates if this is the final record. 394 * @returns {Uint8Array} The decrypted block with padding removed. 395 */ 396 async decodeChunk(slice, index, nonce, key, last) { 397 let params = { 398 name: "AES-GCM", 399 iv: generateNonce(nonce, index), 400 }; 401 let decoded = await crypto.subtle.decrypt(params, key, slice); 402 return this.unpadChunk(new Uint8Array(decoded), last); 403 } 404 405 /** 406 * Removes padding from a decrypted block. 407 * 408 * @throws {CryptoError} if padding is missing or invalid. 409 * @param {Uint8Array} chunk The decrypted block with padding. 410 * @returns {Uint8Array} The block with padding removed. 411 */ 412 unpadChunk() { 413 throw new Error("Missing `unpadChunk` implementation"); 414 } 415 416 /** The record chunking size. */ 417 get chunkSize() { 418 throw new Error("Missing `chunkSize` implementation"); 419 } 420 } 421 422 class OldSchemeDecoder extends Decoder { 423 async decode() { 424 // For aesgcm, the ciphertext length can't fall on a record boundary. 425 if ( 426 this.ciphertext.byteLength > 0 && 427 this.ciphertext.byteLength % this.chunkSize === 0 428 ) { 429 throw new CryptoError("Encrypted data truncated", BAD_CRYPTO); 430 } 431 return super.decode(); 432 } 433 434 /** 435 * For aesgcm, the padding length is a 16-bit unsigned big endian integer. 436 */ 437 unpadChunk(decoded) { 438 if (decoded.length < this.padSize) { 439 throw new CryptoError("Decoded array is too short!", BAD_PADDING); 440 } 441 var pad = decoded[0]; 442 if (this.padSize == 2) { 443 pad = (pad << 8) | decoded[1]; 444 } 445 if (pad > decoded.length - this.padSize) { 446 throw new CryptoError("Padding is wrong!", BAD_PADDING); 447 } 448 // All padded bytes must be zero except the first one. 449 for (var i = this.padSize; i < this.padSize + pad; i++) { 450 if (decoded[i] !== 0) { 451 throw new CryptoError("Padding is wrong!", BAD_PADDING); 452 } 453 } 454 return decoded.slice(pad + this.padSize); 455 } 456 457 /** 458 * aesgcm doesn't account for the authentication tag as part of 459 * the record size. 460 */ 461 get chunkSize() { 462 return this.rs + 16; 463 } 464 465 get padSize() { 466 throw new Error("Missing `padSize` implementation"); 467 } 468 } 469 470 /** New encryption scheme (draft-ietf-httpbis-encryption-encoding-06). */ 471 472 const AES128GCM_ENCODING = "aes128gcm"; 473 const AES128GCM_KEY_INFO = UTF8.encode("Content-Encoding: aes128gcm\0"); 474 const AES128GCM_AUTH_INFO = UTF8.encode("WebPush: info\0"); 475 const AES128GCM_NONCE_INFO = UTF8.encode("Content-Encoding: nonce\0"); 476 477 class aes128gcmDecoder extends Decoder { 478 /** 479 * Derives the aes128gcm decryption key and nonce. The PRK info string for 480 * HKDF is "WebPush: info\0", followed by the unprefixed receiver and sender 481 * public keys. 482 */ 483 async deriveKeyAndNonce(ikm) { 484 let authKdf = new hkdf(this.authenticationSecret, ikm); 485 let authInfo = concatArray([ 486 AES128GCM_AUTH_INFO, 487 this.publicKey, 488 this.senderKey, 489 ]); 490 let prk = await authKdf.extract(authInfo, 32); 491 let prkKdf = new hkdf(this.salt, prk); 492 return Promise.all([ 493 prkKdf.extract(AES128GCM_KEY_INFO, 16), 494 prkKdf.extract(AES128GCM_NONCE_INFO, 12), 495 ]); 496 } 497 498 unpadChunk(decoded, last) { 499 let length = decoded.length; 500 while (length--) { 501 if (decoded[length] === 0) { 502 continue; 503 } 504 let recordPad = last ? 2 : 1; 505 if (decoded[length] != recordPad) { 506 throw new CryptoError("Padding is wrong!", BAD_PADDING); 507 } 508 return decoded.slice(0, length); 509 } 510 throw new CryptoError("Zero plaintext", BAD_PADDING); 511 } 512 513 /** aes128gcm accounts for the authentication tag in the record size. */ 514 get chunkSize() { 515 return this.rs; 516 } 517 } 518 519 /** Older encryption scheme (draft-ietf-httpbis-encryption-encoding-01). */ 520 521 const AESGCM_ENCODING = "aesgcm"; 522 const AESGCM_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm\0"); 523 const AESGCM_AUTH_INFO = UTF8.encode("Content-Encoding: auth\0"); // note nul-terminus 524 const AESGCM_P256DH_INFO = UTF8.encode("P-256\0"); 525 526 class aesgcmDecoder extends OldSchemeDecoder { 527 /** 528 * Derives the aesgcm decryption key and nonce. We mix the authentication 529 * secret with the ikm using HKDF. The context string for the PRK is 530 * "Content-Encoding: auth\0". The context string for the key and nonce is 531 * "Content-Encoding: <blah>\0P-256\0" then the length and value of both the 532 * receiver key and sender key. 533 */ 534 async deriveKeyAndNonce(ikm) { 535 // Since we are using an authentication secret, we need to run an extra 536 // round of HKDF with the authentication secret as salt. 537 let authKdf = new hkdf(this.authenticationSecret, ikm); 538 let prk = await authKdf.extract(AESGCM_AUTH_INFO, 32); 539 let prkKdf = new hkdf(this.salt, prk); 540 let keyInfo = concatArray([ 541 AESGCM_KEY_INFO, 542 AESGCM_P256DH_INFO, 543 encodeLength(this.publicKey), 544 this.publicKey, 545 encodeLength(this.senderKey), 546 this.senderKey, 547 ]); 548 let nonceInfo = concatArray([ 549 NONCE_INFO, 550 new Uint8Array([0]), 551 AESGCM_P256DH_INFO, 552 encodeLength(this.publicKey), 553 this.publicKey, 554 encodeLength(this.senderKey), 555 this.senderKey, 556 ]); 557 return Promise.all([ 558 prkKdf.extract(keyInfo, 16), 559 prkKdf.extract(nonceInfo, 12), 560 ]); 561 } 562 563 get padSize() { 564 return 2; 565 } 566 } 567 568 export var PushCrypto = { 569 concatArray, 570 571 generateAuthenticationSecret() { 572 return crypto.getRandomValues(new Uint8Array(16)); 573 }, 574 575 validateAppServerKey(key) { 576 return crypto.subtle 577 .importKey("raw", key, ECDSA_KEY, true, ["verify"]) 578 .then(_ => key); 579 }, 580 581 generateKeys() { 582 return crypto.subtle 583 .generateKey(ECDH_KEY, true, ["deriveBits"]) 584 .then(cryptoKey => 585 Promise.all([ 586 crypto.subtle.exportKey("raw", cryptoKey.publicKey), 587 crypto.subtle.exportKey("jwk", cryptoKey.privateKey), 588 ]) 589 ); 590 }, 591 592 /** 593 * Decrypts a push message. 594 * 595 * @throws {CryptoError} if decryption fails. 596 * @param {JsonWebKey} privateKey The ECDH private key of the subscription 597 * receiving the message, in JWK form. 598 * @param {BufferSource} publicKey The ECDH public key of the subscription 599 * receiving the message, in raw form. 600 * @param {BufferSource} authenticationSecret The 16-byte shared 601 * authentication secret of the subscription receiving the message. 602 * @param {object} headers The encryption headers from the push server. 603 * @param {BufferSource} payload The encrypted message payload. 604 * @returns {Uint8Array} The decrypted message data. 605 */ 606 async decrypt(privateKey, publicKey, authenticationSecret, headers, payload) { 607 if (!headers) { 608 return null; 609 } 610 611 let encoding = headers.encoding; 612 if (!headers.encoding) { 613 throw new CryptoError( 614 "Missing Content-Encoding header", 615 BAD_ENCODING_HEADER 616 ); 617 } 618 619 let decoder; 620 if (encoding == AES128GCM_ENCODING) { 621 // aes128gcm includes the salt, record size, and sender public key in a 622 // binary header preceding the ciphertext. 623 let cryptoParams = getCryptoParamsFromPayload(new Uint8Array(payload)); 624 Glean.webPush.contentEncoding.aes128gcm.add(); 625 decoder = new aes128gcmDecoder( 626 privateKey, 627 publicKey, 628 authenticationSecret, 629 cryptoParams, 630 cryptoParams.ciphertext 631 ); 632 } else if (encoding == AESGCM_ENCODING) { 633 // aesgcm includes the salt, record size, and sender public 634 // key in the `Crypto-Key` and `Encryption` HTTP headers. 635 let cryptoParams = getCryptoParamsFromHeaders(headers); 636 Glean.webPush.contentEncoding.aesgcm.add(); 637 decoder = new aesgcmDecoder( 638 privateKey, 639 publicKey, 640 authenticationSecret, 641 cryptoParams, 642 payload 643 ); 644 } 645 646 if (!decoder) { 647 throw new CryptoError( 648 "Unsupported Content-Encoding: " + encoding, 649 BAD_ENCODING_HEADER 650 ); 651 } 652 653 return decoder.decode(); 654 }, 655 656 /** 657 * Encrypts a payload suitable for using in a push message. The encryption 658 * is always done with a record size of 4096 and no padding. 659 * 660 * @throws {CryptoError} if encryption fails. 661 * @param {plaintext} Uint8Array The plaintext to encrypt. 662 * @param {receiverPublicKey} Uint8Array The public key of the recipient 663 * of the message as a buffer. 664 * @param {receiverAuthSecret} Uint8Array The auth secret of the of the 665 * message recipient as a buffer. 666 * @param {options} Object Encryption options, used for tests. 667 * @returns {ciphertext, encoding} The encrypted payload and encoding. 668 */ 669 async encrypt( 670 plaintext, 671 receiverPublicKey, 672 receiverAuthSecret, 673 options = {} 674 ) { 675 const encoding = options.encoding || AES128GCM_ENCODING; 676 // We only support one encoding type. 677 if (encoding != AES128GCM_ENCODING) { 678 throw new CryptoError( 679 `Only ${AES128GCM_ENCODING} is supported`, 680 BAD_ENCODING_HEADER 681 ); 682 } 683 // We typically use an ephemeral key for this message, but for testing 684 // purposes we allow it to be specified. 685 const senderKeyPair = 686 options.senderKeyPair || 687 (await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveBits"])); 688 // allowing a salt to be specified is useful for tests. 689 const salt = options.salt || crypto.getRandomValues(new Uint8Array(16)); 690 const rs = options.rs === undefined ? 4096 : options.rs; 691 692 const encoder = new aes128gcmEncoder( 693 plaintext, 694 receiverPublicKey, 695 receiverAuthSecret, 696 senderKeyPair, 697 salt, 698 rs 699 ); 700 return encoder.encode(); 701 }, 702 }; 703 704 // A class for aes128gcm encryption - the only kind we support. 705 class aes128gcmEncoder { 706 constructor( 707 plaintext, 708 receiverPublicKey, 709 receiverAuthSecret, 710 senderKeyPair, 711 salt, 712 rs 713 ) { 714 this.receiverPublicKey = receiverPublicKey; 715 this.receiverAuthSecret = receiverAuthSecret; 716 this.senderKeyPair = senderKeyPair; 717 this.salt = salt; 718 this.rs = rs; 719 this.plaintext = plaintext; 720 } 721 722 async encode() { 723 const sharedSecret = await this.computeSharedSecret( 724 this.receiverPublicKey, 725 this.senderKeyPair.privateKey 726 ); 727 728 const rawSenderPublicKey = await crypto.subtle.exportKey( 729 "raw", 730 this.senderKeyPair.publicKey 731 ); 732 const [gcmBits, nonce] = await this.deriveKeyAndNonce( 733 sharedSecret, 734 rawSenderPublicKey 735 ); 736 737 const contentEncryptionKey = await crypto.subtle.importKey( 738 "raw", 739 gcmBits, 740 "AES-GCM", 741 false, 742 ["encrypt"] 743 ); 744 const payloadHeader = this.createHeader(rawSenderPublicKey); 745 746 const ciphertextChunks = await this.encrypt(contentEncryptionKey, nonce); 747 return { 748 ciphertext: concatArray([payloadHeader, ...ciphertextChunks]), 749 encoding: "aes128gcm", 750 }; 751 } 752 753 // Perform the actual encryption of the payload. 754 async encrypt(key, nonce) { 755 if (this.rs < 18) { 756 // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1 757 throw new CryptoError("recordsize is too small", BAD_RS_PARAM); 758 } 759 760 let chunks; 761 if (this.plaintext.byteLength === 0) { 762 // Send an authentication tag for empty messages. 763 chunks = [ 764 await crypto.subtle.encrypt( 765 { 766 name: "AES-GCM", 767 iv: generateNonce(nonce, 0), 768 }, 769 key, 770 new Uint8Array([2]) 771 ), 772 ]; 773 } else { 774 // Use specified recordsize, though we burn 1 for padding and 16 byte 775 // overhead. 776 let inChunks = chunkArray(this.plaintext, this.rs - 1 - 16); 777 chunks = await Promise.all( 778 inChunks.map(async function (slice, index) { 779 let isLast = index == inChunks.length - 1; 780 let padding = new Uint8Array([isLast ? 2 : 1]); 781 let input = concatArray([slice, padding]); 782 return crypto.subtle.encrypt( 783 { 784 name: "AES-GCM", 785 iv: generateNonce(nonce, index), 786 }, 787 key, 788 input 789 ); 790 }) 791 ); 792 } 793 return chunks; 794 } 795 796 // Note: this is a dupe of aes128gcmDecoder.deriveKeyAndNonce, but tricky 797 // to rationalize without a larger refactor. 798 async deriveKeyAndNonce(sharedSecret, senderPublicKey) { 799 const authKdf = new hkdf(this.receiverAuthSecret, sharedSecret); 800 const authInfo = concatArray([ 801 AES128GCM_AUTH_INFO, 802 this.receiverPublicKey, 803 senderPublicKey, 804 ]); 805 const prk = await authKdf.extract(authInfo, 32); 806 const prkKdf = new hkdf(this.salt, prk); 807 return Promise.all([ 808 prkKdf.extract(AES128GCM_KEY_INFO, 16), 809 prkKdf.extract(AES128GCM_NONCE_INFO, 12), 810 ]); 811 } 812 813 // Note: this duplicates some of Decoder.computeSharedSecret, but the key 814 // management is slightly different. 815 async computeSharedSecret(receiverPublicKey, senderPrivateKey) { 816 const receiverPublicCryptoKey = await crypto.subtle.importKey( 817 "raw", 818 receiverPublicKey, 819 ECDH_KEY, 820 false, 821 [] 822 ); 823 824 return crypto.subtle.deriveBits( 825 { name: "ECDH", public: receiverPublicCryptoKey }, 826 senderPrivateKey, 827 256 828 ); 829 } 830 831 // create aes128gcm's header. 832 createHeader(key) { 833 // layout is "salt|32-bit-int|8-bit-int|key" 834 if (key.byteLength != 65) { 835 // https://datatracker.ietf.org/doc/html/rfc8291/#section-4 836 throw new CryptoError("Invalid key length for header", BAD_DH_PARAM); 837 } 838 // the 2 ints 839 let ints = new Uint8Array(5); 840 let intsv = new DataView(ints.buffer); 841 intsv.setUint32(0, this.rs); // bigendian 842 intsv.setUint8(4, key.byteLength); 843 return concatArray([this.salt, ints, key]); 844 } 845 }