Bug 1442128 - support aes128gcm encryption in PushCrypto.jsm. r?kitcambridge,mt draft
authorMark Hammond <mhammond@skippinet.com.au>
Tue, 20 Feb 2018 12:01:08 +1100
changeset 769728 a56b3dc90b2df806ef6c95b8b0e4c34688f89c78
parent 769726 bfb7edfd0436db388bb9e103b8ad817fc50bfdcf
push id103207
push userbmo:markh@mozilla.com
push dateTue, 20 Mar 2018 00:34:04 +0000
reviewerskitcambridge, mt
bugs1442128
milestone61.0a1
Bug 1442128 - support aes128gcm encryption in PushCrypto.jsm. r?kitcambridge,mt MozReview-Commit-ID: HqLxm7wuZuv
dom/push/PushCrypto.jsm
dom/push/test/webpush.js
dom/push/test/xpcshell/test_crypto_encrypt.js
dom/push/test/xpcshell/xpcshell.ini
--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -443,16 +443,17 @@ class OldSchemeDecoder extends Decoder {
   }
 }
 
 /** 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');
+var 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) {
@@ -461,17 +462,17 @@ class aes128gcmDecoder extends Decoder {
       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)
+      prkKdf.extract(AES128GCM_NONCE_INFO, 12)
     ]);
   }
 
   unpadChunk(decoded, last) {
     let length = decoded.length;
     while (length--) {
       if (decoded[length] === 0) {
         continue;
@@ -630,9 +631,142 @@ var PushCrypto = {
 
     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) {
+      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 await 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, ["deriveBits"]);
+
+    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) {
+      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]);
+  }
+}
--- a/dom/push/test/webpush.js
+++ b/dom/push/test/webpush.js
@@ -1,16 +1,20 @@
 /*
  * Browser-based Web Push client for the application server piece.
  *
  * Any copyright is dedicated to the Public Domain.
  * http://creativecommons.org/licenses/publicdomain/
  *
  * Uses the WebCrypto API.
- * Uses the fetch API.  Polyfill: https://github.com/github/fetch
+ *
+ * Note that this test file uses the old, deprecated aesgcm128 encryption
+ * scheme. PushCrypto.encrypt() exists and uses the later aes128gcm, but
+ * there's no good reason to upgrade this at this time (and having mochitests
+ * use PushCrypto directly is easier said than done.)
  */
 
 (function (g) {
   'use strict';
 
   var P256DH = {
     name: 'ECDH',
     namedCurve: 'P-256'
new file mode 100644
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto_encrypt.js
@@ -0,0 +1,147 @@
+// Test PushCrypto.encrypt()
+"use strict";
+
+Cu.importGlobalProperties(["crypto"]);
+
+const {PushCrypto} = ChromeUtils.import("resource://gre/modules/PushCrypto.jsm");
+
+let from64 = v => {
+  // allow whitespace in the strings.
+  let stripped = v.replace(/ |\t|\r|\n/g, '');
+  return new Uint8Array(ChromeUtils.base64URLDecode(stripped, {padding: "reject"}));
+}
+
+let to64 = v => ChromeUtils.base64URLEncode(v, {pad: false});
+
+// A helper function to take a public key (as a buffer containing a 65-byte
+// buffer of uncompressed EC points) and a private key (32byte buffer) and
+// return 2 crypto keys.
+async function importKeyPair(publicKeyBuffer, privateKeyBuffer) {
+  let jwk = {
+    kty: "EC",
+    crv: "P-256",
+    x: to64(publicKeyBuffer.slice(1, 33)),
+    y: to64(publicKeyBuffer.slice(33, 65)),
+    ext: true,
+  };
+  let publicKey = await crypto.subtle.importKey('jwk', jwk,
+                                                { name: 'ECDH', namedCurve: 'P-256' },
+                                                true, []);
+  jwk.d = to64(privateKeyBuffer);
+  let privateKey = await crypto.subtle.importKey('jwk', jwk,
+                                                 { name: 'ECDH', namedCurve: 'P-256' },
+                                                 true, ['deriveBits']);
+  return {publicKey, privateKey};
+}
+
+// The example from draft-ietf-webpush-encryption-09.
+add_task(async function static_aes128gcm() {
+  let fixture = {
+    ciphertext: from64(`DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
+                        mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT
+                        pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN`),
+    plaintext: new TextEncoder("utf-8").encode("When I grow up, I want to be a watermelon"),
+    authSecret: from64("BTBZMqHH6r4Tts7J_aSIgg"),
+    receiver: {
+      private: from64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"),
+      public: from64(`BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx
+                      aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4`),
+    },
+    sender: {
+      private: from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"),
+      public: from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIg
+                      Dll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`),
+    },
+    salt: from64("DGv6ra1nlYgDCS1FRnbzlw"),
+  };
+
+
+  let publicKeyBuffer = from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZ
+                                IIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`);
+  let privateKeyBuffer = from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw");
+  let options = {
+    senderKeyPair: await importKeyPair(fixture.sender.public, fixture.sender.private),
+    salt: fixture.salt,
+  }
+
+  let {ciphertext, encoding} = await PushCrypto.encrypt(fixture.plaintext,
+                                                        fixture.receiver.public,
+                                                        fixture.authSecret,
+                                                        options);
+
+  Assert.deepEqual(ciphertext, fixture.ciphertext);
+  Assert.equal(encoding, "aes128gcm");
+
+  // and for fun, decrypt it and check the plaintext.
+  let recvKeyPair = await importKeyPair(fixture.receiver.public, fixture.receiver.private);
+  let jwk = await crypto.subtle.exportKey("jwk", recvKeyPair.privateKey);
+  let plaintext = await PushCrypto.decrypt(jwk, fixture.receiver.public,
+                                           fixture.authSecret,
+                                           {encoding: "aes128gcm"},
+                                           ciphertext);
+  Assert.deepEqual(plaintext, fixture.plaintext);
+});
+
+// This is how we expect real code to interact with .encrypt.
+add_task(async function aes128gcm_simple() {
+  let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+  let message = new TextEncoder("utf-8").encode("Fast for good.");
+  let authSecret = crypto.getRandomValues(new Uint8Array(16));
+  let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret);
+  Assert.equal(encoding, "aes128gcm");
+  // and decrypt it.
+  let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+                                           authSecret,
+                                           {encoding},
+                                           ciphertext);
+  deepEqual(message, plaintext);
+});
+
+// Variable record size tests
+add_task(async function aes128gcm_rs() {
+  let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+  let payload = "x".repeat(1024 * 10);
+
+  for (let rs of [-1, 0, 1, 17]) {
+    info(`testing expected failure with rs=${rs}`);
+    let message = new TextEncoder("utf-8").encode(payload);
+    let authSecret = crypto.getRandomValues(new Uint8Array(16));
+    await Assert.rejects(PushCrypto.encrypt(message, recvPublicKey, authSecret, {rs}),
+                         /recordsize is too small/);
+  }
+  for (let rs of [18, 50, 1024, 4096, 16384]) {
+    info(`testing expected success with rs=${rs}`);
+    let message = new TextEncoder("utf-8").encode(payload);
+    let authSecret = crypto.getRandomValues(new Uint8Array(16));
+    let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret, {rs});
+    Assert.equal(encoding, "aes128gcm");
+    // and decrypt it.
+    let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+                                             authSecret,
+                                             {encoding},
+                                             ciphertext);
+    deepEqual(message, plaintext);
+  }
+});
+
+// And try and hit some edge-cases.
+add_task(async function aes128gcm_edgecases() {
+  let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+  for (let size of [0, 4096-16, 4096-16-1, 4096-16+1,
+                    4095, 4096, 4097,
+                    1024*100]) {
+    info(`testing encryption of ${size} byte payload`);
+    let message = new TextEncoder("utf-8").encode("x".repeat(size));
+    let authSecret = crypto.getRandomValues(new Uint8Array(16));
+    let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret);
+    Assert.equal(encoding, "aes128gcm");
+    // and decrypt it.
+    let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+                                             authSecret,
+                                             {encoding},
+                                             ciphertext);
+    deepEqual(message, plaintext);
+  }
+});
--- a/dom/push/test/xpcshell/xpcshell.ini
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -1,16 +1,17 @@
 [DEFAULT]
 head = head.js head-http2.js
 # Push notifications and alarms are currently disabled on Android.
 skip-if = toolkit == 'android'
 
 [test_clear_forgetAboutSite.js]
 [test_clear_origin_data.js]
 [test_crypto.js]
+[test_crypto_encrypt.js]
 [test_drop_expired.js]
 [test_handler_service.js]
 support-files = PushServiceHandler.js PushServiceHandler.manifest
 [test_notification_ack.js]
 [test_notification_data.js]
 [test_notification_duplicate.js]
 [test_notification_error.js]
 [test_notification_incomplete.js]