summaryrefslogtreecommitdiffstats
path: root/dom/media/test/eme_standalone.js
blob: 202259b7fa3e7f20c48621212b4f0b7cbcfaeb15 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/* 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/. */

// This file offers standalone (no dependencies on other files) EME test
// helpers. The intention is that this file can be used to provide helpers
// while not coupling tests as tightly to `dom/media/test/manifest.js` or other
// files. This allows these helpers to be used in different tests across the
// codebase without imports becoming a mess.

// A helper class to assist in setting up EME on media.
//
// Usage
// 1. First configure the EME helper so it can have the information needed
//    to setup EME correctly. This is done by setting
//    - keySystem via `SetKeySystem`.
//    - initDataTypes via `SetInitDataTypes`.
//    - audioCapabilities and/or videoCapabilities via `SetAudioCapabilities`
//      and/or `SetVideoCapabilities`.
//    - keyIds and keys via `AddKeyIdAndKey`.
//    - onerror should be set to a function that will handle errors from the
//      helper. This function should take one argument, the error.
// 2. Use the helper to configure a media element via `ConfigureEme`.
// 3. One the promise from `ConfigureEme` has resolved the media element should
//    be configured and can be played. Errors that happen after this point are
//    reported via `onerror`.
var EmeHelper = class EmeHelper {
  // Members used to configure EME.
  _keySystem;
  _initDataTypes;
  _audioCapabilities = [];
  _videoCapabilities = [];

  // Map of keyIds to keys.
  _keyMap = new Map();

  // Will be called if an error occurs during event handling. Users of the
  // class should set a handler to be notified of errors.
  onerror;

  /**
   * Get the clearkey key system string.
   * @return The clearkey key system string.
   */
  static GetClearkeyKeySystemString() {
    return "org.w3.clearkey";
  }

  // Begin conversion helpers.

  /**
   * Helper to convert Uint8Array into base64 using base64url alphabet, without
   * padding.
   * @param uint8Array An array of bytes to convert to base64.
   * @return A base 64 encoded string
   */
  static Uint8ArrayToBase64(uint8Array) {
    return new TextDecoder()
      .decode(uint8Array)
      .replace(/\+/g, "-") // Replace chars for base64url.
      .replace(/\//g, "_")
      .replace(/=*$/, ""); // Remove padding for base64url.
  }

  /**
   * Helper to convert a hex string into base64 using base64url alphabet,
   * without padding.
   * @param hexString A string of hex characters.
   * @return A base 64 encoded string
   */
  static HexToBase64(hexString) {
    return btoa(
      hexString
        .match(/\w{2}/g) // Take chars two by two.
        // Map to characters.
        .map(hexByte => String.fromCharCode(parseInt(hexByte, 16)))
        .join("")
    )
      .replace(/\+/g, "-") // Replace chars for base64url.
      .replace(/\//g, "_")
      .replace(/=*$/, ""); // Remove padding for base64url.
  }

  /**
   * Helper to convert a base64 string (base64 or base64url) into a hex string.
   * @param base64String A base64 encoded string. This can be base64url.
   * @return A hex string (lower case);
   */
  static Base64ToHex(base64String) {
    let binString = atob(base64String.replace(/-/g, "+").replace(/_/g, "/"));
    let hexString = "";
    for (let i = 0; i < binString.length; i++) {
      // Covert to hex char. The "0" + and substr code are used to ensure we
      // always get 2 chars, even for outputs the would normally be only one.
      // E.g. for charcode 14 we'd get output 'e', and want to buffer that
      // to '0e'.
      hexString += ("0" + binString.charCodeAt(i).toString(16)).substr(-2);
    }
    // EMCA spec says that the num -> string conversion is lower case, so our
    // hex string should already be lower case.
    // https://tc39.es/ecma262/#sec-number.prototype.tostring
    return hexString;
  }

  // End conversion helpers.

  // Begin setters that setup the helper.
  // These should be used to configure the helper prior to calling
  // `ConfigureEme`.

  /**
   * Sets the key system that will be used by the EME helper.
   * @param keySystem The key system to use. Probably "org.w3.clearkey", which
   * can be fetched via `GetClearkeyKeySystemString`.
   */
  SetKeySystem(keySystem) {
    this._keySystem = keySystem;
  }

  /**
   * Sets the init data types that will be used by the EME helper. This is used
   * when calling `navigator.requestMediaKeySystemAccess`.
   * @param initDataTypes A list containing the init data types to be set by
   * the helper. This will usually be ["cenc"] or ["webm"], see
   * https://www.w3.org/TR/eme-initdata-registry/ for more info on what these
   * mean.
   */
  SetInitDataTypes(initDataTypes) {
    this._initDataTypes = initDataTypes;
  }

  /**
   * Sets the audio capabilities that will be used by the EME helper. These are
   * used when calling `navigator.requestMediaKeySystemAccess`.
   * See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess
   * for more info on these.
   * @param audioCapabilities A list containing audio capabilities. E.g.
   * [{ contentType: 'audio/webm; codecs="opus"' }].
   */
  SetAudioCapabilities(audioCapabilities) {
    this._audioCapabilities = audioCapabilities;
  }

  /**
   * Sets the video capabilities that will be used by the EME helper. These are
   * used when calling `navigator.requestMediaKeySystemAccess`.
   * See https://developer.mozilla.org/en-US/docs/Web/API/Navigator/requestMediaKeySystemAccess
   * for more info on these.
   * @param videoCapabilities A list containing video capabilities. E.g.
   * [{ contentType: 'video/webm; codecs="vp9"' }]
   */
  SetVideoCapabilities(videoCapabilities) {
    this._videoCapabilities = videoCapabilities;
  }

  /**
   * Adds a key id and key pair to the key map. These should both be hex
   * strings. E.g.
   * emeHelper.AddKeyIdAndKey(
   *   "2cdb0ed6119853e7850671c3e9906c3c",
   *   "808b9adac384de1e4f56140f4ad76194"
   * );
   * This function will store the keyId and key in lower case to ensure
   * consistency internally.
   * @param keyId The key id used to lookup the following key.
   * @param key The key associated with the earlier key id.
   */
  AddKeyIdAndKey(keyId, key) {
    this._keyMap.set(keyId.toLowerCase(), key.toLowerCase());
  }

  /**
   * Removes a key id and its associate key from the key map.
   * @param keyId The key id to remove.
   */
  RemoveKeyIdAndKey(keyId) {
    this._keyMap.delete(keyId);
  }

  // End setters that setup the helper.

  /**
   * Internal handler for `session.onmessage`. When calling this either do so
   * from inside an arrow function or using `bind` to ensure `this` points to
   * an EmeHelper instance (rather than a session).
   * @param messageEvent The message event passed to `session.onmessage`.
   */
  _SessionMessageHandler(messageEvent) {
    // This handles a session message and generates a clearkey license based
    // on the information in this._keyMap. This is done by populating the
    // appropriate keys on the session based on the keyIds surfaced in the
    // session message (a license request).
    let request = JSON.parse(new TextDecoder().decode(messageEvent.message));

    let keys = [];
    for (const keyId of request.kids) {
      let id64 = keyId;
      let idHex = EmeHelper.Base64ToHex(keyId);
      let key = this._keyMap.get(idHex);

      if (key) {
        keys.push({
          kty: "oct",
          kid: id64,
          k: EmeHelper.HexToBase64(key),
        });
      }
    }

    let license = new TextEncoder().encode(
      JSON.stringify({
        keys,
        type: request.type || "temporary",
      })
    );

    let session = messageEvent.target;
    session.update(license).catch(error => {
      if (this.onerror) {
        this.onerror(error);
      } else {
        console.log(
          `EmeHelper got an error, but no onerror handler was registered! Logging to console, error: ${error}`
        );
      }
    });
  }

  /**
   * Configures EME on a media element using the parameters already set on the
   * instance of EmeHelper.
   * @param htmlMediaElement - A media element to configure EME on.
   * @return A promise that will be resolved once the media element is
   * configured. This promise will be rejected with an error if configuration
   * fails.
   */
  async ConfigureEme(htmlMediaElement) {
    if (!this._keySystem) {
      throw new Error("EmeHelper needs _keySystem to configure media");
    }
    if (!this._initDataTypes) {
      throw new Error("EmeHelper needs _initDataTypes to configure media");
    }
    if (!this._audioCapabilities.length && !this._videoCapabilities.length) {
      throw new Error(
        "EmeHelper needs _audioCapabilities or _videoCapabilities to configure media"
      );
    }
    const options = [
      {
        initDataTypes: this._initDataTypes,
        audioCapabilities: this._audioCapabilities,
        videoCapabilities: this._videoCapabilities,
      },
    ];
    let access = await window.navigator.requestMediaKeySystemAccess(
      this._keySystem,
      options
    );
    let mediaKeys = await access.createMediaKeys();
    await htmlMediaElement.setMediaKeys(mediaKeys);

    htmlMediaElement.onencrypted = async encryptedEvent => {
      let session = htmlMediaElement.mediaKeys.createSession();
      // Use arrow notation so that `this` is the EmeHelper in the message
      // handler. If we do `session.onmessage = this._SessionMessageHandler`
      // then `this` will be the session in the callback.
      session.onmessage = messageEvent =>
        this._SessionMessageHandler(messageEvent);
      try {
        await session.generateRequest(
          encryptedEvent.initDataType,
          encryptedEvent.initData
        );
      } catch (error) {
        if (this.onerror) {
          this.onerror(error);
        } else {
          console.log(
            `EmeHelper got an error, but no onerror handler was registered! Logging to console, error: ${error}`
          );
        }
      }
    };
  }
};