diff options
Diffstat (limited to 'dom/push/PushCrypto.jsm')
-rw-r--r-- | dom/push/PushCrypto.jsm | 454 |
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); + }, +}; |