summaryrefslogtreecommitdiffstats
path: root/dom/push/PushCrypto.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'dom/push/PushCrypto.jsm')
-rw-r--r--dom/push/PushCrypto.jsm454
1 files changed, 454 insertions, 0 deletions
diff --git a/dom/push/PushCrypto.jsm b/dom/push/PushCrypto.jsm
new file mode 100644
index 000000000..5a669875c
--- /dev/null
+++ b/dom/push/PushCrypto.jsm
@@ -0,0 +1,454 @@
+/* jshint moz: true, esnext: true */
+/* 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'];
+
+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).
+var AESGCM_ENCODING = 'aesgcm';
+var AESGCM_ENCRYPT_INFO = UTF8.encode('Content-Encoding: aesgcm');
+
+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) {
+ m[pmap.keyid] = pmap.dh;
+ }
+ 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) {
+ throw new CryptoError('Encryption header missing params',
+ BAD_ENCRYPTION_HEADER);
+ }
+ return p.split(';').reduce(parseHeaderFieldParams, {});
+}
+
+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;
+ } else {
+ throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
+ BAD_ENCODING_HEADER);
+ }
+
+ var enc = getEncryptionParams(headers.encryption);
+ var dh = keymap[enc.keyid || DEFAULT_KEYID];
+ if (!dh) {
+ throw new CryptoError('Missing dh parameter', BAD_DH_PARAM);
+ }
+ var salt = enc.salt;
+ 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');
+ }
+ return m;
+};
+
+function chunkArray(array, size) {
+ var start = array.byteOffset || 0;
+ array = array.buffer || array;
+ var index = 0;
+ var result = [];
+ while(index + size <= array.byteLength) {
+ result.push(new Uint8Array(array, start + index, size));
+ index += size;
+ }
+ if (index < array.byteLength) {
+ result.push(new Uint8Array(array, start + index));
+ }
+ return result;
+}
+
+this.concatArray = function(arrays) {
+ var size = arrays.reduce((total, a) => total + a.byteLength, 0);
+ var index = 0;
+ return arrays.reduce((result, a) => {
+ result.set(new Uint8Array(a), index);
+ index += a.byteLength;
+ return result;
+ }, new Uint8Array(size));
+};
+
+var HMAC_SHA256 = { name: 'HMAC', hash: 'SHA-256' };
+
+function hmac(key) {
+ this.keyPromise = crypto.subtle.importKey('raw', key, HMAC_SHA256,
+ false, ['sign']);
+}
+
+hmac.prototype.hash = function(input) {
+ return this.keyPromise.then(k => crypto.subtle.sign('HMAC', k, input));
+};
+
+function hkdf(salt, ikm) {
+ this.prkhPromise = new hmac(salt).hash(ikm)
+ .then(prk => new hmac(prk));
+}
+
+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 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 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;
+}
+
+this.PushCrypto = {
+
+ generateAuthenticationSecret() {
+ return crypto.getRandomValues(new Uint8Array(16));
+ },
+
+ validateAppServerKey(key) {
+ return crypto.subtle.importKey('raw', key, ECDSA_KEY,
+ true, ['verify'])
+ .then(_ => key);
+ },
+
+ generateKeys() {
+ return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
+ .then(cryptoKey =>
+ Promise.all([
+ crypto.subtle.exportKey('raw', cryptoKey.publicKey),
+ crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
+ ]));
+ },
+
+ /**
+ * 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 null;
+ }
+
+ // The last chunk of data must be less than aRs, if it is not return an
+ // error.
+ if (aData.byteLength % (aRs + 16) === 0) {
+ throw new CryptoError('Encrypted data truncated', BAD_CRYPTO);
+ }
+
+ 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),
+ 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));
+ },
+
+ _deriveKeyAndNonce(padSize, ikm, salt, receiverKey, senderKey,
+ authenticationSecret) {
+ var kdfPromise;
+ var context;
+ var encryptInfo;
+ // The size of the padding determines which key derivation we use.
+ //
+ // 1. If the pad size is 1, we assume "aesgcm128". This scheme ignores the
+ // authenticationSecret, and uses "Content-Encoding: <blah>" for the
+ // context string. It should eventually be removed: bug 1230038.
+ //
+ // 2. If the pad size is 2, we assume "aesgcm", and mix the
+ // authenticationSecret with the ikm using HKDF. The context string is:
+ // "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
+ // receiver key and sender key.
+ if (padSize == 2) {
+ // Since we are using an authentication secret, we need to run an extra
+ // round of HKDF with the authentication secret as salt.
+ var authKdf = new hkdf(authenticationSecret, ikm);
+ kdfPromise = authKdf.extract(AUTH_INFO, 32)
+ .then(ikm2 => new hkdf(salt, ikm2));
+
+ // aesgcm requires extra context for the info parameter.
+ context = concatArray([
+ new Uint8Array([0]), P256DH_INFO,
+ this._encodeLength(receiverKey), receiverKey,
+ this._encodeLength(senderKey), senderKey
+ ]);
+ encryptInfo = AESGCM_ENCRYPT_INFO;
+ } else {
+ kdfPromise = Promise.resolve(new hkdf(salt, ikm));
+ context = new Uint8Array(0);
+ encryptInfo = AESGCM128_ENCRYPT_INFO;
+ }
+ return kdfPromise.then(kdf => Promise.all([
+ kdf.extract(concatArray([encryptInfo, context]), 16)
+ .then(gcmBits => crypto.subtle.importKey('raw', gcmBits, 'AES-GCM', false,
+ ['decrypt'])),
+ kdf.extract(concatArray([NONCE_INFO, context]), 12)
+ ]));
+ },
+
+ _encodeLength(buffer) {
+ return new Uint8Array([0, buffer.byteLength]);
+ },
+
+ _decodeChunk(aPadSize, aSlice, aIndex, aNonce, aKey) {
+ let params = {
+ name: 'AES-GCM',
+ iv: generateNonce(aNonce, aIndex)
+ };
+ return crypto.subtle.decrypt(params, aKey, aSlice)
+ .then(decoded => this._unpadChunk(aPadSize, new Uint8Array(decoded)));
+ },
+
+ /**
+ * Removes padding from a decrypted chunk.
+ *
+ * @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 CryptoError('Unsupported pad size', BAD_CRYPTO);
+ }
+ if (decoded.length < padSize) {
+ throw new CryptoError('Decoded array is too short!', BAD_PADDING);
+ }
+ var pad = decoded[0];
+ if (padSize == 2) {
+ pad = (pad << 8) | decoded[1];
+ }
+ if (pad > decoded.length) {
+ throw new CryptoError('Padding is wrong!', BAD_PADDING);
+ }
+ // All padded bytes must be zero except the first one.
+ for (var i = padSize; i <= pad; i++) {
+ if (decoded[i] !== 0) {
+ throw new CryptoError('Padding is wrong!', BAD_PADDING);
+ }
+ }
+ return decoded.slice(pad + padSize);
+ },
+};