tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }