Bug 1345665 - Implement the "aes128gcm" push decryption scheme. r=mt draft
authorKit Cambridge <kit@yakshaving.ninja>
Thu, 11 May 2017 22:03:26 -0700
changeset 576739 cb02c827fb657440c4ff05b7212a3015baff33be
parent 576738 daaf414127a11525ce7d61c15b96e7f3493611de
child 576740 c54b264c292472d930560f017bf1400d1b2a941d
push id58461
push userbmo:kit@mozilla.com
push dateFri, 12 May 2017 05:06:55 +0000
reviewersmt
bugs1345665
milestone55.0a1
Bug 1345665 - Implement the "aes128gcm" push decryption scheme. r=mt MozReview-Commit-ID: 3touVfeSDwE
dom/push/PushCrypto.jsm
dom/push/test/xpcshell/test_crypto.js
--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -103,29 +103,47 @@ function getEncryptionParams(encryptFiel
   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) {
+    throw new CryptoError('Truncated header', BAD_CRYPTO);
+  }
+  let rs = (payload[16] << 24) | (payload[17] << 16) | (payload[18] << 8) | payload[19];
+  let keyIdLen = payload[20];
+  if (keyIdLen != 65) {
+    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: 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`,
 // `Encryption-Key`, and `Encryption` headers for the aesgcm and aesgcm128
 // schemes.
 function getCryptoParamsFromHeaders(headers) {
   if (!headers) {
     return null;
   }
 
   var keymap;
-  if (!headers.encoding) {
-    throw new CryptoError('Missing Content-Encoding header',
-                          BAD_ENCODING_HEADER);
-  }
   if (headers.encoding == AESGCM_ENCODING) {
     // 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
     keymap = getEncryptionKeyParams(headers.crypto_key);
     if (!keymap) {
       throw new CryptoError('Missing Crypto-Key header',
                             BAD_CRYPTO_KEY_HEADER);
@@ -133,19 +151,16 @@ function getCryptoParamsFromHeaders(head
   } else if (headers.encoding == AESGCM128_ENCODING) {
     // aesgcm128 uses Encryption-Key, 1 byte for the pad length, and no secret.
     // https://tools.ietf.org/html/draft-thomson-http-encryption-02
     keymap = getEncryptionKeyParams(headers.encryption_key);
     if (!keymap) {
       throw new CryptoError('Missing Encryption-Key header',
                             BAD_ENCRYPTION_KEY_HEADER);
     }
-  } else {
-    throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
-                          BAD_ENCODING_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);
   }
@@ -298,17 +313,18 @@ class Decoder {
     }
     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) => this.decodeChunk(slice, index, nonce, key)));
+        .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
@@ -350,23 +366,23 @@ class Decoder {
    * @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) {
+  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));
+    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.
@@ -424,16 +440,64 @@ class OldSchemeDecoder extends Decoder {
     return this.rs + 16;
   }
 
   get padSize() {
     throw new Error('Missing `padSize` implementation');
   }
 }
 
+/** New encryption scheme (draft-ietf-httpbis-encryption-encoding-06). */
+
+var AES128GCM_ENCODING = 'aes128gcm';
+var AES128GCM_KEY_INFO = UTF8.encode('Content-Encoding: aes128gcm\0');
+var AES128GCM_AUTH_INFO = UTF8.encode('WebPush: info\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(concatArray([NONCE_INFO, new Uint8Array([0])]), 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). */
 
 var AESGCM_ENCODING = 'aesgcm';
 var AESGCM_KEY_INFO = UTF8.encode('Content-Encoding: aesgcm\0');
 var AESGCM_AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
 var AESGCM_P256DH_INFO = UTF8.encode('P-256\0');
 
 class aesgcmDecoder extends OldSchemeDecoder {
@@ -526,30 +590,51 @@ this.PushCrypto = {
    * @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} ciphertext The encrypted message data.
+   * @param {BufferSource} payload The encrypted message payload.
    * @returns {Uint8Array} The decrypted message data.
    */
-  async decrypt(privateKey, publicKey, authenticationSecret, headers,
-                ciphertext) {
-    // aesgcm and aesgcm128 include the salt, record size, and sender public
-    // key in the `Crypto-Key` and `Encryption` HTTP headers.
-    let cryptoParams = getCryptoParamsFromHeaders(headers);
-    if (!cryptoParams) {
+  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 (headers.encoding == AESGCM_ENCODING) {
-      decoder = new aesgcmDecoder(privateKey, publicKey, authenticationSecret,
-                                  cryptoParams, ciphertext);
-    } else {
-      decoder = new aesgcm128Decoder(privateKey, publicKey, cryptoParams,
-                                     ciphertext);
+    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));
+      decoder = new aes128gcmDecoder(privateKey, publicKey,
+                                     authenticationSecret, cryptoParams,
+                                     cryptoParams.ciphertext);
+    } else if (encoding == AESGCM128_ENCODING || encoding == AESGCM_ENCODING) {
+      // aesgcm and aesgcm128 include the salt, record size, and sender public
+      // key in the `Crypto-Key` and `Encryption` HTTP headers.
+      let cryptoParams = getCryptoParamsFromHeaders(headers);
+      if (headers.encoding == AESGCM_ENCODING) {
+        decoder = new aesgcmDecoder(privateKey, publicKey, authenticationSecret,
+                                    cryptoParams, payload);
+      } else {
+        decoder = new aesgcm128Decoder(privateKey, publicKey, cryptoParams,
+                                       payload);
+      }
     }
+
+    if (!decoder) {
+      throw new CryptoError('Unsupported Content-Encoding: ' + encoding,
+                            BAD_ENCODING_HEADER);
+    }
+
     return decoder.decode();
   },
 };
--- a/dom/push/test/xpcshell/test_crypto.js
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -1,15 +1,17 @@
 'use strict';
 
 const {
   getCryptoParamsFromHeaders,
   PushCrypto,
 } = Cu.import('resource://gre/modules/PushCrypto.jsm', {});
 
+const REJECT_PADDING = { padding: 'reject' };
+
 function run_test() {
   run_next_test();
 }
 
 add_task(async function test_crypto_getCryptoParamsFromHeaders() {
   // These headers should parse correctly.
   let shouldParse = [{
     desc: 'aesgcm with multiple keys',
@@ -69,22 +71,19 @@ add_task(async function test_crypto_getC
     params: {
       senderKey: 'VA6wmY1IpiE',
       salt: 'khtpyXhpDKM',
       rs: 4096,
     }
   }];
   for (let test of shouldParse) {
     let params = getCryptoParamsFromHeaders(test.headers);
-    let senderKey = ChromeUtils.base64URLDecode(test.params.senderKey, {
-      padding: 'reject',
-    });
-    let salt = ChromeUtils.base64URLDecode(test.params.salt, {
-      padding: 'reject',
-    });
+    let senderKey = ChromeUtils.base64URLDecode(test.params.senderKey,
+                                                REJECT_PADDING);
+    let salt = ChromeUtils.base64URLDecode(test.params.salt, REJECT_PADDING);
     deepEqual(new Uint8Array(params.senderKey), new Uint8Array(senderKey),
       "Sender key should match for " + test.desc);
     deepEqual(new Uint8Array(params.salt), new Uint8Array(salt),
       "Salt should match for " + test.desc);
     equal(params.rs, test.params.rs,
       "Record size should match for " + test.desc);
   }
 
@@ -241,8 +240,51 @@ add_task(function* test_crypto_decodeMsg
     });
     yield rejects(
       PushCrypto.decrypt(privateKey, publicKey, authSecret,
                          test.headers, data),
       test.desc
     );
   }
 });
+
+add_task(async function test_aes128gcm() {
+  let expectedSuccesses = [{
+    desc: 'Example from draft-ietf-webpush-encryption-latest',
+    result: 'When I grow up, I want to be a watermelon',
+    data: 'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',
+    authSecret: 'BTBZMqHH6r4Tts7J_aSIgg',
+    privateKey: {
+      kty: 'EC',
+      crv: 'P-256',
+      d: 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94',
+      x: 'JXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzE',
+      y: 'aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4',
+      ext: true,
+    },
+    publicKey: 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4',
+  }, {
+    desc: 'rs = 24',
+    result: "I am the very model of a modern Major-General; I've information vegetable, animal, and mineral",
+    data: 'goagSH7PP0ZGwUsgShmdkwAAABhBBDJVyIuUJbOSVMeWHP8VNPnxY-dZSw86doqOkEzZZZY1ALBWVXTVf0rUDH3oi68I9Hrp-01zA-mr8XKWl5kcH8cX0KiV2PtCwdkEyaQ73YF5fsDxgoWDiaTA3wPqMvuLDqGsZWHnE9Psnfoy7UMEqKlh2a1nE7ZOXiXcOBHLNj260jYzSJnEPV2eXixSXfyWpaSJHAwfj4wVdAAocmViIg6ywk8wFB1hgJpnX2UVEU_qIOcaP6AOIOr1UUQPfosQqC2MEHe5u9gHXF5pi-E267LAlkoYefq01KV_xK_vjbxpw8GAYfSjQEm0L8FG-CN37c8pnQ2Yf61MkihaXac9OctfNeWq_22cN6hn4qsOq0F7QoWIiZqWhB1vS9cJ3KUlyPQvKI9cvevDxw0fJHWeTFzhuwT9BjdILjjb2Vkqc0-qTDOawqD4c8WXsvdGDQCec5Y1x3UhdQXdjR_mhXypxFM37OZTvKJBr1vPCpRXl-bI6iOd7KScgtMM1x5luKhGzZyz25HyuFyj1ec82A',
+    authSecret: '_tK2LDGoIt6be6agJ_nvGA',
+    privateKey: {
+      kty: 'EC',
+      crv: 'P-256',
+      d: 'bGViEe3PvjjFJg8lcnLsqu71b2yqWGnZN9J2MTed-9s',
+      x: 'auB0GHF0AZ2LAocFnvOXDS7EeCMopnzbg-tS21FMHrU',
+      y: 'GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc',
+      ext: true,
+    },
+    publicKey: 'BGrgdBhxdAGdiwKHBZ7zlw0uxHgjKKZ824PrUttRTB61GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc',
+  }];
+  for (let test of expectedSuccesses) {
+    let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+    let authSecret = ChromeUtils.base64URLDecode(test.authSecret,
+                                                 REJECT_PADDING);
+    let payload = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+    let result = await PushCrypto.decrypt(test.privateKey, publicKey,
+                                          authSecret,
+                                          { encoding: 'aes128gcm' }, payload);
+    let decoder = new TextDecoder('utf-8');
+    equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+  }
+});