Source: lib/media/clearkey_webcrypto_decryptor.js

/*! @license
 * Shaka Player
 * Copyright 2016 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

goog.provide('shaka.media.ClearKeyWebCryptoDecryptor');

goog.require('goog.asserts');
goog.require('shaka.device.DeviceFactory');
goog.require('shaka.drm.DrmUtils');
goog.require('shaka.log');
goog.require('shaka.util.BufferUtils');
goog.require('shaka.util.IDestroyable');
goog.require('shaka.util.Mp4BoxParsers');
goog.require('shaka.util.Mp4Parser');
goog.require('shaka.util.Uint8ArrayUtils');


/**
 * @implements {shaka.util.IDestroyable}
 */
shaka.media.ClearKeyWebCryptoDecryptor = class {
  constructor() {
    /** @private {?shaka.extern.DrmInfo} */
    this.drmInfo_ = null;

    /** @private {!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>} */
    this.keyMap_ = new Map();

    /** @private {?Promise<!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>>} */
    this.keyMapPromise_ = null;

    /** @private {?Uint8Array} */
    this.lastInit_ = null;
  }

  /**
   * @param {!BufferSource} data
   * @param {boolean} isInit
   * @param {!shaka.extern.DrmInfo} drmInfo
   * @return {!Promise<!Uint8Array>}
   */
  async decrypt(data, isInit, drmInfo) {
    const uint8ArrayData = shaka.util.BufferUtils.toUint8(data);

    if (isInit) {
      this.lastInit_ = uint8ArrayData;
      return this.stripEncryptionFromInit_(uint8ArrayData);
    }

    if (!this.lastInit_) {
      return uint8ArrayData;
    }

    if (this.drmInfo_ !== drmInfo) {
      this.drmInfo_ = drmInfo;
      this.keyMapPromise_ = this.buildKeyMap_(this.drmInfo_);
      this.keyMap_ = await this.keyMapPromise_;
      this.keyMapPromise_ = null;
    }

    return this.decryptSegment_(uint8ArrayData, this.lastInit_);
  }

  /**
   * @override
   */
  async destroy() {
    if (this.keyMapPromise_) {
      try {
        await this.keyMapPromise_;
      } catch (e) {
        // Ignore errors.
      }
    }

    this.drmInfo_ = null;
    this.keyMap_.clear();
    this.keyMapPromise_ = null;
    this.lastInit_ = null;
  }

  /**
   * Extracts a Map<keyIdHex, {cbc, ctr}> from a ClearKey DrmInfo whose
   * licenseServerUri is a data:application/json;base64,<JWK-set> URI.
   *
   * @param {!shaka.extern.DrmInfo} drmInfo
   * @return {!Promise<!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>>}
   * @private
   */
  async buildKeyMap_(drmInfo) {
    /** @type {!Map<string, {cbc:!CryptoKey, ctr:!CryptoKey}>} */
    const keyMap = new Map();

    if (!drmInfo.clearKeys) {
      return keyMap;
    }

    const results = await Promise.all(
        [...drmInfo.clearKeys.entries()].map(([kid, key]) => {
          const keyBytes = shaka.util.Uint8ArrayUtils.fromBase64(key);
          const kidBytes = shaka.util.Uint8ArrayUtils.fromBase64(kid);
          return Promise.all([
            crypto.subtle.importKey(
                'raw', keyBytes, {name: 'AES-CBC'},
                /* extractable= */ false, ['decrypt', 'encrypt']),
            crypto.subtle.importKey(
                'raw', keyBytes, {name: 'AES-CTR'},
                /* extractable= */ false, ['decrypt']),
          ]).then(([cbc, ctr]) => ({
            kidHex: shaka.util.Uint8ArrayUtils.toHex(kidBytes), cbc, ctr,
          }));
        }),
    );

    for (const {kidHex, cbc, ctr} of results) {
      keyMap.set(kidHex, {cbc, ctr});
    }
    return keyMap;
  }

  /**
   * Full segment decryption pipeline.
   *
   * @param {!Uint8Array} segmentData
   * @param {!Uint8Array} initData
   * @return {!Promise<!Uint8Array>}
   * @private
   */
  async decryptSegment_(segmentData, initData) {
    const trackInfos = this.parseInitSegment_(initData);
    const segInfo = this.parseMediaSegment_(segmentData, trackInfos);

    const fragmentPromises = segInfo.fragments.map((fragment) => {
      const initInfo = trackInfos.get(fragment.trackId) ||
          trackInfos.values().next().value;
      return this.decryptFragment_(fragment, segmentData, initInfo);
    });

    const decryptedFragments = await Promise.all(fragmentPromises);
    const outputChunks = [
      segmentData.slice(0, segInfo.firstFragmentOffset),
      ...decryptedFragments,
    ];
    return shaka.util.Uint8ArrayUtils.concat(...outputChunks);
  }

  /**
   * Rewrites the init segment to strip encryption signalling:
   * encv/enca -> original codec fourcc (from frma), sinf -> free.
   * MSE will then accept the plain decrypted samples without complaint.
   *
   * @param {!Uint8Array} initData
   * @return {!Uint8Array}
   * @private
   */
  stripEncryptionFromInit_(initData) {
    const initSegment = shaka.util.BufferUtils.toUint8(initData).slice();
    const view = shaka.util.BufferUtils.toDataView(initSegment);

    const modifications = [];
    let currentEncBoxStart = -1;

    const freeBox = (box) => {
      modifications.push(() => {
        view.setUint32(box.start + 4,
            shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_,
            /* littleEndian= */ false);
        initSegment.fill(0, box.start + 8, box.start + box.size);
      });
    };

    new shaka.util.Mp4Parser()
        .boxes([
          'moov',
          'trak',
          'mdia',
          'minf',
          'stbl',
        ], shaka.util.Mp4Parser.children)
        .fullBox('stsd', shaka.util.Mp4Parser.sampleDescription)
        .box('encv', (box) => {
          currentEncBoxStart = box.start;
          shaka.util.Mp4Parser.visualSampleEntry(box);
        })
        .box('enca', (box) => {
          currentEncBoxStart = box.start;
          shaka.util.Mp4Parser.audioSampleEntry(box);
        })
        .box('sinf', (box) => {
          freeBox(box);
          shaka.util.Mp4Parser.children(box);
        })
        .box('frma', (box) => {
          const {codec} = shaka.util.Mp4BoxParsers.parseFRMA(box.reader);
          const targetEncStart = currentEncBoxStart;
          if (targetEncStart !== -1 && codec) {
            modifications.push(() => {
              for (let i = 0; i < 4; ++i) {
                view.setUint8(targetEncStart + 4 + i, codec.charCodeAt(i));
              }
            });
          }
        })
        .fullBox('sgpd', freeBox)
        .box('pssh', freeBox)
        .parse(initSegment, /* partialOkay= */ true);

    for (const mod of modifications) {
      mod();
    }

    return initSegment;
  }

  /**
   * @param {!Uint8Array} initData
   * @return {!Map<number, !shaka.media.ClearKeyWebCryptoDecryptor.InitInfo>}
   * @private
   */
  parseInitSegment_(initData) {
    const trackInfos = new Map();
    let currentTrackId = 0;

    const Mp4Parser = shaka.util.Mp4Parser;
    const Mp4BoxParsers = shaka.util.Mp4BoxParsers;

    new Mp4Parser()
        .boxes([
          'moov',
          'mdia',
          'minf',
          'stbl',
        ], Mp4Parser.children)
        .box('trak', (box) => {
          currentTrackId = 0;
          Mp4Parser.children(box);
        })
        .fullBox('tkhd', (box) => {
          goog.asserts.assert(
              box.version != null,
              'TKHD is a full box and should have a valid version.');
          const parsed = Mp4BoxParsers.parseTKHD(box.reader, box.version);
          currentTrackId = parsed.trackId;
          trackInfos.getOrInsert(currentTrackId, {
            defaultKID: '',
            encryptionScheme: 'cenc',
            defaultIVSize: 8,
            defaultConstantIV: null,
            defaultCryptByteBlock: 0,
            defaultSkipByteBlock: 0,
          });
        })
        .fullBox('stsd', Mp4Parser.sampleDescription)
        .box('encv', Mp4Parser.visualSampleEntry)
        .box('enca', Mp4Parser.audioSampleEntry)
        .box('sinf', Mp4Parser.children)
        .fullBox('schm', (box) => {
          const parsed = Mp4BoxParsers.parseSCHM(box.reader);
          const info = trackInfos.get(currentTrackId);
          if (info) {
            info.encryptionScheme = parsed.encryptionScheme.toLowerCase();
          }
        })
        .box('schi', Mp4Parser.children)
        .fullBox('tenc', (box) => {
          goog.asserts.assert(
              box.version != null,
              'TENC is a full box and should have a valid version.');
          const parsed = Mp4BoxParsers.parseTENC(box.reader, box.version);
          const info = trackInfos.get(currentTrackId);
          if (info) {
            info.defaultKID = parsed.defaultKID;
            info.defaultIVSize = parsed.defaultPerSampleIVSize;
            info.defaultConstantIV = parsed.defaultConstantIV;
            info.defaultCryptByteBlock = parsed.defaultCryptByteBlock;
            info.defaultSkipByteBlock = parsed.defaultSkipByteBlock;
          }
        })
        .parse(initData, /* partialOkay= */ true);

    return trackInfos;
  }

  /**
   * @param {!Uint8Array} segData
   * @param {!Map<number,
   *         !shaka.media.ClearKeyWebCryptoDecryptor.InitInfo>} trackInfos
   * @return {!shaka.media.ClearKeyWebCryptoDecryptor.SegmentParseResult}
   * @private
   */
  parseMediaSegment_(segData, trackInfos) {
    const Mp4Parser = shaka.util.Mp4Parser;
    const Mp4BoxParsers = shaka.util.Mp4BoxParsers;
    const fragments = [];
    let firstFragmentOffset = 0;

    /** @type {?shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo} */
    let currentFragment = null;

    const markFree = (box) => {
      if (currentFragment) {
        currentFragment.boxesToFree.push({start: box.start, size: box.size});
      }
    };

    new Mp4Parser()
        .box('moof', (box) => {
          if (!fragments.length) {
            firstFragmentOffset = box.start;
          }
          currentFragment = {
            moofStart: box.start,
            moofSize: box.size,
            mdatStart: -1,
            mdatSize: -1,
            sencInfo: null,
            tfhdDefaultSize: 0,
            trackId: 0,
            trunSamples: [],
            boxesToFree: [],
          };
          Mp4Parser.children(box);
        })
        .box('traf', Mp4Parser.children)
        .fullBox('tfhd', (box) => {
          if (!currentFragment) {
            return;
          }
          goog.asserts.assert(
              box.flags != null,
              'TFHD is a full box and should have valid flags.');
          const parsed = Mp4BoxParsers.parseTFHD(box.reader, box.flags);
          currentFragment.trackId = parsed.trackId;
          currentFragment.tfhdDefaultSize = parsed.defaultSampleSize || 0;
        })
        .fullBox('trun', (box) => {
          if (!currentFragment) {
            return;
          }
          goog.asserts.assert(
              box.version != null && box.flags != null,
              'TRUN is a full box and should have a valid version & flags.');
          const parsed = Mp4BoxParsers.parseTRUN(
              box.reader, box.version, box.flags);
          for (const sample of parsed.sampleData) {
            currentFragment.trunSamples.push({
              size: sample.sampleSize || currentFragment.tfhdDefaultSize,
            });
          }
        })
        .fullBox('senc', (box) => {
          if (!currentFragment) {
            return;
          }
          goog.asserts.assert(
              box.flags != null,
              'SENC is a full box and should have valid flags.');
          const info = trackInfos.get(currentFragment.trackId);
          if (info) {
            currentFragment.sencInfo = shaka.util.Mp4BoxParsers.parseSENC(
                box.reader, box.flags, info.defaultIVSize,
                info.defaultConstantIV);
          }
          markFree(box);
        })
        .fullBoxes([
          'saiz',
          'saio',
          'sgpd',
          'sbgp',
        ], markFree)
        .box('pssh', markFree)
        .box('mdat', (mdatBox) => {
          if (currentFragment) {
            currentFragment.mdatStart = mdatBox.start;
            currentFragment.mdatSize = mdatBox.size;
            fragments.push(currentFragment);
            currentFragment = null;
          }
        })
        .parse(segData);

    return {fragments, firstFragmentOffset};
  }

  /**
   * @param {!shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo} fragment
   * @param {!Uint8Array} segData
   * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo
   * @return {!Promise<!Uint8Array>}
   * @private
   */
  async decryptFragment_(fragment, segData, initInfo) {
    const keyId = initInfo.defaultKID;
    const keyEntry = this.keyMap_.get(keyId);

    if (!keyEntry) {
      shaka.log.warning('[ClearKeyDecryptor] No key found for KID:', keyId);
      return segData.slice(
          fragment.moofStart, fragment.mdatStart + fragment.mdatSize);
    }

    const scheme = initInfo.encryptionScheme;
    let sencInfo = fragment.sencInfo;
    if (sencInfo && initInfo.defaultIVSize !== 8) {
      const moofSlice = segData.subarray(
          fragment.moofStart, fragment.moofStart + fragment.moofSize);
      const Mp4Parser = shaka.util.Mp4Parser;
      new Mp4Parser()
          .box('moof', Mp4Parser.children)
          .box('traf', Mp4Parser.children)
          .fullBox('senc', (box) => {
            goog.asserts.assert(
                box.flags != null,
                'SENC is a full box and should have valid flags.');
            sencInfo = shaka.util.Mp4BoxParsers.parseSENC(
                box.reader, box.flags, initInfo.defaultIVSize,
                initInfo.defaultConstantIV);
          })
          .parse(moofSlice, /* partialOkay= */ false);
    }

    const mdatPayloadStart = fragment.mdatStart + 8;
    const mdatPayload = segData.subarray(
        mdatPayloadStart, mdatPayloadStart + fragment.mdatSize - 8);

    const decryptedMdat = await this.decryptMdat_(
        mdatPayload, fragment.trunSamples.map((s) => s.size),
        sencInfo, initInfo, keyEntry, scheme);

    const moof = segData.slice(
        fragment.moofStart, fragment.moofStart + fragment.moofSize);
    const moofView = shaka.util.BufferUtils.toDataView(moof);

    if (fragment.boxesToFree) {
      for (const boxToFree of fragment.boxesToFree) {
        const relStart = boxToFree.start - fragment.moofStart;
        if (relStart >= 0 && (relStart + boxToFree.size) <= moof.byteLength) {
          moofView.setUint32(relStart + 4,
              shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_,
              /* littleEndian= */ false);
          // Zero payload
          moof.fill(0, relStart + 8, relStart + boxToFree.size);
        }
      }
    }

    const newMdatSize = 8 + decryptedMdat.byteLength;
    const newMdat = new Uint8Array(newMdatSize);
    shaka.util.BufferUtils.toDataView(newMdat).setUint32(
        0, newMdatSize, /* LE= */ false);
    newMdat.set([0x6d, 0x64, 0x61, 0x74], 4); // 'mdat'
    newMdat.set(decryptedMdat, 8);

    return shaka.util.Uint8ArrayUtils.concat(moof, newMdat);
  }

  /**
   * Decrypt the raw mdat payload sample by sample.
   *
   * @param {!Uint8Array} mdatPayload
   * @param {!Array<number>} sampleSizes
   * @param {?shaka.media.ClearKeyWebCryptoDecryptor.SencInfo} senc
   * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo
   * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry
   * @param {string} scheme  'cenc' | 'cbcs'
   * @return {!Promise<!Uint8Array>}
   * @private
   */
  async decryptMdat_(
      mdatPayload, sampleSizes, senc, initInfo, keyEntry, scheme) {
    const out = new Uint8Array(mdatPayload.byteLength);

    // Build per-sample decrypt promises; samples are independent of each
    // other so we can run them all in parallel with Promise.all.
    let sampleOffset = 0;
    const samplePromises = sampleSizes.map((sampleSize, i) => {
      const offset = sampleOffset;
      sampleOffset += sampleSize;
      const sampleData = mdatPayload.subarray(offset, offset + sampleSize);

      if (senc && senc.samples[i]) {
        const sencSample = senc.samples[i];

        // Zero-pad 8-byte IVs into the high bytes of a 16-byte block.
        const ivLen = initInfo.defaultIVSize === 16 ? 16 : 8;
        const iv = new Uint8Array(16);
        iv.set(sencSample.iv.slice(0, ivLen), 0);

        if (scheme === 'cenc') {
          return this.decryptSampleCenc_(
              sampleData, iv, sencSample.subsamples, keyEntry)
              .then((dec) => ({offset, dec}));
        }
        // cbcs: use constant IV if signalled in tenc, else per-sample IV.
        const cbcsIV = initInfo.defaultConstantIV || iv;
        return this.decryptSampleCbcs_(
            sampleData, cbcsIV, sencSample.subsamples, initInfo, keyEntry)
            .then((dec) => ({offset, dec}));
      }

      if (scheme === 'cbcs' && initInfo.defaultConstantIV) {
        // No per-sample senc entry — whole sample uses constant IV.
        return this.decryptSampleCbcs_(
            sampleData, initInfo.defaultConstantIV, null, initInfo, keyEntry)
            .then((dec) => ({offset, dec}));
      }

      // Clear sample — pass through unchanged.
      return Promise.resolve({offset, dec: sampleData});
    });

    const results = await Promise.all(samplePromises);
    for (const {offset, dec} of results) {
      out.set(dec, offset);
    }
    return out;
  }

  /**
   * Decrypt one sample under CENC (AES-128-CTR, full or subsample).
   *
   * The IV is the initial 128-bit counter block (big-endian). For
   * subsample encryption the counter is NOT reset between subsample
   * regions — it advances by the number of whole 16-byte blocks
   * consumed in prior regions.
   *
   * @param {!Uint8Array} sampleData
   * @param {!Uint8Array} iv  16 bytes
   * @param {?Array<{clearBytes: number, encryptedBytes: number}>} subsamples
   * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry
   * @return {!Promise<!Uint8Array>}
   * @private
   */
  async decryptSampleCenc_(sampleData, iv, subsamples, keyEntry) {
    if (!subsamples || !subsamples.length) {
      return shaka.util.BufferUtils.toUint8(await crypto.subtle.decrypt(
          {name: 'AES-CTR', counter: iv, length: 64},
          keyEntry.ctr, sampleData));
    }

    // Pre-compute per-subsample counters (counter state is cumulative),
    // then decrypt all encrypted ranges in parallel.
    const out = new Uint8Array(sampleData.byteLength);
    let pos = 0;
    let totalEncryptedBlocks = 0;

    const decryptJobs = subsamples.map((sub) => {
      // Copy clear bytes synchronously; record their range.
      const clearStart = pos;
      pos += sub.clearBytes;

      if (sub.encryptedBytes === 0) {
        return Promise.resolve({
          clearStart,
          clearLen: sub.clearBytes,
          encStart: pos,
          encLen: 0,
          decrypted: null,
        });
      }

      // Snapshot counter for this subsample before advancing.
      const counter = iv.slice();
      this.addCounterOffset_(counter, totalEncryptedBlocks);

      const encStart = pos;
      pos += sub.encryptedBytes;
      totalEncryptedBlocks += Math.ceil(sub.encryptedBytes / 16);

      const encData = sampleData.subarray(
          encStart, encStart + sub.encryptedBytes);
      return crypto.subtle.decrypt(
          {name: 'AES-CTR', counter, length: 64},
          keyEntry.ctr, encData)
          .then((buf) => ({
            clearStart,
            clearLen: sub.clearBytes,
            encStart,
            encLen: sub.encryptedBytes,
            decrypted: shaka.util.BufferUtils.toUint8(buf),
          }));
    });

    const results = await Promise.all(decryptJobs);
    for (const r of results) {
      out.set(
          sampleData.subarray(r.clearStart, r.clearStart + r.clearLen),
          r.clearStart);
      if (r.decrypted) {
        out.set(r.decrypted, r.encStart);
      }
    }
    return out;
  }

  /**
   * Decrypt one sample under CBCS (AES-128-CBC pattern encryption).
   *
   * Within each encrypted range, blocks alternate between encrypted
   * (cryptByteBlock x 16 bytes) and clear (skipByteBlock x 16 bytes).
   * Partial trailing blocks are always clear.
   *
   * @param {!Uint8Array} sampleData
   * @param {!Uint8Array} iv  16 bytes
   * @param {?Array<{clearBytes: number, encryptedBytes: number}>} subsamples
   * @param {!shaka.media.ClearKeyWebCryptoDecryptor.InitInfo} initInfo
   * @param {{cbc:!CryptoKey, ctr:!CryptoKey}} keyEntry
   * @return {!Promise<!Uint8Array>}
   * @private
   */
  async decryptSampleCbcs_(
      sampleData, iv, subsamples, initInfo, keyEntry) {
    let cryptBlocks = initInfo.defaultCryptByteBlock;
    let skipBlocks = initInfo.defaultSkipByteBlock;

    // In cbcs, a 0:0 pattern means 100% encrypted, equivalent to 1:0.
    if (cryptBlocks === 0) {
      cryptBlocks = 1;
      skipBlocks = 0;
    }

    const out = new Uint8Array(sampleData.byteLength);
    const jobs = [];

    // State variable to maintain CBC IV chaining across the entire sample.
    let currentIv = iv;

    const processRange = (rangeStart, rangeLen) => {
      let offset = rangeStart;
      const end = rangeStart + rangeLen;

      while (offset < end) {
        const remaining = end - offset;
        const encLen = Math.min(cryptBlocks * 16, remaining);

        if (encLen >= 16) {
          const alignedLen = Math.floor(encLen / 16) * 16;
          const encStart = offset;
          // Partial trailing block of the crypt group is always clear.
          const partialLen = encLen - alignedLen;
          const clearAfterStart = offset + alignedLen;

          // Capture the correct IV for this specific asynchronous block.
          const chunkIv = currentIv;

          jobs.push(this.rawCBCDecrypt_(
              sampleData.subarray(encStart, encStart + alignedLen),
              chunkIv, keyEntry.cbc)
              .then((dec) => {
                out.set(dec, encStart);
                if (partialLen > 0) {
                  out.set(
                      sampleData.subarray(
                          clearAfterStart,
                          clearAfterStart + partialLen),
                      clearAfterStart);
                }
              }),
          );

          // Update the IV for the next block:
          // CBC chaining requires the last 16 bytes of the current ciphertext.
          currentIv = sampleData.slice(
              encStart + alignedLen - 16, encStart + alignedLen);

          offset += alignedLen + partialLen;
        } else {
          // Less than one full block remaining in crypt group — clear.
          out.set(sampleData.subarray(offset, offset + encLen), offset);
          offset += encLen;
        }

        // Skip group — always copied clear.
        const skipLen = Math.min(skipBlocks * 16, end - offset);
        if (skipLen > 0) {
          out.set(sampleData.subarray(offset, offset + skipLen), offset);
          offset += skipLen;
        }
      }
    };

    // Process subsamples synchronously to ensure correct IV chaining order
    // before resolving promises.
    if (!subsamples || !subsamples.length) {
      processRange(0, sampleData.byteLength);
    } else {
      let pos = 0;
      for (const sub of subsamples) {
        const clearStart = pos;
        pos += sub.clearBytes;
        out.set(
            sampleData.subarray(clearStart, clearStart + sub.clearBytes),
            clearStart);

        if (sub.encryptedBytes > 0) {
          const encStart = pos;
          pos += sub.encryptedBytes;
          processRange(encStart, sub.encryptedBytes);
        }
      }
    }

    // Await all decryption jobs concurrently.
    await Promise.all(jobs);
    return out;
  }

  /**
   * AES-128-CBC decryption without PKCS7 unpadding.
   *
   * WebCrypto AES-CBC always applies PKCS7. CBCS stream data is NOT
   * padded — partial final blocks are clear rather than padded. We work
   * around this by appending a synthetic full PKCS7 padding block
   * (16 x 0x10) so WebCrypto's automatic unpadding removes only that
   * dummy block, leaving our real data (always 16-byte aligned here)
   * intact.
   *
   * @param {!Uint8Array} data  Must be 16-byte aligned
   * @param {!Uint8Array} iv    16 bytes
   * @param {!CryptoKey} cbcKey AES-CBC key
   * @return {!Promise<!Uint8Array>}  Exactly data.byteLength bytes
   * @private
   */
  async rawCBCDecrypt_(data, iv, cbcKey) {
    if (!data || !data.byteLength || data.byteLength % 16 !== 0) {
      return new Uint8Array(0);
    }

    const numBlocks = data.byteLength / 16;
    const lastCiphertextBlock =
        data.subarray((numBlocks - 1) * 16, numBlocks * 16);

    // Mathematical requirement to force a PKCS#7 padding block
    const paddingBlock = lastCiphertextBlock.map((b) => 0x10 ^ b);

    // Artificial padding encryption using zero IV (AES-ECB simulation)
    const zeroIv = new Uint8Array(16);
    const encryptedPadding = await crypto.subtle.encrypt(
        {name: 'AES-CBC', iv: zeroIv},
        cbcKey,
        paddingBlock,
    );

    // Take only the first 16 bytes
    const extraCiphertextBlock = new Uint8Array(encryptedPadding, 0, 16);

    // Concatenate synthetic block at the end
    const extendedCiphertext = new Uint8Array(data.byteLength + 16);
    extendedCiphertext.set(data, 0);
    extendedCiphertext.set(extraCiphertextBlock, data.byteLength);

    // WebCrypto will strip the extra block cleanly
    const decrypted = await crypto.subtle.decrypt(
        {name: 'AES-CBC', iv: iv},
        cbcKey,
        extendedCiphertext,
    );

    return shaka.util.BufferUtils.toUint8(decrypted);
  }

  /**
   * Add a block-count offset to a 16-byte big-endian AES-CTR counter.
   * Only the lower 8 bytes (bytes 8-15) act as the incrementing counter
   * per the CENC spec (section 9.1); the upper 8 bytes are the IV nonce.
   *
   * @param {!Uint8Array} counter  modified in place, 16 bytes
   * @param {number} offset        number of 16-byte blocks to add
   * @private
   */
  addCounterOffset_(counter, offset) {
    let carry = offset;
    for (let i = 15; i >= 8 && carry > 0; i--) {
      carry += counter[i];
      counter[i] = carry & 0xff;
      carry >>>= 8;
    }
  }

  /**
   * Returns true if the ClearKey WebCrypto path should be used.
   *
   * @param {?shaka.extern.DrmInfo} drmInfo
   * @return {boolean}
   */
  static shouldUse(drmInfo) {
    if (!drmInfo) {
      return false;
    }
    if (!window.crypto?.subtle) {
      return false;
    }
    if (!shaka.drm.DrmUtils.isClearKeySystem(drmInfo.keySystem)) {
      return false;
    }
    return !shaka.device.DeviceFactory.getDevice().hasWorkingClearKeySupport();
  }
};

/**
 * Box type for "free".
 *
 * @const {number}
 * @private
 */
shaka.media.ClearKeyWebCryptoDecryptor.BOX_TYPE_FREE_ = 0x66726565;


/**
 * @typedef {{
 *   defaultKID: string,
 *   encryptionScheme: string,
 *   defaultIVSize: number,
 *   defaultConstantIV: ?Uint8Array,
 *   defaultCryptByteBlock: number,
 *   defaultSkipByteBlock: number,
 * }}
 */
shaka.media.ClearKeyWebCryptoDecryptor.InitInfo;


/**
 * @typedef {{
 *   moofStart: number,
 *   moofSize: number,
 *   mdatStart: number,
 *   mdatSize: number,
 *   sencInfo: ?shaka.media.ClearKeyWebCryptoDecryptor.SencInfo,
 *   tfhdDefaultSize: number,
 *   trackId: number,
 *   trunSamples: !Array<{size: number}>,
 *   boxesToFree: !Array<{start: number, size: number}>,
 * }}
 */
shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo;


/**
 * @typedef {{
 *   samples: !Array<{
 *     iv: !Uint8Array,
 *     subsamples:
 *         ?Array<{clearBytes: number, encryptedBytes: number}>
 *   }>
 * }}
 */
shaka.media.ClearKeyWebCryptoDecryptor.SencInfo;


/**
 * @typedef {{
 *   fragments:
 *       !Array<!shaka.media.ClearKeyWebCryptoDecryptor.FragmentInfo>,
 *   firstFragmentOffset: number,
 * }}
 */
shaka.media.ClearKeyWebCryptoDecryptor.SegmentParseResult;