Bug 1442128 - support aes128gcm encryption in PushCrypto.jsm. r=kitcambridge,mt
authorMark Hammond <mhammond@skippinet.com.au>
Tue, 20 Feb 2018 12:01:08 +1100
changeset 408927 8715148b05f8799a2de5104078b601dd7706f11f
parent 408926 24274919cbb0bf7788583c1788e106723e80a152
child 408928 41c707ae4d6d143f1c63772be63108df34bde197
push id61368
push usermhammond@skippinet.com.au
push dateTue, 20 Mar 2018 01:45:16 +0000
treeherderautoland@8715148b05f8 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskitcambridge, mt
bugs1442128
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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]