Bug 1301469 - Add localized decryption errors for invalid headers and padding. r=mt draft
authorKit Cambridge <kit@yakshaving.ninja>
Wed, 05 Oct 2016 08:57:52 -0700
changeset 421848 8f1b047b6f01c89a852aefbb1349a608f1178ab8
parent 421775 b21f15cc9ac12ff11496d4f63ee531c021bd7a29
child 533189 d69ce8d3a36238b3564446353fb65f35063dc452
push id31618
push userbmo:kcambridge@mozilla.com
push dateThu, 06 Oct 2016 23:49:41 +0000
reviewersmt
bugs1301469
milestone52.0a1
Bug 1301469 - Add localized decryption errors for invalid headers and padding. r=mt Web Crypto returns an unhelpful "operation failed for an operation-specific reason" error if the actual decryption fails, but we can report more useful errors for missing and invalid header values. MozReview-Commit-ID: JRdGHBUodmb
dom/locales/en-US/chrome/dom/dom.properties
dom/push/PushCrypto.jsm
dom/push/PushService.jsm
dom/push/test/xpcshell/test_crypto.js
mobile/android/components/FxAccountsPush.js
--- a/dom/locales/en-US/chrome/dom/dom.properties
+++ b/dom/locales/en-US/chrome/dom/dom.properties
@@ -242,18 +242,60 @@ ManifestInvalidType=Expected the %1$S’s %2$S member to be a %3$S.
 ManifestInvalidCSSColor=%1$S: %2$S is not a valid CSS color.
 PatternAttributeCompileFailure=Unable to check <input pattern='%S'> because the pattern is not a valid regexp: %S
 # LOCALIZATION NOTE: Do not translate "postMessage" or DOMWindow. %S values are origins, like https://domain.com:port
 TargetPrincipalDoesNotMatch=Failed to execute ‘postMessage’ on ‘DOMWindow’: The target origin provided (‘%S’) does not match the recipient window’s origin (‘%S’).
 # LOCALIZATION NOTE: Do not translate 'YouTube'. %S values are origins, like https://domain.com:port
 RewriteYouTubeEmbed=Rewriting old-style YouTube Flash embed (%S) to iframe embed (%S). Please update page to use iframe instead of embed/object, if possible.
 # LOCALIZATION NOTE: Do not translate 'YouTube'. %S values are origins, like https://domain.com:port
 RewriteYouTubeEmbedPathParams=Rewriting old-style YouTube Flash embed (%S) to iframe embed (%S). Params were unsupported by iframe embeds and converted. Please update page to use iframe instead of embed/object, if possible.
-# LOCALIZATION NOTE: Do not translate "ServiceWorker". %1$S is the ServiceWorker scope URL. %2$S is an error string.
-PushMessageDecryptionFailure=The ServiceWorker for scope ‘%1$S’ encountered an error decrypting a push message: ‘%2$S’. For help with encryption, please see https://developer.mozilla.org/en-US/docs/Web/API/Push_API/Using_the_Push_API#Encryption
+# LOCALIZATION NOTE: This error is reported when the "Encryption" header for an
+# incoming push message is missing or invalid. Do not translate "ServiceWorker",
+# "Encryption", and "salt". %1$S is the ServiceWorker scope URL.
+PushMessageBadEncryptionHeader=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘Encryption’ header must include a unique ‘salt‘ parameter for each message.
+# LOCALIZATION NOTE: This error is reported when the "Crypto-Key" header for an
+# incoming push message is missing or invalid. Do not translate "ServiceWorker",
+# "Crypto-Key", and "dh". %1$S is the ServiceWorker scope URL.
+PushMessageBadCryptoKeyHeader=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘Crypto-Key‘ header must include a ‘dh‘ parameter containing the app server’s public key.
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt because the deprecated
+# "Encryption-Key" header for an incoming push message is missing or invalid.
+# Do not translate "ServiceWorker", "Encryption-Key", "dh", "Crypto-Key", and
+# "Content-Encoding: aesgcm". %1$S is the ServiceWorker scope URL.
+PushMessageBadEncryptionKeyHeader=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘Encryption-Key’ header must include a ‘dh‘ parameter. This header is deprecated and will soon be removed. Please use ‘Crypto-Key‘ with ‘Content-Encoding: aesgcm‘ instead.
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt
+# because the "Content-Encoding" header is missing or contains an
+# unsupported encoding. Do not translate "ServiceWorker", "Content-Encoding",
+# "aesgcm", and "aesgcm128". %1$S is the ServiceWorker scope URL.
+PushMessageBadEncodingHeader=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘Content-Encoding‘ header must be ‘aesgcm‘. ‘aesgcm128‘ is allowed, but deprecated and will soon be removed.
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt
+# because the "dh" parameter is not valid base64url. Do not translate
+# "ServiceWorker", "dh", "Crypto-Key", and "base64url". %1$S is the
+# ServiceWorker scope URL.
+PushMessageBadSenderKey=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘dh‘ parameter in the ‘Crypto-Key‘ header must be the app server’s Diffie-Hellman public key, base64url-encoded (RFC 7515, Appendix C) and in “uncompressed” or “raw” form (65 bytes before encoding).
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt
+# because the "salt" parameter is not valid base64url. Do not translate
+# "ServiceWorker", "salt", "Encryption", and "base64url". %1$S is the
+# ServiceWorker scope URL.
+PushMessageBadSalt=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘salt‘ parameter in the ‘Encryption‘ header must be base64url-encoded (RFC 7515, Appendix C), and be at least 16 bytes before encoding.
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt
+# because the "rs" parameter is not a number, or is less than the pad size.
+# Do not translate "ServiceWorker", "rs", or "Encryption". %1$S is the
+# ServiceWorker scope URL. %2$S is the minimum value (1 for aesgcm128, 2 for
+# aesgcm).
+PushMessageBadRecordSize=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. The ‘rs‘ parameter of the ‘Encryption‘ header must be between %2$S and 2^36-31, or omitted entirely.
+# LOCALIZATION NOTE: This error is reported when a push message fails to decrypt
+# because an encrypted record is shorter than the pad size, the pad is larger
+# than the record, or any of the padding bytes are non-zero. Do not translate
+# "ServiceWorker". %1$S is the ServiceWorker scope URL. %2$S is the pad size
+# (1 for aesgcm128, 2 for aesgcm).
+PushMessageBadPaddingError=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. Each record in the encrypted message must be padded with a %2$S byte big-endian unsigned integer, followed by that number of zero-valued bytes.
+# LOCALIZATION NOTE: This error is reported when push message decryption fails
+# and no specific error info is available. Do not translate "ServiceWorker".
+# %1$S is the ServiceWorker scope URL.
+PushMessageBadCryptoError=The ServiceWorker for scope ‘%1$S’ failed to decrypt a push message. For help with encryption, please see https://developer.mozilla.org/en-US/docs/Web/API/Push_API/Using_the_Push_API#Encryption
 # LOCALIZATION NOTE: %1$S is the type of a DOM event. 'passive' is a literal parameter from the DOM spec.
 PreventDefaultFromPassiveListenerWarning=Ignoring ‘preventDefault()’ call on event of type ‘%1$S’ from a listener registered as ‘passive’.
 FileLastModifiedDateWarning=File.lastModifiedDate is deprecated. Use File.lastModified instead.
 ChromeScriptedDOMParserWithoutPrincipal=Creating DOMParser without a principal is deprecated.
 IIRFilterChannelCountChangeWarning=IIRFilterNode channel count changes may produce audio glitches.
 BiquadFilterChannelCountChangeWarning=BiquadFilterNode channel count changes may produce audio glitches.
 # LOCALIZATION NOTE: %1$S is the unanimatable paced property.
 UnanimatablePacedProperty=Paced property ‘%1$S’ is not an animatable property.
--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -2,20 +2,25 @@
 /* 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/. */
 
 'use strict';
 
 const Cu = Components.utils;
 
+Cu.import('resource://gre/modules/Services.jsm');
+Cu.import('resource://gre/modules/XPCOMUtils.jsm');
+
+XPCOMUtils.defineLazyGetter(this, 'gDOMBundle', () =>
+  Services.strings.createBundle('chrome://global/locale/dom/dom.properties'));
+
 Cu.importGlobalProperties(['crypto']);
 
-this.EXPORTED_SYMBOLS = ['PushCrypto', 'concatArray',
-                         'getCryptoParams'];
+this.EXPORTED_SYMBOLS = ['PushCrypto', 'concatArray'];
 
 var UTF8 = new TextEncoder('utf-8');
 
 // Legacy encryption scheme (draft-thomson-http-encryption-02).
 var AESGCM128_ENCODING = 'aesgcm128';
 var AESGCM128_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm128');
 
 // New encryption scheme (draft-ietf-httpbis-encryption-encoding-01).
@@ -25,16 +30,69 @@ var AESGCM_ENCRYPT_INFO = UTF8.encode('C
 var NONCE_INFO = UTF8.encode('Content-Encoding: nonce');
 var AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
 var P256DH_INFO = UTF8.encode('P-256\0');
 var ECDH_KEY = { name: 'ECDH', namedCurve: 'P-256' };
 var ECDSA_KEY =  { name: 'ECDSA', namedCurve: 'P-256' };
 // A default keyid with a name that won't conflict with a real keyid.
 var DEFAULT_KEYID = '';
 
+/** Localized error property names. */
+
+// `Encryption` header missing or malformed.
+const BAD_ENCRYPTION_HEADER = 'PushMessageBadEncryptionHeader';
+// `Crypto-Key` or legacy `Encryption-Key` header missing.
+const BAD_CRYPTO_KEY_HEADER = 'PushMessageBadCryptoKeyHeader';
+const BAD_ENCRYPTION_KEY_HEADER = 'PushMessageBadEncryptionKeyHeader';
+// `Content-Encoding` header missing or contains unsupported encoding.
+const BAD_ENCODING_HEADER = 'PushMessageBadEncodingHeader';
+// `dh` parameter of `Crypto-Key` header missing or not base64url-encoded.
+const BAD_DH_PARAM = 'PushMessageBadSenderKey';
+// `salt` parameter of `Encryption` header missing or not base64url-encoded.
+const BAD_SALT_PARAM = 'PushMessageBadSalt';
+// `rs` parameter of `Encryption` header not a number or less than pad size.
+const BAD_RS_PARAM = 'PushMessageBadRecordSize';
+// Invalid or insufficient padding for encrypted chunk.
+const BAD_PADDING = 'PushMessageBadPaddingError';
+// Generic crypto error.
+const BAD_CRYPTO = 'PushMessageBadCryptoError';
+
+class CryptoError extends Error {
+  /**
+   * Creates an error object indicating an incoming push message could not be
+   * decrypted.
+   *
+   * @param {String} message A human-readable error message. This is only for
+   * internal module logging, and doesn't need to be localized.
+   * @param {String} property The localized property name from `dom.properties`.
+   * @param {String...} params Substitutions to insert into the localized
+   *  string.
+   */
+  constructor(message, property, ...params) {
+    super(message);
+    this.isCryptoError = true;
+    this.property = property;
+    this.params = params;
+  }
+
+  /**
+   * Formats a localized string for reporting decryption errors to the Web
+   * Console.
+   *
+   * @param {String} scope The scope of the service worker receiving the
+   *  message, prepended to any other substitutions in the string.
+   * @returns {String} The localized string.
+   */
+  format(scope) {
+    let params = [scope, ...this.params].map(String);
+    return gDOMBundle.formatStringFromName(this.property, params,
+                                           params.length);
+  }
+}
+
 function getEncryptionKeyParams(encryptKeyField) {
   if (!encryptKeyField) {
     return null;
   }
   var params = encryptKeyField.split(',');
   return params.reduce((m, p) => {
     var pmap = p.split(';').reduce(parseHeaderFieldParams, {});
     if (pmap.keyid && pmap.dh) {
@@ -43,60 +101,94 @@ function getEncryptionKeyParams(encryptK
     if (!m[DEFAULT_KEYID] && pmap.dh) {
       m[DEFAULT_KEYID] = pmap.dh;
     }
     return m;
   }, {});
 }
 
 function getEncryptionParams(encryptField) {
+  if (!encryptField) {
+    throw new CryptoError('Missing encryption header',
+                          BAD_ENCRYPTION_HEADER);
+  }
   var p = encryptField.split(',', 1)[0];
   if (!p) {
-    return null;
+    throw new CryptoError('Encryption header missing params',
+                          BAD_ENCRYPTION_HEADER);
   }
   return p.split(';').reduce(parseHeaderFieldParams, {});
 }
 
-this.getCryptoParams = function(headers) {
+function getCryptoParams(headers) {
   if (!headers) {
     return null;
   }
 
   var keymap;
   var padSize;
+  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);
+    }
     padSize = 2;
   } 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);
+    }
     padSize = 1;
-  }
-  if (!keymap) {
-    return null;
+  } else {
+    throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
+                          BAD_ENCODING_HEADER);
   }
 
   var enc = getEncryptionParams(headers.encryption);
-  if (!enc) {
-    return null;
+  var dh = keymap[enc.keyid || DEFAULT_KEYID];
+  if (!dh) {
+    throw new CryptoError('Missing dh parameter', BAD_DH_PARAM);
   }
-  var dh = keymap[enc.keyid || DEFAULT_KEYID];
   var salt = enc.salt;
-  var rs = (enc.rs)? parseInt(enc.rs, 10) : 4096;
-
-  if (!dh || !salt || isNaN(rs) || (rs <= padSize)) {
-    return null;
+  if (!salt) {
+    throw new CryptoError('Missing salt parameter', BAD_SALT_PARAM);
+  }
+  var rs = enc.rs ? parseInt(enc.rs, 10) : 4096;
+  if (isNaN(rs)) {
+    throw new CryptoError('rs parameter must be a number', BAD_RS_PARAM);
+  }
+  if (rs <= padSize) {
+    throw new CryptoError('rs parameter must be at least ' + padSize,
+                          BAD_RS_PARAM, padSize);
   }
   return {dh, salt, rs, padSize};
 }
 
+// Decodes an unpadded, base64url-encoded string.
+function base64URLDecode(string) {
+  try {
+    return ChromeUtils.base64URLDecode(string, {
+      // draft-ietf-httpbis-encryption-encoding-01 prohibits padding.
+      padding: 'reject',
+    });
+  } catch (ex) {}
+  return null;
+}
+
 var parseHeaderFieldParams = (m, v) => {
   var i = v.indexOf('=');
   if (i >= 0) {
     // A quoted string with internal quotes is invalid for all the possible
     // values of this header field.
     m[v.substring(0, i).trim()] = v.substring(i + 1).trim()
                                    .replace(/^"(.*)"$/, '$1');
   }
@@ -145,26 +237,26 @@ function hkdf(salt, ikm) {
 }
 
 hkdf.prototype.extract = function(info, len) {
   var input = concatArray([info, new Uint8Array([1])]);
   return this.prkhPromise
     .then(prkh => prkh.hash(input))
     .then(h => {
       if (h.byteLength < len) {
-        throw new Error('Length is too long');
+        throw new CryptoError('HKDF length is too long', BAD_CRYPTO);
       }
       return h.slice(0, len);
     });
 };
 
 /* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */
 function generateNonce(base, index) {
   if (index >= Math.pow(2, 48)) {
-    throw new Error('Error generating nonce - index is too large.');
+    throw new CryptoError('Nonce index is too large', BAD_CRYPTO);
   }
   var nonce = base.slice(0, 12);
   nonce = new Uint8Array(nonce);
   for (var i = 0; i < 6; ++i) {
     nonce[nonce.byteLength - 1 - i] ^= (index / Math.pow(256, i)) & 0xff;
   }
   return nonce;
 }
@@ -185,48 +277,89 @@ this.PushCrypto = {
     return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
       .then(cryptoKey =>
          Promise.all([
            crypto.subtle.exportKey('raw', cryptoKey.publicKey),
            crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
          ]));
   },
 
-  decodeMsg(aData, aPrivateKey, aPublicKey, aSenderPublicKey, aSalt, aRs,
-            aAuthenticationSecret, aPadSize) {
+  /**
+   * Decrypts a push message.
+   *
+   * @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 passed to `getCryptoParams`.
+   * @param {BufferSource} ciphertext The encrypted message data.
+   * @returns {Promise} Resolves with a `Uint8Array` containing the decrypted
+   *  message data. Rejects with a `CryptoError` if decryption fails.
+   */
+  decrypt(privateKey, publicKey, authenticationSecret, headers, ciphertext) {
+    return Promise.resolve().then(_ => {
+      let cryptoParams = getCryptoParams(headers);
+      if (!cryptoParams) {
+        return null;
+      }
+      return this._decodeMsg(ciphertext, privateKey, publicKey,
+                             cryptoParams.dh, cryptoParams.salt,
+                             cryptoParams.rs, authenticationSecret,
+                             cryptoParams.padSize);
+    }).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
+      // context about what went wrong, so we throw a generic error instead.
+      throw new CryptoError('Bad encryption', BAD_CRYPTO);
+    });
+  },
+
+  _decodeMsg(aData, aPrivateKey, aPublicKey, aSenderPublicKey, aSalt, aRs,
+             aAuthenticationSecret, aPadSize) {
 
     if (aData.byteLength === 0) {
       // Zero length messages will be passed as null.
-      return Promise.resolve(null);
+      return null;
     }
 
     // The last chunk of data must be less than aRs, if it is not return an
     // error.
     if (aData.byteLength % (aRs + 16) === 0) {
-      return Promise.reject(new Error('Data truncated'));
+      throw new CryptoError('Encrypted data truncated', BAD_CRYPTO);
     }
 
-    let senderKey = ChromeUtils.base64URLDecode(aSenderPublicKey, {
-      // draft-ietf-httpbis-encryption-encoding-01 prohibits padding.
-      padding: "reject",
-    });
+    let senderKey = base64URLDecode(aSenderPublicKey);
+    if (!senderKey) {
+      throw new CryptoError('dh parameter is not base64url-encoded',
+                            BAD_DH_PARAM);
+    }
+
+    let salt = base64URLDecode(aSalt);
+    if (!salt) {
+      throw new CryptoError('salt parameter is not base64url-encoded',
+                            BAD_SALT_PARAM);
+    }
 
     return Promise.all([
       crypto.subtle.importKey('raw', senderKey, ECDH_KEY,
                               false, ['deriveBits']),
       crypto.subtle.importKey('jwk', aPrivateKey, ECDH_KEY,
                               false, ['deriveBits'])
     ])
     .then(([appServerKey, subscriptionPrivateKey]) =>
           crypto.subtle.deriveBits({ name: 'ECDH', public: appServerKey },
                                    subscriptionPrivateKey, 256))
     .then(ikm => this._deriveKeyAndNonce(aPadSize,
                                          new Uint8Array(ikm),
-                                         ChromeUtils.base64URLDecode(aSalt,
-                                                    { padding: "reject" }),
+                                         salt,
                                          aPublicKey,
                                          senderKey,
                                          aAuthenticationSecret))
     .then(r =>
       // AEAD_AES_128_GCM expands ciphertext to be 16 octets longer.
       Promise.all(chunkArray(aData, aRs + 16).map((slice, index) =>
         this._decodeChunk(aPadSize, slice, index, r[1], r[0]))))
     .then(r => concatArray(r));
@@ -293,29 +426,30 @@ this.PushCrypto = {
    * @param {Number} padSize The size of the padding length prepended to each
    *  chunk. For aesgcm, the padding length is expressed as a 16-bit unsigned
    *  big endian integer. For aesgcm128, the padding is an 8-bit integer.
    * @param {Uint8Array} decoded The decrypted, padded chunk.
    * @returns {Uint8Array} The chunk with padding removed.
    */
   _unpadChunk(padSize, decoded) {
     if (padSize < 1 || padSize > 2) {
-      throw new Error('Unsupported pad size');
+      throw new CryptoError('Unsupported pad size', BAD_CRYPTO);
     }
     if (decoded.length < padSize) {
-      throw new Error('Decoded array is too short!');
+      throw new CryptoError('Decoded array is too short!', BAD_PADDING,
+                            padSize);
     }
     var pad = decoded[0];
     if (padSize == 2) {
       pad = (pad << 8) | decoded[1];
     }
     if (pad > decoded.length) {
-      throw new Error ('Padding is wrong!');
+      throw new CryptoError('Padding is wrong!', BAD_PADDING, padSize);
     }
     // All padded bytes must be zero except the first one.
     for (var i = padSize; i <= pad; i++) {
       if (decoded[i] !== 0) {
-        throw new Error('Padding is wrong!');
+        throw new CryptoError('Padding is wrong!', BAD_PADDING, padSize);
       }
     }
     return decoded.slice(pad + padSize);
   },
 };
--- a/dom/push/PushService.jsm
+++ b/dom/push/PushService.jsm
@@ -14,16 +14,17 @@ Cu.import("resource://gre/modules/AppCon
 Cu.import("resource://gre/modules/Preferences.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/Timer.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 const {
   PushCrypto,
   getCryptoParams,
+  CryptoError,
 } = Cu.import("resource://gre/modules/PushCrypto.jsm");
 const {PushDB} = Cu.import("resource://gre/modules/PushDB.jsm");
 
 const CONNECTION_PROTOCOLS = (function() {
   if ('android' != AppConstants.MOZ_WIDGET_TOOLKIT) {
     const {PushServiceWebSocket} = Cu.import("resource://gre/modules/PushServiceWebSocket.jsm");
     const {PushServiceHttp2} = Cu.import("resource://gre/modules/PushServiceHttp2.jsm");
     return [PushServiceWebSocket, PushServiceHttp2];
@@ -32,19 +33,16 @@ const CONNECTION_PROTOCOLS = (function()
     return [PushServiceAndroidGCM];
   }
 })();
 
 XPCOMUtils.defineLazyServiceGetter(this, "gPushNotifier",
                                    "@mozilla.org/push/Notifier;1",
                                    "nsIPushNotifier");
 
-XPCOMUtils.defineLazyGetter(this, "gDOMBundle", () =>
-  Services.strings.createBundle("chrome://global/locale/dom/dom.properties"));
-
 this.EXPORTED_SYMBOLS = ["PushService"];
 
 XPCOMUtils.defineLazyGetter(this, "console", () => {
   let {ConsoleAPI} = Cu.import("resource://gre/modules/Console.jsm", {});
   return new ConsoleAPI({
     maxLogLevelPref: "dom.push.loglevel",
     prefix: "PushService",
   });
@@ -866,59 +864,34 @@ this.PushService = {
       }
       gPushNotifier.notifySubscriptionModified(record.scope,
                                                record.principal);
       return record;
     });
   },
 
   /**
-   * Decrypts a message. Will resolve with null if cryptoParams is falsy.
-   *
-   * @param {PushRecord} record The receiving registration.
-   * @param {Object} headers The encryption headers.
-   * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
-
-   * @returns {Promise} Resolves with the decrypted message.
-   */
-  _decryptMessage(record, headers, data) {
-    return Promise.resolve().then(_ => {
-      let cryptoParams = getCryptoParams(headers);
-      if (!cryptoParams) {
-        return null;
-      }
-      return PushCrypto.decodeMsg(
-        data,
-        record.p256dhPrivateKey,
-        record.p256dhPublicKey,
-        cryptoParams.dh,
-        cryptoParams.salt,
-        cryptoParams.rs,
-        record.authenticationSecret,
-        cryptoParams.padSize
-      );
-    });
-  },
-
-  /**
    * Decrypts an incoming message and notifies the associated service worker.
    *
    * @param {PushRecord} record The receiving registration.
    * @param {String} messageID The message ID.
    * @param {Object} headers The encryption headers.
    * @param {ArrayBuffer|Uint8Array} data The encrypted message data.
    * @returns {Promise} Resolves with an ack status code.
    */
   _decryptAndNotifyApp(record, messageID, headers, data) {
-    return this._decryptMessage(record, headers, data)
+    return PushCrypto.decrypt(record.p256dhPrivateKey, record.p256dhPublicKey,
+                              record.authenticationSecret, headers, data)
       .then(
         message => this._notifyApp(record, messageID, message),
         error => {
-          let message = gDOMBundle.formatStringFromName(
-            "PushMessageDecryptionFailure", [record.scope, String(error)], 2);
+          console.warn("decryptAndNotifyApp: Error decrypting message",
+            record.scope, messageID, error);
+
+          let message = error.format(record.scope);
           gPushNotifier.notifyError(record.scope, record.principal, message,
                                     Ci.nsIScriptError.errorFlag);
           return Ci.nsIPushErrorReporter.ACK_DECRYPTION_ERROR;
         });
   },
 
   _updateQuota: function(keyID) {
     console.debug("updateQuota()");
--- a/dom/push/test/xpcshell/test_crypto.js
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -5,19 +5,18 @@ const {
   PushCrypto,
 } = Cu.import('resource://gre/modules/PushCrypto.jsm', {});
 
 function run_test() {
   run_next_test();
 }
 
 add_task(function* test_crypto_getCryptoParams() {
-  let testData = [
   // These headers should parse correctly.
-  {
+  let shouldParse = [{
     desc: 'aesgcm with multiple keys',
     headers: {
       encoding: 'aesgcm',
       crypto_key: 'keyid=p256dh;dh=Iy1Je2Kv11A,p256ecdsa=o2M8QfiEKuI',
       encryption: 'keyid=p256dh;salt=upk1yFkp1xI',
     },
     params: {
       dh: 'Iy1Je2Kv11A',
@@ -72,63 +71,59 @@ add_task(function* test_crypto_getCrypto
       encryption: 'keyid=v2; salt=khtpyXhpDKM',
     },
     params: {
       dh: 'VA6wmY1IpiE',
       salt: 'khtpyXhpDKM',
       rs: 4096,
       padSize: 1,
     }
-  },
+  }];
+  for (let test of shouldParse) {
+    let params = getCryptoParams(test.headers);
+    deepEqual(params, test.params, test.desc);
+  }
 
   // These headers should be rejected.
-  {
+  let shouldThrow = [{
     desc: 'aesgcm128 with Crypto-Key',
     headers: {
       encoding: 'aesgcm128',
       crypto_key: 'keyid=v2; dh=VA6wmY1IpiE',
       encryption: 'keyid=v2; salt=F0Im7RtGgNY',
     },
-    params: null,
-  },
-  {
+  }, {
     desc: 'Invalid encoding',
     headers: {
       encoding: 'nonexistent',
     },
-    params: null,
   }, {
     desc: 'Invalid record size',
     headers: {
       encoding: 'aesgcm',
       crypto_key: 'dh=pbmv1QkcEDY',
       encryption: 'dh=Esao8aTBfIk;rs=bad',
     },
-    params: null,
   }, {
     desc: 'Insufficiently large record size',
     headers: {
       encoding: 'aesgcm',
       crypto_key: 'dh=fK0EXaw5IU8',
       encryption: 'salt=orbLLmlbJfM;rs=1',
     },
-    params: null,
   }, {
     desc: 'aesgcm with Encryption-Key',
     headers: {
       encoding: 'aesgcm',
       encryption_key: 'dh=FplK5KkvUF0',
       encryption: 'salt=p6YHhFF3BQY',
     },
-    params: null,
   }];
-
-  for (let test of testData) {
-    let params = getCryptoParams(test.headers);
-    deepEqual(params, test.params, test.desc);
+  for (let test of shouldThrow) {
+    throws(() => getCryptoParams(test.headers), test.desc);
   }
 });
 
 add_task(function* test_crypto_decodeMsg() {
   let privateKey = {
     crv: 'P-256',
     d: '4h23G_KkXC9TvBSK2v0Q7ImpS2YAuRd8hQyN0rFAwBg',
     ext: true,
@@ -140,106 +135,115 @@ add_task(function* test_crypto_decodeMsg
   let publicKey = ChromeUtils.base64URLDecode('BLHfOWQmxBunRJBjApg8hgSLeOeEMvmKPtcYW2k9iZZOvr3cKpQ-Sp1kpZ9HipNjUCwSA55yy0uM8N9byE8dmLs', {
     padding: "reject",
   });
 
   let expectedSuccesses = [{
     desc: 'padSize = 2, rs = 24, pad = 0',
     result: 'Some message',
     data: 'Oo34w2F9VVnTMFfKtdx48AZWQ9Li9M6DauWJVgXU',
-    senderPublicKey: 'BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo',
-    salt: 'zCU18Rw3A5aB_Xi-vfixmA',
-    rs: 24,
     authSecret: 'aTDc6JebzR6eScy2oLo4RQ',
-    padSize: 2,
+    headers: {
+      crypto_key: 'dh=BCHFVrflyxibGLlgztLwKelsRZp4gqX3tNfAKFaxAcBhpvYeN1yIUMrxaDKiLh4LNKPtj0BOXGdr-IQ-QP82Wjo',
+      encryption: 'salt=zCU18Rw3A5aB_Xi-vfixmA; rs=24',
+      encoding: 'aesgcm',
+    },
   }, {
     desc: 'padSize = 2, rs = 8, pad = 16',
     result: 'Yet another message',
     data: 'uEC5B_tR-fuQ3delQcrzrDCp40W6ipMZjGZ78USDJ5sMj-6bAOVG3AK6JqFl9E6AoWiBYYvMZfwThVxmDnw6RHtVeLKFM5DWgl1EwkOohwH2EhiDD0gM3io-d79WKzOPZE9rDWUSv64JstImSfX_ADQfABrvbZkeaWxh53EG59QMOElFJqHue4dMURpsMXg',
-    senderPublicKey: 'BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ',
-    salt: 'ZFhzj0S-n29g9P2p4-I7tA',
-    rs: 8,
     authSecret: '6plwZnSpVUbF7APDXus3UQ',
-    padSize: 2,
+    headers: {
+      crypto_key: 'dh=BEaA4gzA3i0JDuirGhiLgymS4hfFX7TNTdEhSk_HBlLpkjgCpjPL5c-GL9uBGIfa_fhGNKKFhXz1k9Kyens2ZpQ',
+      encryption: 'salt=ZFhzj0S-n29g9P2p4-I7tA; rs=8',
+      encoding: 'aesgcm',
+    },
   }, {
     desc: 'padSize = 1, rs = 4096, pad = 2',
     result: 'aesgcm128 encrypted message',
     data: 'ljBJ44NPzJFH9EuyT5xWMU4vpZ90MdAqaq1TC1kOLRoPNHtNFXeJ0GtuSaE',
-    senderPublicKey: 'BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI',
-    salt: 'btxxUtclbmgcc30b9rT3Bg',
-    rs: 4096,
-    padSize: 1,
+    headers: {
+      encryption_key: 'dh=BOmnfg02vNd6RZ7kXWWrCGFF92bI-rQ-bV0Pku3-KmlHwbGv4ejWqgasEdLGle5Rhmp6SKJunZw2l2HxKvrIjfI',
+      encryption: 'salt=btxxUtclbmgcc30b9rT3Bg; rs=4096',
+      encoding: 'aesgcm128',
+    },
   }, {
     desc: 'padSize = 2, rs = 3, pad = 0',
     result: 'Small record size',
     data: 'oY4e5eDatDVt2fpQylxbPJM-3vrfhDasfPc8Q1PWt4tPfMVbz_sDNL_cvr0DXXkdFzS1lxsJsj550USx4MMl01ihjImXCjrw9R5xFgFrCAqJD3GwXA1vzS4T5yvGVbUp3SndMDdT1OCcEofTn7VC6xZ-zP8rzSQfDCBBxmPU7OISzr8Z4HyzFCGJeBfqiZ7yUfNlKF1x5UaZ4X6iU_TXx5KlQy_toV1dXZ2eEAMHJUcSdArvB6zRpFdEIxdcHcJyo1BIYgAYTDdAIy__IJVCPY_b2CE5W_6ohlYKB7xDyH8giNuWWXAgBozUfScLUVjPC38yJTpAUi6w6pXgXUWffende5FreQpnMFL1L4G-38wsI_-ISIOzdO8QIrXHxmtc1S5xzYu8bMqSgCinvCEwdeGFCmighRjj8t1zRWo0D14rHbQLPR_b1P5SvEeJTtS9Nm3iibM',
-    senderPublicKey: 'BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk',
-    salt: '5LIDBXbvkBvvb7ZdD-T4PQ',
-    rs: 3,
     authSecret: 'g2rWVHUCpUxgcL9Tz7vyeQ',
-    padSize: 2,
+    headers: {
+      crypto_key: 'dh=BCg6ZIGuE2ZNm2ti6Arf4CDVD_8--aLXAGLYhpghwjl1xxVjTLLpb7zihuEOGGbyt8Qj0_fYHBP4ObxwJNl56bk',
+      encryption: 'salt=5LIDBXbvkBvvb7ZdD-T4PQ; rs=3',
+      encoding: 'aesgcm',
+    },
   }];
   for (let test of expectedSuccesses) {
     let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
       padding: "reject",
     }) : null;
     let data = ChromeUtils.base64URLDecode(test.data, {
       padding: "reject",
     });
-    let result = yield PushCrypto.decodeMsg(data,
-                                            privateKey, publicKey,
-                                            test.senderPublicKey, test.salt,
-                                            test.rs, authSecret, test.padSize);
+    let result = yield PushCrypto.decrypt(privateKey, publicKey, authSecret,
+                                          test.headers, data);
     let decoder = new TextDecoder('utf-8');
     equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
   }
 
   let expectedFailures = [{
     desc: 'padSize = 1, rs = 4096, auth secret, pad = 8',
     data: 'h0FmyldY8aT5EQ6CJrbfRn_IdDvytoLeHb9_q5CjtdFRfgDRknxLmOzavLaVG4oOiS0r',
-    senderPublicKey: 'BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM',
-    salt: 'aGBpoKklLtrLcAUCcCr7JQ',
-    rs: 4096,
+    senderPublicKey: '',
     authSecret: 'Sxb6u0gJIhGEogyLawjmCw',
-    padSize: 1,
+    headers: {
+      crypto_key: 'dh=BCXHk7O8CE-9AOp6xx7g7c-NCaNpns1PyyHpdcmDaijLbT6IdGq0ezGatBwtFc34BBfscFxdk4Tjksa2Mx5rRCM',
+      encryption: 'salt=aGBpoKklLtrLcAUCcCr7JQ',
+      encoding: 'aesgcm128',
+    },
   }, {
     desc: 'Missing padding',
     data: 'anvsHj7oBQTPMhv7XSJEsvyMS4-8EtbC7HgFZsKaTg',
-    senderPublicKey: 'BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4',
-    salt: 'Czx2i18rar8XWOXAVDnUuw',
-    rs: 4096,
-    padSize: 1,
+    headers: {
+      crypto_key: 'dh=BMSqfc3ohqw2DDgu3nsMESagYGWubswQPGxrW1bAbYKD18dIHQBUmD3ul_lu7MyQiT5gNdzn5JTXQvCcpf-oZE4',
+      encryption: 'salt=Czx2i18rar8XWOXAVDnUuw',
+      encoding: 'aesgcm128',
+    },
   }, {
     desc: 'padSize > rs',
     data: 'Ct_h1g7O55e6GvuhmpjLsGnv8Rmwvxgw8iDESNKGxk_8E99iHKDzdV8wJPyHA-6b2E6kzuVa5UWiQ7s4Zms1xzJ4FKgoxvBObXkc_r_d4mnb-j245z3AcvRmcYGk5_HZ0ci26SfhAN3lCgxGzTHS4nuHBRkGwOb4Tj4SFyBRlLoTh2jyVK2jYugNjH9tTrGOBg7lP5lajLTQlxOi91-RYZSfFhsLX3LrAkXuRoN7G1CdiI7Y3_eTgbPIPabDcLCnGzmFBTvoJSaQF17huMl_UnWoCj2WovA4BwK_TvWSbdgElNnQ4CbArJ1h9OqhDOphVu5GUGr94iitXRQR-fqKPMad0ULLjKQWZOnjuIdV1RYEZ873r62Yyd31HoveJcSDb1T8l_QK2zVF8V4k0xmK9hGuC0rF5YJPYPHgl5__usknzxMBnRrfV5_MOL5uPZwUEFsu',
-    senderPublicKey: 'BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls',
-    salt: 'NQVTKhB0rpL7ZzKkotTGlA',
-    rs: 1,
-    authSecret: '6plwZnSpVUbF7APDXus3UQ',
-    padSize: 2,
+    headers: {
+      crypto_key: 'dh=BAcMdWLJRGx-kPpeFtwqR3GE1LWzd1TYh2rg6CEFu53O-y3DNLkNe_BtGtKRR4f7ZqpBMVS6NgfE2NwNPm3Ndls',
+      encryption: 'salt=NQVTKhB0rpL7ZzKkotTGlA; rs=1',
+      encoding: 'aesgcm',
+    },
   }, {
     desc: 'Encrypted with padSize = 1, decrypted with padSize = 2 and auth secret',
     data: 'fwkuwTTChcLnrzsbDI78Y2EoQzfnbMI8Ax9Z27_rwX8',
-    senderPublicKey: 'BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
-    salt: 'c6JQl9eJ0VvwrUVCQDxY7Q',
-    rs: 4096,
     authSecret: 'BhbpNTWyO5wVJmVKTV6XaA',
-    padSize: 2,
+    headers: {
+      crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
+      encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q',
+      encoding: 'aesgcm',
+    },
   }, {
     desc: 'Truncated input',
     data: 'AlDjj6NvT5HGyrHbT8M5D6XBFSra6xrWS9B2ROaCIjwSu3RyZ1iyuv0',
-    rs: 25,
+    headers: {
+      crypto_key: 'dh=BCHn-I-J3dfPRLJBlNZ3xFoAqaBLZ6qdhpaz9W7Q00JW1oD-hTxyEECn6KYJNK8AxKUyIDwn6Icx_PYWJiEYjQ0',
+      encryption: 'salt=c6JQl9eJ0VvwrUVCQDxY7Q; rs=25',
+      encoding: 'aesgcm',
+    },
   }];
   for (let test of expectedFailures) {
     let authSecret = test.authSecret ? ChromeUtils.base64URLDecode(test.authSecret, {
       padding: "reject",
     }) : null;
     let data = ChromeUtils.base64URLDecode(test.data, {
       padding: "reject",
     });
     yield rejects(
-      PushCrypto.decodeMsg(data, privateKey, publicKey,
-                           test.senderPublicKey, test.salt, test.rs,
-                           authSecret, test.padSize),
+      PushCrypto.decrypt(privateKey, publicKey, authSecret,
+                         test.headers, data),
       test.desc
     );
   }
 });
--- a/mobile/android/components/FxAccountsPush.js
+++ b/mobile/android/components/FxAccountsPush.js
@@ -96,74 +96,64 @@ FxAccountsPush.prototype = {
     }).catch(err => {
       Log.e("Error during unsubscribe", err);
     });
   },
 
   _decodePushMessage(data) {
     Log.i("FxAccountsPush _decodePushMessage");
     data = JSON.parse(data);
-    let { message, cryptoParams } = this._messageAndCryptoParams(data);
+    let { headers, message } = this._messageAndHeaders(data);
     return new Promise((resolve, reject) => {
       PushService.getSubscription(FXA_PUSH_SCOPE,
         Services.scriptSecurityManager.getSystemPrincipal(),
         (result, subscription) => {
           if (!subscription) {
             return reject(new Error("No subscription found"));
           }
           return resolve(subscription);
         });
     }).then(subscription => {
-      if (!cryptoParams) {
-        return new Uint8Array();
-      }
-      return PushCrypto.decodeMsg(
-        message,
-        subscription.p256dhPrivateKey,
-        new Uint8Array(subscription.getKey("p256dh")),
-        cryptoParams.dh,
-        cryptoParams.salt,
-        cryptoParams.rs,
-        new Uint8Array(subscription.getKey("auth")),
-        cryptoParams.padSize
-      );
+      return PushCrypto.decrypt(subscription.p256dhPrivateKey,
+                                new Uint8Array(subscription.getKey("p256dh")),
+                                new Uint8Array(subscription.getKey("auth")),
+                                headers, message);
     })
-    .then(decryptedMessage => {
-      decryptedMessage = _decoder.decode(decryptedMessage);
+    .then(plaintext => {
+      let decryptedMessage = plaintext ? _decoder.decode(plaintext) : "";
       Messaging.sendRequestForResult({
         type: "FxAccountsPush:ReceivedPushMessageToDecode:Response",
         message: decryptedMessage
       });
     })
     .catch(err => {
       Log.d("Error while decoding incoming message : " + err);
     });
   },
 
   // Copied from PushServiceAndroidGCM
-  _messageAndCryptoParams(data) {
+  _messageAndHeaders(data) {
     // Default is no data (and no encryption).
     let message = null;
-    let cryptoParams = null;
+    let headers = null;
 
     if (data.message && data.enc && (data.enckey || data.cryptokey)) {
       let headers = {
         encryption_key: data.enckey,
         crypto_key: data.cryptokey,
         encryption: data.enc,
         encoding: data.con,
       };
-      cryptoParams = getCryptoParams(headers);
       // Ciphertext is (urlsafe) Base 64 encoded.
       message = ChromeUtils.base64URLDecode(data.message, {
         // The Push server may append padding.
         padding: "ignore",
       });
     }
-    return { message, cryptoParams };
+    return { headers, message };
   },
 
   QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver]),
 
   classID: Components.ID("{d1bbb0fd-1d47-4134-9c12-d7b1be20b721}")
 };
 
 function urlsafeBase64Encode(key) {