dom/push/PushCrypto.sys.mjs
author Sandor Molnar <smolnar@mozilla.com>
Fri, 11 Jul 2025 19:57:29 +0300 (7 hours ago)
changeset 796221 8ba6984a5604ac7dcf50325b1a0ebadf9e305d22
parent 767040 353feca04484a06391e819f0db421dbeb6955291
permissions -rw-r--r--
Revert "Bug 1972411 - give gnome-shell and pipewire more time to start, and retry the task if we time out. r=jmaher" for causing linux perma failures This reverts commit 2b905fe7199c9210434f7c7f8326b57025c91c55. Revert "Bug 1972411 - make /builds/worker/fetches a volume in the test docker image. r=releng-reviewers,Eijebong" This reverts commit 9d15aecaf6a08b98d3c47f2d0e644e35341b2520.
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

const lazy = {};

ChromeUtils.defineLazyGetter(lazy, "gDOMBundle", () =>
  Services.strings.createBundle("chrome://global/locale/dom/dom.properties")
);

// getCryptoParamsFromHeaders is exported for test purposes.
const UTF8 = new TextEncoder();

const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
const ECDSA_KEY = { name: "ECDSA", namedCurve: "P-256" };
const HMAC_SHA256 = { name: "HMAC", hash: "SHA-256" };
const NONCE_INFO = UTF8.encode("Content-Encoding: nonce");

// A default keyid with a name that won't conflict with a real keyid.
const DEFAULT_KEYID = "";

/** Localized error property names. */

// `Encryption` header missing or malformed.
const BAD_ENCRYPTION_HEADER = "PushMessageBadEncryptionHeader";
// `Crypto-Key` header missing.
const BAD_CRYPTO_KEY_HEADER = "PushMessageBadCryptoKeyHeader";
// `Content-Encoding` header missing or contains unsupported encoding.
const BAD_ENCODING_HEADER = "PushMessageBadEncodingHeader";
// `dh` parameter of `Crypto-Key` header missing or not base64url-encoded.
const BAD_DH_PARAM = "PushMessageBadSenderKey";
// `salt` parameter of `Encryption` header missing or not base64url-encoded.
const BAD_SALT_PARAM = "PushMessageBadSalt";
// `rs` parameter of `Encryption` header not a number or less than pad size.
const BAD_RS_PARAM = "PushMessageBadRecordSize";
// Invalid or insufficient padding for encrypted chunk.
const BAD_PADDING = "PushMessageBadPaddingError";
// Generic crypto error.
const BAD_CRYPTO = "PushMessageBadCryptoError";

class CryptoError extends Error {
  /**
   * Creates an error object indicating an incoming push message could not be
   * decrypted.
   *
   * @param {String} message A human-readable error message. This is only for
   * internal module logging, and doesn't need to be localized.
   * @param {String} property The localized property name from `dom.properties`.
   * @param {String...} params Substitutions to insert into the localized
   *  string.
   */
  constructor(message, property, ...params) {
    super(message);
    this.isCryptoError = true;
    this.property = property;
    this.params = params;
  }

  /**
   * Formats a localized string for reporting decryption errors to the Web
   * Console.
   *
   * @param {String} scope The scope of the service worker receiving the
   *  message, prepended to any other substitutions in the string.
   * @returns {String} The localized string.
   */
  format(scope) {
    let params = [scope, ...this.params].map(String);
    return lazy.gDOMBundle.formatStringFromName(this.property, params);
  }
}

function getEncryptionKeyParams(encryptKeyField) {
  if (!encryptKeyField) {
    return null;
  }
  var params = encryptKeyField.split(",");
  return params.reduce((m, p) => {
    var pmap = p.split(";").reduce(parseHeaderFieldParams, {});
    if (pmap.keyid && pmap.dh) {
      m[pmap.keyid] = pmap.dh;
    }
    if (!m[DEFAULT_KEYID] && pmap.dh) {
      m[DEFAULT_KEYID] = pmap.dh;
    }
    return m;
  }, {});
}

function getEncryptionParams(encryptField) {
  if (!encryptField) {
    throw new CryptoError("Missing encryption header", BAD_ENCRYPTION_HEADER);
  }
  var p = encryptField.split(",", 1)[0];
  if (!p) {
    throw new CryptoError(
      "Encryption header missing params",
      BAD_ENCRYPTION_HEADER
    );
  }
  return p.split(";").reduce(parseHeaderFieldParams, {});
}

// Extracts the sender public key, salt, and record size from the payload for the
// aes128gcm scheme.
function getCryptoParamsFromPayload(payload) {
  if (payload.byteLength < 21) {
    // The value 21 is from https://datatracker.ietf.org/doc/html/rfc8188#section-2.1
    // | salt (16) | rs (4) | idlen (1) | keyid (idlen) |
    throw new CryptoError("Truncated header", BAD_CRYPTO);
  }
  let rs =
    (payload[16] << 24) |
    (payload[17] << 16) |
    (payload[18] << 8) |
    payload[19];
  if (rs < 18) {
    // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1
    throw new CryptoError(
      "Record sizes smaller than 18 are invalid",
      BAD_RS_PARAM
    );
  }
  let keyIdLen = payload[20];
  if (keyIdLen != 65) {
    // https://datatracker.ietf.org/doc/html/rfc8291/#section-4
    throw new CryptoError("Invalid sender public key", BAD_DH_PARAM);
  }
  if (payload.byteLength <= 21 + keyIdLen) {
    throw new CryptoError("Truncated payload", BAD_CRYPTO);
  }
  return {
    salt: payload.slice(0, 16),
    rs,
    senderKey: payload.slice(21, 21 + keyIdLen),
    ciphertext: payload.slice(21 + keyIdLen),
  };
}

// Extracts the sender public key, salt, and record size from the `Crypto-Key`
// and `Encryption` headers for the aesgcm scheme.
export function getCryptoParamsFromHeaders(headers) {
  if (!headers) {
    return null;
  }

  if (headers.encoding !== AESGCM_ENCODING) {
    throw new CryptoError("Unexpected encoding", BAD_CRYPTO);
  }

  // aesgcm uses the Crypto-Key header, 2 bytes for the pad length, and an
  // authentication secret.
  // https://tools.ietf.org/html/draft-ietf-httpbis-encryption-encoding-01
  let keymap = getEncryptionKeyParams(headers.crypto_key);
  if (!keymap) {
    throw new CryptoError("Missing Crypto-Key header", BAD_CRYPTO_KEY_HEADER);
  }

  var enc = getEncryptionParams(headers.encryption);
  var dh = keymap[enc.keyid || DEFAULT_KEYID];
  var senderKey = base64URLDecode(dh);
  if (!senderKey) {
    throw new CryptoError("Invalid dh parameter", BAD_DH_PARAM);
  }

  var salt = base64URLDecode(enc.salt);
  if (!salt) {
    throw new CryptoError("Invalid salt parameter", BAD_SALT_PARAM);
  }
  var rs = enc.rs ? parseInt(enc.rs, 10) : 4096;
  if (isNaN(rs) || rs < 1 || rs > 68719476705) {
    // https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-encryption-encoding-03#section-3.1
    throw new CryptoError(
      "rs parameter must be a number greater than 1 and smaller than 2^36-31",
      BAD_RS_PARAM
    );
  }
  return {
    salt,
    rs,
    senderKey,
  };
}

// Decodes an unpadded, base64url-encoded string.
function base64URLDecode(string) {
  if (!string) {
    return null;
  }
  try {
    return ChromeUtils.base64URLDecode(string, {
      // draft-ietf-httpbis-encryption-encoding-01 prohibits padding.
      padding: "reject",
    });
  } catch (ex) {}
  return null;
}

var parseHeaderFieldParams = (m, v) => {
  var i = v.indexOf("=");
  if (i >= 0) {
    // A quoted string with internal quotes is invalid for all the possible
    // values of this header field.
    m[v.substring(0, i).trim()] = v
      .substring(i + 1)
      .trim()
      .replace(/^"(.*)"$/, "$1");
  }
  return m;
};

function chunkArray(array, size) {
  var start = array.byteOffset || 0;
  array = array.buffer || array;
  var index = 0;
  var result = [];
  while (index + size <= array.byteLength) {
    result.push(new Uint8Array(array, start + index, size));
    index += size;
  }
  if (index < array.byteLength) {
    result.push(new Uint8Array(array, start + index));
  }
  return result;
}

function concatArray(arrays) {
  var size = arrays.reduce((total, a) => total + a.byteLength, 0);
  var index = 0;
  return arrays.reduce((result, a) => {
    result.set(new Uint8Array(a), index);
    index += a.byteLength;
    return result;
  }, new Uint8Array(size));
}

function hmac(key) {
  this.keyPromise = crypto.subtle.importKey("raw", key, HMAC_SHA256, false, [
    "sign",
  ]);
}

hmac.prototype.hash = function (input) {
  return this.keyPromise.then(k => crypto.subtle.sign("HMAC", k, input));
};

function hkdf(salt, ikm) {
  this.prkhPromise = new hmac(salt).hash(ikm).then(prk => new hmac(prk));
}

hkdf.prototype.extract = function (info, len) {
  var input = concatArray([info, new Uint8Array([1])]);
  return this.prkhPromise
    .then(prkh => prkh.hash(input))
    .then(h => {
      if (h.byteLength < len) {
        throw new CryptoError("HKDF length is too long", BAD_CRYPTO);
      }
      return h.slice(0, len);
    });
};

/* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */
function generateNonce(base, index) {
  if (index >= Math.pow(2, 48)) {
    throw new CryptoError("Nonce index is too large", BAD_CRYPTO);
  }
  var nonce = base.slice(0, 12);
  nonce = new Uint8Array(nonce);
  for (var i = 0; i < 6; ++i) {
    nonce[nonce.byteLength - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
  }
  return nonce;
}

function encodeLength(buffer) {
  return new Uint8Array([0, buffer.byteLength]);
}

class Decoder {
  /**
   * Creates a decoder for decrypting an incoming push message.
   *
   * @param {JsonWebKey} privateKey The static subscription private key.
   * @param {BufferSource} publicKey The static subscription public key.
   * @param {BufferSource} authenticationSecret The subscription authentication
   *  secret, or `null` if not used by the scheme.
   * @param {Object} cryptoParams An object containing the ephemeral sender
   *  public key, salt, and record size.
   * @param {BufferSource} ciphertext The encrypted message data.
   */
  constructor(
    privateKey,
    publicKey,
    authenticationSecret,
    cryptoParams,
    ciphertext
  ) {
    this.privateKey = privateKey;
    this.publicKey = publicKey;
    this.authenticationSecret = authenticationSecret;
    this.senderKey = cryptoParams.senderKey;
    this.salt = cryptoParams.salt;
    this.rs = cryptoParams.rs;
    this.ciphertext = ciphertext;
  }

  /**
   * Derives the decryption keys and decodes the push message.
   *
   * @throws {CryptoError} if decryption fails.
   * @returns {Uint8Array} The decrypted message data.
   */
  async decode() {
    if (this.ciphertext.byteLength === 0) {
      // Zero length messages will be passed as null.
      return null;
    }
    try {
      let ikm = await this.computeSharedSecret();
      let [gcmBits, nonce] = await this.deriveKeyAndNonce(ikm);
      let key = await crypto.subtle.importKey(
        "raw",
        gcmBits,
        "AES-GCM",
        false,
        ["decrypt"]
      );

      let r = await Promise.all(
        chunkArray(this.ciphertext, this.chunkSize).map(
          (slice, index, chunks) =>
            this.decodeChunk(
              slice,
              index,
              nonce,
              key,
              index >= chunks.length - 1
            )
        )
      );

      return concatArray(r);
    } catch (error) {
      if (error.isCryptoError) {
        throw error;
      }
      // Web Crypto returns an unhelpful "operation failed for an
      // operation-specific reason" error if decryption fails. We don't have
      // context about what went wrong, so we throw a generic error instead.
      throw new CryptoError("Bad encryption", BAD_CRYPTO);
    }
  }

  /**
   * Computes the ECDH shared secret, used as the input key material for HKDF.
   *
   * @throws if the static or ephemeral ECDH keys are invalid.
   * @returns {ArrayBuffer} The shared secret.
   */
  async computeSharedSecret() {
    let [appServerKey, subscriptionPrivateKey] = await Promise.all([
      crypto.subtle.importKey("raw", this.senderKey, ECDH_KEY, false, []),
      crypto.subtle.importKey("jwk", this.privateKey, ECDH_KEY, false, [
        "deriveBits",
      ]),
    ]);
    return crypto.subtle.deriveBits(
      { name: "ECDH", public: appServerKey },
      subscriptionPrivateKey,
      256
    );
  }

  /**
   * Derives the content encryption key and nonce.
   *
   * @param {BufferSource} ikm The ECDH shared secret.
   * @returns {Array} A `[gcmBits, nonce]` tuple.
   */
  async deriveKeyAndNonce() {
    throw new Error("Missing `deriveKeyAndNonce` implementation");
  }

  /**
   * Decrypts and removes padding from an encrypted record.
   *
   * @throws {CryptoError} if decryption fails or padding is incorrect.
   * @param {Uint8Array} slice The encrypted record.
   * @param {Number} index The record sequence number.
   * @param {Uint8Array} nonce The nonce base, used to generate the IV.
   * @param {Uint8Array} key The content encryption key.
   * @param {Boolean} last Indicates if this is the final record.
   * @returns {Uint8Array} The decrypted block with padding removed.
   */
  async decodeChunk(slice, index, nonce, key, last) {
    let params = {
      name: "AES-GCM",
      iv: generateNonce(nonce, index),
    };
    let decoded = await crypto.subtle.decrypt(params, key, slice);
    return this.unpadChunk(new Uint8Array(decoded), last);
  }

  /**
   * Removes padding from a decrypted block.
   *
   * @throws {CryptoError} if padding is missing or invalid.
   * @param {Uint8Array} chunk The decrypted block with padding.
   * @returns {Uint8Array} The block with padding removed.
   */
  unpadChunk() {
    throw new Error("Missing `unpadChunk` implementation");
  }

  /** The record chunking size. */
  get chunkSize() {
    throw new Error("Missing `chunkSize` implementation");
  }
}

class OldSchemeDecoder extends Decoder {
  async decode() {
    // For aesgcm, the ciphertext length can't fall on a record boundary.
    if (
      this.ciphertext.byteLength > 0 &&
      this.ciphertext.byteLength % this.chunkSize === 0
    ) {
      throw new CryptoError("Encrypted data truncated", BAD_CRYPTO);
    }
    return super.decode();
  }

  /**
   * For aesgcm, the padding length is a 16-bit unsigned big endian integer.
   */
  unpadChunk(decoded) {
    if (decoded.length < this.padSize) {
      throw new CryptoError("Decoded array is too short!", BAD_PADDING);
    }
    var pad = decoded[0];
    if (this.padSize == 2) {
      pad = (pad << 8) | decoded[1];
    }
    if (pad > decoded.length - this.padSize) {
      throw new CryptoError("Padding is wrong!", BAD_PADDING);
    }
    // All padded bytes must be zero except the first one.
    for (var i = this.padSize; i < this.padSize + pad; i++) {
      if (decoded[i] !== 0) {
        throw new CryptoError("Padding is wrong!", BAD_PADDING);
      }
    }
    return decoded.slice(pad + this.padSize);
  }

  /**
   * aesgcm doesn't account for the authentication tag as part of
   * the record size.
   */
  get chunkSize() {
    return this.rs + 16;
  }

  get padSize() {
    throw new Error("Missing `padSize` implementation");
  }
}

/** New encryption scheme (draft-ietf-httpbis-encryption-encoding-06). */

const AES128GCM_ENCODING = "aes128gcm";
const AES128GCM_KEY_INFO = UTF8.encode("Content-Encoding: aes128gcm\0");
const AES128GCM_AUTH_INFO = UTF8.encode("WebPush: info\0");
const AES128GCM_NONCE_INFO = UTF8.encode("Content-Encoding: nonce\0");

class aes128gcmDecoder extends Decoder {
  /**
   * Derives the aes128gcm decryption key and nonce. The PRK info string for
   * HKDF is "WebPush: info\0", followed by the unprefixed receiver and sender
   * public keys.
   */
  async deriveKeyAndNonce(ikm) {
    let authKdf = new hkdf(this.authenticationSecret, ikm);
    let authInfo = concatArray([
      AES128GCM_AUTH_INFO,
      this.publicKey,
      this.senderKey,
    ]);
    let prk = await authKdf.extract(authInfo, 32);
    let prkKdf = new hkdf(this.salt, prk);
    return Promise.all([
      prkKdf.extract(AES128GCM_KEY_INFO, 16),
      prkKdf.extract(AES128GCM_NONCE_INFO, 12),
    ]);
  }

  unpadChunk(decoded, last) {
    let length = decoded.length;
    while (length--) {
      if (decoded[length] === 0) {
        continue;
      }
      let recordPad = last ? 2 : 1;
      if (decoded[length] != recordPad) {
        throw new CryptoError("Padding is wrong!", BAD_PADDING);
      }
      return decoded.slice(0, length);
    }
    throw new CryptoError("Zero plaintext", BAD_PADDING);
  }

  /** aes128gcm accounts for the authentication tag in the record size. */
  get chunkSize() {
    return this.rs;
  }
}

/** Older encryption scheme (draft-ietf-httpbis-encryption-encoding-01). */

const AESGCM_ENCODING = "aesgcm";
const AESGCM_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm\0");
const AESGCM_AUTH_INFO = UTF8.encode("Content-Encoding: auth\0"); // note nul-terminus
const AESGCM_P256DH_INFO = UTF8.encode("P-256\0");

class aesgcmDecoder extends OldSchemeDecoder {
  /**
   * Derives the aesgcm decryption key and nonce. We mix the authentication
   * secret with the ikm using HKDF. The context string for the PRK is
   * "Content-Encoding: auth\0". The context string for the key and nonce is
   * "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
   * receiver key and sender key.
   */
  async deriveKeyAndNonce(ikm) {
    // Since we are using an authentication secret, we need to run an extra
    // round of HKDF with the authentication secret as salt.
    let authKdf = new hkdf(this.authenticationSecret, ikm);
    let prk = await authKdf.extract(AESGCM_AUTH_INFO, 32);
    let prkKdf = new hkdf(this.salt, prk);
    let keyInfo = concatArray([
      AESGCM_KEY_INFO,
      AESGCM_P256DH_INFO,
      encodeLength(this.publicKey),
      this.publicKey,
      encodeLength(this.senderKey),
      this.senderKey,
    ]);
    let nonceInfo = concatArray([
      NONCE_INFO,
      new Uint8Array([0]),
      AESGCM_P256DH_INFO,
      encodeLength(this.publicKey),
      this.publicKey,
      encodeLength(this.senderKey),
      this.senderKey,
    ]);
    return Promise.all([
      prkKdf.extract(keyInfo, 16),
      prkKdf.extract(nonceInfo, 12),
    ]);
  }

  get padSize() {
    return 2;
  }
}

export var PushCrypto = {
  concatArray,

  generateAuthenticationSecret() {
    return crypto.getRandomValues(new Uint8Array(16));
  },

  validateAppServerKey(key) {
    return crypto.subtle
      .importKey("raw", key, ECDSA_KEY, true, ["verify"])
      .then(_ => key);
  },

  generateKeys() {
    return crypto.subtle
      .generateKey(ECDH_KEY, true, ["deriveBits"])
      .then(cryptoKey =>
        Promise.all([
          crypto.subtle.exportKey("raw", cryptoKey.publicKey),
          crypto.subtle.exportKey("jwk", cryptoKey.privateKey),
        ])
      );
  },

  /**
   * Decrypts a push message.
   *
   * @throws {CryptoError} if decryption fails.
   * @param {JsonWebKey} privateKey The ECDH private key of the subscription
   *  receiving the message, in JWK form.
   * @param {BufferSource} publicKey The ECDH public key of the subscription
   *  receiving the message, in raw form.
   * @param {BufferSource} authenticationSecret The 16-byte shared
   *  authentication secret of the subscription receiving the message.
   * @param {Object} headers The encryption headers from the push server.
   * @param {BufferSource} payload The encrypted message payload.
   * @returns {Uint8Array} The decrypted message data.
   */
  async decrypt(privateKey, publicKey, authenticationSecret, headers, payload) {
    if (!headers) {
      return null;
    }

    let encoding = headers.encoding;
    if (!headers.encoding) {
      throw new CryptoError(
        "Missing Content-Encoding header",
        BAD_ENCODING_HEADER
      );
    }

    let decoder;
    if (encoding == AES128GCM_ENCODING) {
      // aes128gcm includes the salt, record size, and sender public key in a
      // binary header preceding the ciphertext.
      let cryptoParams = getCryptoParamsFromPayload(new Uint8Array(payload));
      Glean.webPush.contentEncoding.aes128gcm.add();
      decoder = new aes128gcmDecoder(
        privateKey,
        publicKey,
        authenticationSecret,
        cryptoParams,
        cryptoParams.ciphertext
      );
    } else if (encoding == AESGCM_ENCODING) {
      // aesgcm includes the salt, record size, and sender public
      // key in the `Crypto-Key` and `Encryption` HTTP headers.
      let cryptoParams = getCryptoParamsFromHeaders(headers);
      Glean.webPush.contentEncoding.aesgcm.add();
      decoder = new aesgcmDecoder(
        privateKey,
        publicKey,
        authenticationSecret,
        cryptoParams,
        payload
      );
    }

    if (!decoder) {
      throw new CryptoError(
        "Unsupported Content-Encoding: " + encoding,
        BAD_ENCODING_HEADER
      );
    }

    return decoder.decode();
  },

  /**
   * Encrypts a payload suitable for using in a push message. The encryption
   * is always done with a record size of 4096 and no padding.
   *
   * @throws {CryptoError} if encryption fails.
   * @param {plaintext} Uint8Array The plaintext to encrypt.
   * @param {receiverPublicKey} Uint8Array The public key of the recipient
   *  of the message as a buffer.
   * @param {receiverAuthSecret} Uint8Array The auth secret of the of the
   *  message recipient as a buffer.
   * @param {options} Object Encryption options, used for tests.
   * @returns {ciphertext, encoding} The encrypted payload and encoding.
   */
  async encrypt(
    plaintext,
    receiverPublicKey,
    receiverAuthSecret,
    options = {}
  ) {
    const encoding = options.encoding || AES128GCM_ENCODING;
    // We only support one encoding type.
    if (encoding != AES128GCM_ENCODING) {
      throw new CryptoError(
        `Only ${AES128GCM_ENCODING} is supported`,
        BAD_ENCODING_HEADER
      );
    }
    // We typically use an ephemeral key for this message, but for testing
    // purposes we allow it to be specified.
    const senderKeyPair =
      options.senderKeyPair ||
      (await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveBits"]));
    // allowing a salt to be specified is useful for tests.
    const salt = options.salt || crypto.getRandomValues(new Uint8Array(16));
    const rs = options.rs === undefined ? 4096 : options.rs;

    const encoder = new aes128gcmEncoder(
      plaintext,
      receiverPublicKey,
      receiverAuthSecret,
      senderKeyPair,
      salt,
      rs
    );
    return encoder.encode();
  },
};

// A class for aes128gcm encryption - the only kind we support.
class aes128gcmEncoder {
  constructor(
    plaintext,
    receiverPublicKey,
    receiverAuthSecret,
    senderKeyPair,
    salt,
    rs
  ) {
    this.receiverPublicKey = receiverPublicKey;
    this.receiverAuthSecret = receiverAuthSecret;
    this.senderKeyPair = senderKeyPair;
    this.salt = salt;
    this.rs = rs;
    this.plaintext = plaintext;
  }

  async encode() {
    const sharedSecret = await this.computeSharedSecret(
      this.receiverPublicKey,
      this.senderKeyPair.privateKey
    );

    const rawSenderPublicKey = await crypto.subtle.exportKey(
      "raw",
      this.senderKeyPair.publicKey
    );
    const [gcmBits, nonce] = await this.deriveKeyAndNonce(
      sharedSecret,
      rawSenderPublicKey
    );

    const contentEncryptionKey = await crypto.subtle.importKey(
      "raw",
      gcmBits,
      "AES-GCM",
      false,
      ["encrypt"]
    );
    const payloadHeader = this.createHeader(rawSenderPublicKey);

    const ciphertextChunks = await this.encrypt(contentEncryptionKey, nonce);
    return {
      ciphertext: concatArray([payloadHeader, ...ciphertextChunks]),
      encoding: "aes128gcm",
    };
  }

  // Perform the actual encryption of the payload.
  async encrypt(key, nonce) {
    if (this.rs < 18) {
      // https://datatracker.ietf.org/doc/html/rfc8188#section-2.1
      throw new CryptoError("recordsize is too small", BAD_RS_PARAM);
    }

    let chunks;
    if (this.plaintext.byteLength === 0) {
      // Send an authentication tag for empty messages.
      chunks = [
        await crypto.subtle.encrypt(
          {
            name: "AES-GCM",
            iv: generateNonce(nonce, 0),
          },
          key,
          new Uint8Array([2])
        ),
      ];
    } else {
      // Use specified recordsize, though we burn 1 for padding and 16 byte
      // overhead.
      let inChunks = chunkArray(this.plaintext, this.rs - 1 - 16);
      chunks = await Promise.all(
        inChunks.map(async function (slice, index) {
          let isLast = index == inChunks.length - 1;
          let padding = new Uint8Array([isLast ? 2 : 1]);
          let input = concatArray([slice, padding]);
          return crypto.subtle.encrypt(
            {
              name: "AES-GCM",
              iv: generateNonce(nonce, index),
            },
            key,
            input
          );
        })
      );
    }
    return chunks;
  }

  // Note: this is a dupe of aes128gcmDecoder.deriveKeyAndNonce, but tricky
  // to rationalize without a larger refactor.
  async deriveKeyAndNonce(sharedSecret, senderPublicKey) {
    const authKdf = new hkdf(this.receiverAuthSecret, sharedSecret);
    const authInfo = concatArray([
      AES128GCM_AUTH_INFO,
      this.receiverPublicKey,
      senderPublicKey,
    ]);
    const prk = await authKdf.extract(authInfo, 32);
    const prkKdf = new hkdf(this.salt, prk);
    return Promise.all([
      prkKdf.extract(AES128GCM_KEY_INFO, 16),
      prkKdf.extract(AES128GCM_NONCE_INFO, 12),
    ]);
  }

  // Note: this duplicates some of Decoder.computeSharedSecret, but the key
  // management is slightly different.
  async computeSharedSecret(receiverPublicKey, senderPrivateKey) {
    const receiverPublicCryptoKey = await crypto.subtle.importKey(
      "raw",
      receiverPublicKey,
      ECDH_KEY,
      false,
      []
    );

    return crypto.subtle.deriveBits(
      { name: "ECDH", public: receiverPublicCryptoKey },
      senderPrivateKey,
      256
    );
  }

  // create aes128gcm's header.
  createHeader(key) {
    // layout is "salt|32-bit-int|8-bit-int|key"
    if (key.byteLength != 65) {
      // https://datatracker.ietf.org/doc/html/rfc8291/#section-4
      throw new CryptoError("Invalid key length for header", BAD_DH_PARAM);
    }
    // the 2 ints
    let ints = new Uint8Array(5);
    let intsv = new DataView(ints.buffer);
    intsv.setUint32(0, this.rs); // bigendian
    intsv.setUint8(4, key.byteLength);
    return concatArray([this.salt, ints, key]);
  }
}