2015-09-11 17:51:32 +03:00
|
|
|
/* 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.importGlobalProperties(['crypto']);
|
|
|
|
|
2015-09-17 15:08:50 +03:00
|
|
|
this.EXPORTED_SYMBOLS = ['PushCrypto', 'concatArray',
|
2015-12-04 00:24:47 +03:00
|
|
|
'getCryptoParams',
|
2015-09-17 15:08:50 +03:00
|
|
|
'base64UrlDecode'];
|
2015-09-11 17:51:32 +03:00
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
var UTF8 = new TextEncoder('utf-8');
|
2016-03-18 19:01:50 +03:00
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
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');
|
2015-12-04 00:24:47 +03:00
|
|
|
var ECDH_KEY = { name: 'ECDH', namedCurve: 'P-256' };
|
|
|
|
// A default keyid with a name that won't conflict with a real keyid.
|
|
|
|
var DEFAULT_KEYID = '';
|
2015-09-11 17:51:32 +03:00
|
|
|
|
2015-12-04 00:24:47 +03:00
|
|
|
function getEncryptionKeyParams(encryptKeyField) {
|
2015-12-08 23:26:42 +03:00
|
|
|
if (!encryptKeyField) {
|
|
|
|
return null;
|
|
|
|
}
|
2015-09-17 15:08:50 +03:00
|
|
|
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;
|
|
|
|
}
|
2015-12-04 00:24:47 +03:00
|
|
|
if (!m[DEFAULT_KEYID] && pmap.dh) {
|
|
|
|
m[DEFAULT_KEYID] = pmap.dh;
|
|
|
|
}
|
2015-09-17 15:08:50 +03:00
|
|
|
return m;
|
|
|
|
}, {});
|
2015-12-04 00:24:47 +03:00
|
|
|
}
|
2015-09-17 15:08:50 +03:00
|
|
|
|
2015-12-04 00:24:47 +03:00
|
|
|
function getEncryptionParams(encryptField) {
|
2015-09-17 15:08:50 +03:00
|
|
|
var p = encryptField.split(',', 1)[0];
|
|
|
|
if (!p) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
return p.split(';').reduce(parseHeaderFieldParams, {});
|
2015-12-04 00:24:47 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
this.getCryptoParams = function(headers) {
|
|
|
|
if (!headers) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
var requiresAuthenticationSecret = true;
|
2016-03-18 19:01:50 +03:00
|
|
|
var keymap;
|
|
|
|
var padSize;
|
|
|
|
if (headers.encoding == AESGCM_ENCODING) {
|
|
|
|
// aesgcm uses the Crypto-Key header, 2 bytes for the pad length, and an
|
|
|
|
// authentication secret.
|
|
|
|
keymap = getEncryptionKeyParams(headers.crypto_key);
|
|
|
|
padSize = 2;
|
|
|
|
} else if (headers.encoding == AESGCM128_ENCODING) {
|
|
|
|
// aesgcm128 uses Crypto-Key or Encryption-Key, and 1 byte for the pad
|
|
|
|
// length.
|
|
|
|
keymap = getEncryptionKeyParams(headers.crypto_key);
|
|
|
|
padSize = 1;
|
2015-12-04 00:24:47 +03:00
|
|
|
if (!keymap) {
|
2016-03-18 19:01:50 +03:00
|
|
|
// Encryption-Key header indicates unauthenticated encryption.
|
|
|
|
requiresAuthenticationSecret = false;
|
|
|
|
keymap = getEncryptionKeyParams(headers.encryption_key);
|
2015-12-04 00:24:47 +03:00
|
|
|
}
|
|
|
|
}
|
2016-03-18 19:01:50 +03:00
|
|
|
if (!keymap) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
2015-12-04 00:24:47 +03:00
|
|
|
var enc = getEncryptionParams(headers.encryption);
|
|
|
|
if (!enc) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
var dh = keymap[enc.keyid || DEFAULT_KEYID];
|
|
|
|
var salt = enc.salt;
|
|
|
|
var rs = (enc.rs)? parseInt(enc.rs, 10) : 4096;
|
|
|
|
|
2016-03-18 19:01:50 +03:00
|
|
|
if (!dh || !salt || isNaN(rs) || (rs <= padSize)) {
|
2015-12-04 00:24:47 +03:00
|
|
|
return null;
|
|
|
|
}
|
2016-03-18 19:01:50 +03:00
|
|
|
return {dh, salt, rs, auth: requiresAuthenticationSecret, padSize};
|
2015-12-04 00:24:47 +03:00
|
|
|
}
|
2015-09-17 15:08:50 +03:00
|
|
|
|
|
|
|
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()
|
2015-12-08 23:26:42 +03:00
|
|
|
.replace(/^"(.*)"$/, '$1');
|
2015-09-17 15:08:50 +03:00
|
|
|
}
|
|
|
|
return m;
|
|
|
|
};
|
|
|
|
|
2015-09-11 17:51:32 +03:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2015-09-17 15:08:50 +03:00
|
|
|
this.base64UrlDecode = function(s) {
|
2015-09-11 17:51:32 +03:00
|
|
|
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
|
|
|
|
|
|
// Replace padding if it was stripped by the sender.
|
|
|
|
// See http://tools.ietf.org/html/rfc4648#section-4
|
|
|
|
switch (s.length % 4) {
|
|
|
|
case 0:
|
|
|
|
break; // No pad chars in this case
|
|
|
|
case 2:
|
|
|
|
s += '==';
|
|
|
|
break; // Two pad chars
|
|
|
|
case 3:
|
|
|
|
s += '=';
|
|
|
|
break; // One pad char
|
|
|
|
default:
|
|
|
|
throw new Error('Illegal base64url string!');
|
|
|
|
}
|
|
|
|
|
|
|
|
// With correct padding restored, apply the standard base64 decoder
|
|
|
|
var decoded = atob(s);
|
|
|
|
|
|
|
|
var array = new Uint8Array(new ArrayBuffer(decoded.length));
|
|
|
|
for (var i = 0; i < decoded.length; i++) {
|
|
|
|
array[i] = decoded.charCodeAt(i);
|
|
|
|
}
|
|
|
|
return array;
|
2015-09-17 15:08:50 +03:00
|
|
|
};
|
2015-09-11 17:51:32 +03:00
|
|
|
|
|
|
|
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));
|
|
|
|
}
|
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
hkdf.prototype.extract = function(info, len) {
|
2015-09-11 17:51:32 +03:00
|
|
|
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');
|
|
|
|
}
|
|
|
|
return h.slice(0, len);
|
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
/* generate a 96-bit nonce for use in GCM, 48-bits of which are populated */
|
2015-09-11 17:51:32 +03:00
|
|
|
function generateNonce(base, index) {
|
|
|
|
if (index >= Math.pow(2, 48)) {
|
2015-12-08 23:26:42 +03:00
|
|
|
throw new Error('Error generating nonce - index is too large.');
|
2015-09-11 17:51:32 +03:00
|
|
|
}
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2015-09-17 15:08:50 +03:00
|
|
|
this.PushCrypto = {
|
2015-09-11 17:51:32 +03:00
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
generateAuthenticationSecret() {
|
2016-03-17 05:53:19 +03:00
|
|
|
return crypto.getRandomValues(new Uint8Array(16));
|
2015-12-08 23:26:42 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
generateKeys() {
|
2015-12-04 00:24:47 +03:00
|
|
|
return crypto.subtle.generateKey(ECDH_KEY, true, ['deriveBits'])
|
2015-09-11 17:51:32 +03:00
|
|
|
.then(cryptoKey =>
|
|
|
|
Promise.all([
|
|
|
|
crypto.subtle.exportKey('raw', cryptoKey.publicKey),
|
|
|
|
crypto.subtle.exportKey('jwk', cryptoKey.privateKey)
|
|
|
|
]));
|
|
|
|
},
|
|
|
|
|
2016-03-18 19:01:50 +03:00
|
|
|
decodeMsg(aData, aPrivateKey, aPublicKey, aSenderPublicKey, aSalt, aRs,
|
|
|
|
aAuthenticationSecret, aPadSize) {
|
2015-09-11 17:51:32 +03:00
|
|
|
|
|
|
|
if (aData.byteLength === 0) {
|
|
|
|
// Zero length messages will be passed as null.
|
|
|
|
return Promise.resolve(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'));
|
|
|
|
}
|
|
|
|
|
2015-12-08 23:26:42 +03:00
|
|
|
let senderKey = base64UrlDecode(aSenderPublicKey)
|
2015-09-11 17:51:32 +03:00
|
|
|
return Promise.all([
|
2015-12-04 00:24:47 +03:00
|
|
|
crypto.subtle.importKey('raw', senderKey, ECDH_KEY,
|
|
|
|
false, ['deriveBits']),
|
|
|
|
crypto.subtle.importKey('jwk', aPrivateKey, ECDH_KEY,
|
|
|
|
false, ['deriveBits'])
|
2015-09-11 17:51:32 +03:00
|
|
|
])
|
2015-12-04 00:24:47 +03:00
|
|
|
.then(([appServerKey, subscriptionPrivateKey]) =>
|
|
|
|
crypto.subtle.deriveBits({ name: 'ECDH', public: appServerKey },
|
|
|
|
subscriptionPrivateKey, 256))
|
2016-03-18 19:01:50 +03:00
|
|
|
.then(ikm => this._deriveKeyAndNonce(aPadSize,
|
|
|
|
new Uint8Array(ikm),
|
2015-12-08 23:26:42 +03:00
|
|
|
base64UrlDecode(aSalt),
|
2015-12-04 00:24:47 +03:00
|
|
|
aPublicKey,
|
2015-12-08 23:26:42 +03:00
|
|
|
senderKey,
|
|
|
|
aAuthenticationSecret))
|
2015-09-11 17:51:32 +03:00
|
|
|
.then(r =>
|
|
|
|
// AEAD_AES_128_GCM expands ciphertext to be 16 octets longer.
|
|
|
|
Promise.all(chunkArray(aData, aRs + 16).map((slice, index) =>
|
2016-03-18 19:01:50 +03:00
|
|
|
this._decodeChunk(aPadSize, slice, index, r[1], r[0]))))
|
2015-09-11 17:51:32 +03:00
|
|
|
.then(r => concatArray(r));
|
|
|
|
},
|
|
|
|
|
2016-03-18 19:01:50 +03:00
|
|
|
_deriveKeyAndNonce(padSize, ikm, salt, receiverKey, senderKey,
|
|
|
|
authenticationSecret) {
|
2015-12-08 23:26:42 +03:00
|
|
|
var kdfPromise;
|
|
|
|
var context;
|
2016-03-18 19:01:50 +03:00
|
|
|
var encryptInfo;
|
2015-12-08 23:26:42 +03:00
|
|
|
// The authenticationSecret, when present, is mixed with the ikm using HKDF.
|
|
|
|
// This is its primary purpose. However, since the authentication secret
|
|
|
|
// was added at the same time that the info string was changed, we also use
|
|
|
|
// its presence to change how the final info string is calculated:
|
|
|
|
//
|
|
|
|
// 1. When there is no authenticationSecret, the context string is simply
|
|
|
|
// "Content-Encoding: <blah>". This corresponds to old, deprecated versions
|
|
|
|
// of the content encoding. This should eventually be removed: bug 1230038.
|
|
|
|
//
|
|
|
|
// 2. When there is an authenticationSecret, the context string is:
|
|
|
|
// "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
|
|
|
|
// receiver key and sender key.
|
|
|
|
if (authenticationSecret) {
|
|
|
|
// 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));
|
|
|
|
|
|
|
|
// We also use the presence of the authentication secret to indicate that
|
|
|
|
// we have extra context to add to the info parameter.
|
|
|
|
context = concatArray([
|
|
|
|
new Uint8Array([0]), P256DH_INFO,
|
|
|
|
this._encodeLength(receiverKey), receiverKey,
|
|
|
|
this._encodeLength(senderKey), senderKey
|
|
|
|
]);
|
2016-03-18 19:01:50 +03:00
|
|
|
// Finally, we use the pad size to infer the content encoding.
|
|
|
|
encryptInfo = padSize == 2 ? AESGCM_ENCRYPT_INFO :
|
|
|
|
AESGCM128_ENCRYPT_INFO;
|
2015-12-08 23:26:42 +03:00
|
|
|
} else {
|
2016-03-18 19:01:50 +03:00
|
|
|
if (padSize == 2) {
|
|
|
|
throw new Error("aesgcm encoding requires an authentication secret");
|
|
|
|
}
|
2015-12-08 23:26:42 +03:00
|
|
|
kdfPromise = Promise.resolve(new hkdf(salt, ikm));
|
|
|
|
context = new Uint8Array(0);
|
2016-03-18 19:01:50 +03:00
|
|
|
encryptInfo = AESGCM128_ENCRYPT_INFO;
|
2015-12-08 23:26:42 +03:00
|
|
|
}
|
|
|
|
return kdfPromise.then(kdf => Promise.all([
|
2016-03-18 19:01:50 +03:00
|
|
|
kdf.extract(concatArray([encryptInfo, context]), 16)
|
2015-12-08 23:26:42 +03:00
|
|
|
.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]);
|
|
|
|
},
|
|
|
|
|
2016-03-18 19:01:50 +03:00
|
|
|
_decodeChunk(aPadSize, aSlice, aIndex, aNonce, aKey) {
|
2015-12-08 23:26:42 +03:00
|
|
|
let params = {
|
|
|
|
name: 'AES-GCM',
|
|
|
|
iv: generateNonce(aNonce, aIndex)
|
|
|
|
};
|
|
|
|
return crypto.subtle.decrypt(params, aKey, aSlice)
|
2016-03-18 19:01:50 +03:00
|
|
|
.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 Error('Unsupported pad size');
|
|
|
|
}
|
|
|
|
if (decoded.length < padSize) {
|
|
|
|
throw new Error('Decoded array is too short!');
|
|
|
|
}
|
|
|
|
var pad = decoded[0];
|
|
|
|
if (padSize == 2) {
|
|
|
|
pad = (pad << 8) | decoded[1];
|
|
|
|
}
|
|
|
|
if (pad > decoded.length) {
|
|
|
|
throw new Error ('Padding is wrong!');
|
|
|
|
}
|
|
|
|
// 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!');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return decoded.slice(pad + padSize);
|
|
|
|
},
|
2015-09-11 17:51:32 +03:00
|
|
|
};
|