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"use strict";
6
7const { XPCOMUtils } = ChromeUtils.import(
8  "resource://gre/modules/XPCOMUtils.jsm"
9);
10
11XPCOMUtils.defineLazyGlobalGetters(this, ["crypto"]);
12
13const EXPORTED_SYMBOLS = ["jwcrypto"];
14
15const ECDH_PARAMS = {
16  name: "ECDH",
17  namedCurve: "P-256",
18};
19const AES_PARAMS = {
20  name: "AES-GCM",
21  length: 256,
22};
23const AES_TAG_LEN = 128;
24const AES_GCM_IV_SIZE = 12;
25const UTF8_ENCODER = new TextEncoder();
26const UTF8_DECODER = new TextDecoder();
27
28class JWCrypto {
29  /**
30   * Encrypts the given data into a JWE using AES-256-GCM content encryption.
31   *
32   * This function implements a very small subset of the JWE encryption standard
33   * from https://tools.ietf.org/html/rfc7516. The only supported content encryption
34   * algorithm is enc="A256GCM" [1] and the only supported key encryption algorithm
35   * is alg="ECDH-ES" [2].
36   *
37   * @param {Object} key Peer Public JWK.
38   * @param {ArrayBuffer} data
39   *
40   * [1] https://tools.ietf.org/html/rfc7518#section-5.3
41   * [2] https://tools.ietf.org/html/rfc7518#section-4.6
42   *
43   * @returns {Promise<String>}
44   */
45  async generateJWE(key, data) {
46    // Generate an ephemeral key to use just for this encryption.
47    // The public component gets embedded in the JWE header.
48    const epk = await crypto.subtle.generateKey(ECDH_PARAMS, true, [
49      "deriveKey",
50    ]);
51    const ownPublicJWK = await crypto.subtle.exportKey("jwk", epk.publicKey);
52    // Remove properties added by our WebCrypto implementation but that aren't typically
53    // used with JWE in the wild. This saves space in the resulting JWE, and makes it easier
54    // to re-import the resulting JWK.
55    delete ownPublicJWK.key_ops;
56    delete ownPublicJWK.ext;
57    let header = { alg: "ECDH-ES", enc: "A256GCM", epk: ownPublicJWK };
58    // Import the peer's public key.
59    const peerPublicKey = await crypto.subtle.importKey(
60      "jwk",
61      key,
62      ECDH_PARAMS,
63      false,
64      ["deriveKey"]
65    );
66    if (key.hasOwnProperty("kid")) {
67      header.kid = key.kid;
68    }
69    // Do ECDH agreement to get the content encryption key.
70    const contentKey = await deriveECDHSharedAESKey(
71      epk.privateKey,
72      peerPublicKey,
73      ["encrypt"]
74    );
75    // Encrypt with AES-GCM using the generated key.
76    // Note that the IV is generated randomly, which *in general* is not safe to do with AES-GCM because
77    // it's too short to guarantee uniqueness. But we know that the AES-GCM key itself is unique and will
78    // only be used for this single encryption, making a random IV safe to use for this particular use-case.
79    let iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_SIZE));
80    // Yes, additionalData is the byte representation of the base64 representation of the stringified header.
81    const additionalData = UTF8_ENCODER.encode(
82      ChromeUtils.base64URLEncode(UTF8_ENCODER.encode(JSON.stringify(header)), {
83        pad: false,
84      })
85    );
86    const encrypted = await crypto.subtle.encrypt(
87      {
88        name: "AES-GCM",
89        iv,
90        additionalData,
91        tagLength: AES_TAG_LEN,
92      },
93      contentKey,
94      data
95    );
96    // JWE needs the authentication tag as a separate string.
97    const tagIdx = encrypted.byteLength - ((AES_TAG_LEN + 7) >> 3);
98    let ciphertext = encrypted.slice(0, tagIdx);
99    let tag = encrypted.slice(tagIdx);
100    // JWE serialization in compact format.
101    header = UTF8_ENCODER.encode(JSON.stringify(header));
102    header = ChromeUtils.base64URLEncode(header, { pad: false });
103    tag = ChromeUtils.base64URLEncode(tag, { pad: false });
104    ciphertext = ChromeUtils.base64URLEncode(ciphertext, { pad: false });
105    iv = ChromeUtils.base64URLEncode(iv, { pad: false });
106    return `${header}..${iv}.${ciphertext}.${tag}`; // No CEK
107  }
108
109  /**
110   * Decrypts the given JWE using AES-256-GCM content encryption into a byte array.
111   * This function does the opposite of `JWCrypto.generateJWE`.
112   * The only supported content encryption algorithm is enc="A256GCM" [1]
113   * and the only supported key encryption algorithm is alg="ECDH-ES" [2].
114   *
115   * @param {"ECDH-ES"} algorithm
116   * @param {CryptoKey} key Local private key
117   *
118   * [1] https://tools.ietf.org/html/rfc7518#section-5.3
119   * [2] https://tools.ietf.org/html/rfc7518#section-4.6
120   *
121   * @returns {Promise<Uint8Array>}
122   */
123  async decryptJWE(jwe, key) {
124    let [header, cek, iv, ciphertext, authTag] = jwe.split(".");
125    const additionalData = UTF8_ENCODER.encode(header);
126    header = JSON.parse(
127      UTF8_DECODER.decode(
128        ChromeUtils.base64URLDecode(header, { padding: "reject" })
129      )
130    );
131    if (
132      cek.length > 0 ||
133      header.enc !== "A256GCM" ||
134      header.alg !== "ECDH-ES"
135    ) {
136      throw new Error("Unknown algorithm.");
137    }
138    if ("apu" in header || "apv" in header) {
139      throw new Error("apu and apv header values are not supported.");
140    }
141    const peerPublicKey = await crypto.subtle.importKey(
142      "jwk",
143      header.epk,
144      ECDH_PARAMS,
145      false,
146      ["deriveKey"]
147    );
148    // Do ECDH agreement to get the content encryption key.
149    const contentKey = await deriveECDHSharedAESKey(key, peerPublicKey, [
150      "decrypt",
151    ]);
152    iv = ChromeUtils.base64URLDecode(iv, { padding: "reject" });
153    ciphertext = new Uint8Array(
154      ChromeUtils.base64URLDecode(ciphertext, { padding: "reject" })
155    );
156    authTag = new Uint8Array(
157      ChromeUtils.base64URLDecode(authTag, { padding: "reject" })
158    );
159    const bundle = new Uint8Array([...ciphertext, ...authTag]);
160
161    const decrypted = await crypto.subtle.decrypt(
162      {
163        name: "AES-GCM",
164        iv,
165        tagLength: AES_TAG_LEN,
166        additionalData,
167      },
168      contentKey,
169      bundle
170    );
171    return new Uint8Array(decrypted);
172  }
173}
174
175/**
176 * Do an ECDH agreement between a public and private key,
177 * returning the derived encryption key as specced by
178 * JWA RFC.
179 * The raw ECDH secret is derived into a key using
180 * Concat KDF, as defined in Section 5.8.1 of [NIST.800-56A].
181 * @param {CryptoKey} privateKey
182 * @param {CryptoKey} publicKey
183 * @param {String[]} keyUsages See `SubtleCrypto.deriveKey` 5th paramater documentation.
184 * @returns {Promise<CryptoKey>}
185 */
186async function deriveECDHSharedAESKey(privateKey, publicKey, keyUsages) {
187  const params = { ...ECDH_PARAMS, ...{ public: publicKey } };
188  const sharedKey = await crypto.subtle.deriveKey(
189    params,
190    privateKey,
191    AES_PARAMS,
192    true,
193    keyUsages
194  );
195  // This is the NIST Concat KDF specialized to a specific set of parameters,
196  // which basically turn it into a single application of SHA256.
197  // The details are from the JWA RFC.
198  let sharedKeyBytes = await crypto.subtle.exportKey("raw", sharedKey);
199  sharedKeyBytes = new Uint8Array(sharedKeyBytes);
200  const info = [
201    "\x00\x00\x00\x07A256GCM", // 7-byte algorithm identifier
202    "\x00\x00\x00\x00", // empty PartyUInfo
203    "\x00\x00\x00\x00", // empty PartyVInfo
204    "\x00\x00\x01\x00", // keylen == 256
205  ].join("");
206  const pkcs = `\x00\x00\x00\x01${String.fromCharCode.apply(
207    null,
208    sharedKeyBytes
209  )}${info}`;
210  const pkcsBuf = Uint8Array.from(
211    Array.prototype.map.call(pkcs, c => c.charCodeAt(0))
212  );
213  const derivedKeyBytes = await crypto.subtle.digest(
214    {
215      name: "SHA-256",
216    },
217    pkcsBuf
218  );
219  return crypto.subtle.importKey(
220    "raw",
221    derivedKeyBytes,
222    AES_PARAMS,
223    false,
224    keyUsages
225  );
226}
227
228const jwcrypto = new JWCrypto();
229