--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -103,29 +103,47 @@ function getEncryptionParams(encryptFiel
var p = encryptField.split(',', 1)[0];
if (!p) {
throw new CryptoError('Encryption header missing params',
BAD_ENCRYPTION_HEADER);
}
return p.split(';').reduce(parseHeaderFieldParams, {});
}
+// Extracts the sender public key, salt, and record size from the payload for the
+// aes128gcm scheme.
+function getCryptoParamsFromPayload(payload) {
+ if (payload.byteLength < 21) {
+ throw new CryptoError('Truncated header', BAD_CRYPTO);
+ }
+ let rs = (payload[16] << 24) | (payload[17] << 16) | (payload[18] << 8) | payload[19];
+ let keyIdLen = payload[20];
+ if (keyIdLen != 65) {
+ throw new CryptoError('Invalid sender public key', BAD_DH_PARAM);
+ }
+ if (payload.byteLength <= 21 + keyIdLen) {
+ throw new CryptoError('Truncated payload', BAD_CRYPTO);
+ }
+ return {
+ salt: payload.slice(0, 16),
+ rs: rs,
+ senderKey: payload.slice(21, 21 + keyIdLen),
+ ciphertext: payload.slice(21 + keyIdLen),
+ };
+}
+
// Extracts the sender public key, salt, and record size from the `Crypto-Key`,
// `Encryption-Key`, and `Encryption` headers for the aesgcm and aesgcm128
// schemes.
function getCryptoParamsFromHeaders(headers) {
if (!headers) {
return null;
}
var keymap;
- 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);
@@ -133,19 +151,16 @@ function getCryptoParamsFromHeaders(head
} 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);
}
- } else {
- throw new CryptoError('Unsupported Content-Encoding: ' + headers.encoding,
- BAD_ENCODING_HEADER);
}
var enc = getEncryptionParams(headers.encryption);
var dh = keymap[enc.keyid || DEFAULT_KEYID];
var senderKey = base64URLDecode(dh);
if (!senderKey) {
throw new CryptoError('Invalid dh parameter', BAD_DH_PARAM);
}
@@ -298,17 +313,18 @@ class Decoder {
}
try {
let ikm = await this.computeSharedSecret();
let [gcmBits, nonce] = await this.deriveKeyAndNonce(ikm);
let key = await crypto.subtle.importKey('raw', gcmBits, 'AES-GCM', false,
['decrypt']);
let r = await Promise.all(chunkArray(this.ciphertext, this.chunkSize)
- .map((slice, index) => this.decodeChunk(slice, index, nonce, key)));
+ .map((slice, index, chunks) => this.decodeChunk(slice, index, nonce,
+ key, index >= chunks.length - 1)));
return concatArray(r);
} 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
@@ -350,23 +366,23 @@ class Decoder {
* @throws {CryptoError} if decryption fails or padding is incorrect.
* @param {Uint8Array} slice The encrypted record.
* @param {Number} index The record sequence number.
* @param {Uint8Array} nonce The nonce base, used to generate the IV.
* @param {Uint8Array} key The content encryption key.
* @param {Boolean} last Indicates if this is the final record.
* @returns {Uint8Array} The decrypted block with padding removed.
*/
- async decodeChunk(slice, index, nonce, key) {
+ async decodeChunk(slice, index, nonce, key, last) {
let params = {
name: 'AES-GCM',
iv: generateNonce(nonce, index)
};
let decoded = await crypto.subtle.decrypt(params, key, slice);
- return this.unpadChunk(new Uint8Array(decoded));
+ return this.unpadChunk(new Uint8Array(decoded), last);
}
/**
* Removes padding from a decrypted block.
*
* @throws {CryptoError} if padding is missing or invalid.
* @param {Uint8Array} chunk The decrypted block with padding.
* @returns {Uint8Array} The block with padding removed.
@@ -424,16 +440,64 @@ class OldSchemeDecoder extends Decoder {
return this.rs + 16;
}
get padSize() {
throw new Error('Missing `padSize` implementation');
}
}
+/** 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');
+
+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) {
+ let authKdf = new hkdf(this.authenticationSecret, ikm);
+ let authInfo = concatArray([
+ 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)
+ ]);
+ }
+
+ unpadChunk(decoded, last) {
+ let length = decoded.length;
+ while (length--) {
+ if (decoded[length] === 0) {
+ continue;
+ }
+ let recordPad = last ? 2 : 1;
+ if (decoded[length] != recordPad) {
+ throw new CryptoError('Padding is wrong!', BAD_PADDING);
+ }
+ return decoded.slice(0, length);
+ }
+ throw new CryptoError('Zero plaintext', BAD_PADDING);
+ }
+
+ /** aes128gcm accounts for the authentication tag in the record size. */
+ get chunkSize() {
+ return this.rs;
+ }
+}
+
/** Older encryption scheme (draft-ietf-httpbis-encryption-encoding-01). */
var AESGCM_ENCODING = 'aesgcm';
var AESGCM_KEY_INFO = UTF8.encode('Content-Encoding: aesgcm\0');
var AESGCM_AUTH_INFO = UTF8.encode('Content-Encoding: auth\0'); // note nul-terminus
var AESGCM_P256DH_INFO = UTF8.encode('P-256\0');
class aesgcmDecoder extends OldSchemeDecoder {
@@ -526,30 +590,51 @@ this.PushCrypto = {
* @throws {CryptoError} if decryption fails.
* @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 from the push server.
- * @param {BufferSource} ciphertext The encrypted message data.
+ * @param {BufferSource} payload The encrypted message payload.
* @returns {Uint8Array} The decrypted message data.
*/
- async decrypt(privateKey, publicKey, authenticationSecret, headers,
- ciphertext) {
- // aesgcm and aesgcm128 include the salt, record size, and sender public
- // key in the `Crypto-Key` and `Encryption` HTTP headers.
- let cryptoParams = getCryptoParamsFromHeaders(headers);
- if (!cryptoParams) {
+ async decrypt(privateKey, publicKey, authenticationSecret, headers, payload) {
+ if (!headers) {
return null;
}
+
+ let encoding = headers.encoding;
+ if (!headers.encoding) {
+ throw new CryptoError('Missing Content-Encoding header',
+ BAD_ENCODING_HEADER);
+ }
+
let decoder;
- if (headers.encoding == AESGCM_ENCODING) {
- decoder = new aesgcmDecoder(privateKey, publicKey, authenticationSecret,
- cryptoParams, ciphertext);
- } else {
- decoder = new aesgcm128Decoder(privateKey, publicKey, cryptoParams,
- ciphertext);
+ if (encoding == AES128GCM_ENCODING) {
+ // aes128gcm includes the salt, record size, and sender public key in a
+ // binary header preceding the ciphertext.
+ let cryptoParams = getCryptoParamsFromPayload(new Uint8Array(payload));
+ decoder = new aes128gcmDecoder(privateKey, publicKey,
+ authenticationSecret, cryptoParams,
+ cryptoParams.ciphertext);
+ } else if (encoding == AESGCM128_ENCODING || encoding == AESGCM_ENCODING) {
+ // aesgcm and aesgcm128 include the salt, record size, and sender public
+ // key in the `Crypto-Key` and `Encryption` HTTP headers.
+ let cryptoParams = getCryptoParamsFromHeaders(headers);
+ if (headers.encoding == AESGCM_ENCODING) {
+ decoder = new aesgcmDecoder(privateKey, publicKey, authenticationSecret,
+ cryptoParams, payload);
+ } else {
+ decoder = new aesgcm128Decoder(privateKey, publicKey, cryptoParams,
+ payload);
+ }
}
+
+ if (!decoder) {
+ throw new CryptoError('Unsupported Content-Encoding: ' + encoding,
+ BAD_ENCODING_HEADER);
+ }
+
return decoder.decode();
},
};
--- a/dom/push/test/xpcshell/test_crypto.js
+++ b/dom/push/test/xpcshell/test_crypto.js
@@ -1,15 +1,17 @@
'use strict';
const {
getCryptoParamsFromHeaders,
PushCrypto,
} = Cu.import('resource://gre/modules/PushCrypto.jsm', {});
+const REJECT_PADDING = { padding: 'reject' };
+
function run_test() {
run_next_test();
}
add_task(async function test_crypto_getCryptoParamsFromHeaders() {
// These headers should parse correctly.
let shouldParse = [{
desc: 'aesgcm with multiple keys',
@@ -69,22 +71,19 @@ add_task(async function test_crypto_getC
params: {
senderKey: 'VA6wmY1IpiE',
salt: 'khtpyXhpDKM',
rs: 4096,
}
}];
for (let test of shouldParse) {
let params = getCryptoParamsFromHeaders(test.headers);
- let senderKey = ChromeUtils.base64URLDecode(test.params.senderKey, {
- padding: 'reject',
- });
- let salt = ChromeUtils.base64URLDecode(test.params.salt, {
- padding: 'reject',
- });
+ let senderKey = ChromeUtils.base64URLDecode(test.params.senderKey,
+ REJECT_PADDING);
+ let salt = ChromeUtils.base64URLDecode(test.params.salt, REJECT_PADDING);
deepEqual(new Uint8Array(params.senderKey), new Uint8Array(senderKey),
"Sender key should match for " + test.desc);
deepEqual(new Uint8Array(params.salt), new Uint8Array(salt),
"Salt should match for " + test.desc);
equal(params.rs, test.params.rs,
"Record size should match for " + test.desc);
}
@@ -241,8 +240,51 @@ add_task(function* test_crypto_decodeMsg
});
yield rejects(
PushCrypto.decrypt(privateKey, publicKey, authSecret,
test.headers, data),
test.desc
);
}
});
+
+add_task(async function test_aes128gcm() {
+ let expectedSuccesses = [{
+ desc: 'Example from draft-ietf-webpush-encryption-latest',
+ result: 'When I grow up, I want to be a watermelon',
+ data: 'DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPTpK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN',
+ authSecret: 'BTBZMqHH6r4Tts7J_aSIgg',
+ privateKey: {
+ kty: 'EC',
+ crv: 'P-256',
+ d: 'q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94',
+ x: 'JXGyvs3942BVGq8e0PTNNmwRzr5VX4m8t7GGpTM5FzE',
+ y: 'aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4',
+ ext: true,
+ },
+ publicKey: 'BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4',
+ }, {
+ desc: 'rs = 24',
+ result: "I am the very model of a modern Major-General; I've information vegetable, animal, and mineral",
+ data: 'goagSH7PP0ZGwUsgShmdkwAAABhBBDJVyIuUJbOSVMeWHP8VNPnxY-dZSw86doqOkEzZZZY1ALBWVXTVf0rUDH3oi68I9Hrp-01zA-mr8XKWl5kcH8cX0KiV2PtCwdkEyaQ73YF5fsDxgoWDiaTA3wPqMvuLDqGsZWHnE9Psnfoy7UMEqKlh2a1nE7ZOXiXcOBHLNj260jYzSJnEPV2eXixSXfyWpaSJHAwfj4wVdAAocmViIg6ywk8wFB1hgJpnX2UVEU_qIOcaP6AOIOr1UUQPfosQqC2MEHe5u9gHXF5pi-E267LAlkoYefq01KV_xK_vjbxpw8GAYfSjQEm0L8FG-CN37c8pnQ2Yf61MkihaXac9OctfNeWq_22cN6hn4qsOq0F7QoWIiZqWhB1vS9cJ3KUlyPQvKI9cvevDxw0fJHWeTFzhuwT9BjdILjjb2Vkqc0-qTDOawqD4c8WXsvdGDQCec5Y1x3UhdQXdjR_mhXypxFM37OZTvKJBr1vPCpRXl-bI6iOd7KScgtMM1x5luKhGzZyz25HyuFyj1ec82A',
+ authSecret: '_tK2LDGoIt6be6agJ_nvGA',
+ privateKey: {
+ kty: 'EC',
+ crv: 'P-256',
+ d: 'bGViEe3PvjjFJg8lcnLsqu71b2yqWGnZN9J2MTed-9s',
+ x: 'auB0GHF0AZ2LAocFnvOXDS7EeCMopnzbg-tS21FMHrU',
+ y: 'GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc',
+ ext: true,
+ },
+ publicKey: 'BGrgdBhxdAGdiwKHBZ7zlw0uxHgjKKZ824PrUttRTB61GpbhrW-_xKj3XhhXA-kDZSicKZ0kn0BuVhqzhLOB-Cc',
+ }];
+ for (let test of expectedSuccesses) {
+ let publicKey = ChromeUtils.base64URLDecode(test.publicKey, REJECT_PADDING);
+ let authSecret = ChromeUtils.base64URLDecode(test.authSecret,
+ REJECT_PADDING);
+ let payload = ChromeUtils.base64URLDecode(test.data, REJECT_PADDING);
+ let result = await PushCrypto.decrypt(test.privateKey, publicKey,
+ authSecret,
+ { encoding: 'aes128gcm' }, payload);
+ let decoder = new TextDecoder('utf-8');
+ equal(decoder.decode(new Uint8Array(result)), test.result, test.desc);
+ }
+});