Bug 1442128 - support aes128gcm encryption in PushCrypto.jsm. r?kitcambridge,mt
MozReview-Commit-ID: HqLxm7wuZuv
--- a/dom/push/PushCrypto.jsm
+++ b/dom/push/PushCrypto.jsm
@@ -443,16 +443,17 @@ class OldSchemeDecoder extends Decoder {
}
}
/** 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');
+var AES128GCM_NONCE_INFO = UTF8.encode('Content-Encoding: nonce\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) {
@@ -461,17 +462,17 @@ class aes128gcmDecoder extends Decoder {
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)
+ prkKdf.extract(AES128GCM_NONCE_INFO, 12)
]);
}
unpadChunk(decoded, last) {
let length = decoded.length;
while (length--) {
if (decoded[length] === 0) {
continue;
@@ -630,9 +631,142 @@ var PushCrypto = {
if (!decoder) {
throw new CryptoError('Unsupported Content-Encoding: ' + encoding,
BAD_ENCODING_HEADER);
}
return decoder.decode();
},
+
+ /**
+ * Encrypts a payload suitable for using in a push message. The encryption
+ * is always done with a record size of 4096 and no padding.
+ *
+ * @throws {CryptoError} if encryption fails.
+ * @param {plaintext} Uint8Array The plaintext to encrypt.
+ * @param {receiverPublicKey} Uint8Array The public key of the recipient
+ * of the message as a buffer.
+ * @param {receiverAuthSecret} Uint8Array The auth secret of the of the
+ * message recipient as a buffer.
+ * @param {options} Object Encryption options, used for tests.
+ * @returns {ciphertext, encoding} The encrypted payload and encoding.
+ */
+ async encrypt(plaintext, receiverPublicKey, receiverAuthSecret, options={}) {
+ const encoding = options.encoding || AES128GCM_ENCODING;
+ // We only support one encoding type.
+ if (encoding != AES128GCM_ENCODING) {
+ throw new CryptoError(`Only ${AES128GCM_ENCODING} is supported`,
+ BAD_ENCODING_HEADER);
+ }
+ // We typically use an ephemeral key for this message, but for testing
+ // purposes we allow it to be specified.
+ const senderKeyPair = options.senderKeyPair ||
+ await crypto.subtle.generateKey(ECDH_KEY, true, ["deriveBits"]);
+ // allowing a salt to be specified is useful for tests.
+ const salt = options.salt || crypto.getRandomValues(new Uint8Array(16));
+ const rs = options.rs === undefined ? 4096 : options.rs;
+
+ const encoder = new aes128gcmEncoder(plaintext, receiverPublicKey,
+ receiverAuthSecret, senderKeyPair,
+ salt, rs);
+ return encoder.encode();
+ },
};
+
+// A class for aes128gcm encryption - the only kind we support.
+class aes128gcmEncoder {
+ constructor(plaintext ,receiverPublicKey, receiverAuthSecret, senderKeyPair, salt, rs) {
+ this.receiverPublicKey = receiverPublicKey;
+ this.receiverAuthSecret = receiverAuthSecret;
+ this.senderKeyPair = senderKeyPair;
+ this.salt = salt;
+ this.rs = rs;
+ this.plaintext = plaintext;
+ }
+
+ async encode() {
+ const sharedSecret = await this.computeSharedSecret(this.receiverPublicKey,
+ this.senderKeyPair.privateKey);
+
+ const rawSenderPublicKey = await crypto.subtle.exportKey("raw", this.senderKeyPair.publicKey);
+ const [gcmBits, nonce] = await this.deriveKeyAndNonce(sharedSecret,
+ rawSenderPublicKey)
+
+ const contentEncryptionKey = await crypto.subtle.importKey("raw", gcmBits,
+ "AES-GCM", false,
+ ["encrypt"]);
+ const payloadHeader = this.createHeader(rawSenderPublicKey);
+
+ const ciphertextChunks = await this.encrypt(contentEncryptionKey, nonce);
+ return {ciphertext: concatArray([payloadHeader, ...ciphertextChunks]),
+ encoding: "aes128gcm"};
+ }
+
+ // Perform the actual encryption of the payload.
+ async encrypt(key, nonce) {
+ if (this.rs < 18) {
+ throw new CryptoError("recordsize is too small", BAD_RS_PARAM);
+ }
+
+ let chunks;
+ if (this.plaintext.byteLength === 0) {
+ // Send an authentication tag for empty messages.
+ chunks = [await crypto.subtle.encrypt({
+ name: "AES-GCM",
+ iv: generateNonce(nonce, 0)
+ }, key, new Uint8Array([2]))];
+ } else {
+ // Use specified recordsize, though we burn 1 for padding and 16 byte
+ // overhead.
+ let inChunks = chunkArray(this.plaintext, this.rs - 1 - 16);
+ chunks = await Promise.all(inChunks.map(async function (slice, index) {
+ let isLast = index == inChunks.length - 1;
+ let padding = new Uint8Array([isLast ? 2 : 1]);
+ let input = concatArray([slice, padding]);
+ return await crypto.subtle.encrypt({
+ name: "AES-GCM",
+ iv: generateNonce(nonce, index),
+ }, key, input);
+ }));
+ }
+ return chunks;
+ }
+
+ // Note: this is a dupe of aes128gcmDecoder.deriveKeyAndNonce, but tricky
+ // to rationalize without a larger refactor.
+ async deriveKeyAndNonce(sharedSecret, senderPublicKey) {
+ const authKdf = new hkdf(this.receiverAuthSecret, sharedSecret);
+ const authInfo = concatArray([AES128GCM_AUTH_INFO,
+ this.receiverPublicKey,
+ senderPublicKey]);
+ const prk = await authKdf.extract(authInfo, 32);
+ const prkKdf = new hkdf(this.salt, prk);
+ return Promise.all([
+ prkKdf.extract(AES128GCM_KEY_INFO, 16),
+ prkKdf.extract(AES128GCM_NONCE_INFO, 12),
+ ]);
+ }
+
+ // Note: this duplicates some of Decoder.computeSharedSecret, but the key
+ // management is slightly different.
+ async computeSharedSecret(receiverPublicKey, senderPrivateKey) {
+ const receiverPublicCryptoKey = await crypto.subtle.importKey("raw", receiverPublicKey,
+ ECDH_KEY, false, ["deriveBits"]);
+
+ return crypto.subtle.deriveBits({name: "ECDH", public: receiverPublicCryptoKey},
+ senderPrivateKey, 256);
+ }
+
+ // create aes128gcm's header.
+ createHeader(key) {
+ // layout is "salt|32-bit-int|8-bit-int|key"
+ if (key.byteLength != 65) {
+ throw new CryptoError("Invalid key length for header", BAD_DH_PARAM);
+ }
+ // the 2 ints
+ let ints = new Uint8Array(5);
+ let intsv = new DataView(ints.buffer);
+ intsv.setUint32(0, this.rs); // bigendian
+ intsv.setUint8(4, key.byteLength);
+ return concatArray([this.salt, ints, key]);
+ }
+}
--- a/dom/push/test/webpush.js
+++ b/dom/push/test/webpush.js
@@ -1,16 +1,20 @@
/*
* Browser-based Web Push client for the application server piece.
*
* Any copyright is dedicated to the Public Domain.
* http://creativecommons.org/licenses/publicdomain/
*
* Uses the WebCrypto API.
- * Uses the fetch API. Polyfill: https://github.com/github/fetch
+ *
+ * Note that this test file uses the old, deprecated aesgcm128 encryption
+ * scheme. PushCrypto.encrypt() exists and uses the later aes128gcm, but
+ * there's no good reason to upgrade this at this time (and having mochitests
+ * use PushCrypto directly is easier said than done.)
*/
(function (g) {
'use strict';
var P256DH = {
name: 'ECDH',
namedCurve: 'P-256'
new file mode 100644
--- /dev/null
+++ b/dom/push/test/xpcshell/test_crypto_encrypt.js
@@ -0,0 +1,147 @@
+// Test PushCrypto.encrypt()
+"use strict";
+
+Cu.importGlobalProperties(["crypto"]);
+
+const {PushCrypto} = ChromeUtils.import("resource://gre/modules/PushCrypto.jsm");
+
+let from64 = v => {
+ // allow whitespace in the strings.
+ let stripped = v.replace(/ |\t|\r|\n/g, '');
+ return new Uint8Array(ChromeUtils.base64URLDecode(stripped, {padding: "reject"}));
+}
+
+let to64 = v => ChromeUtils.base64URLEncode(v, {pad: false});
+
+// A helper function to take a public key (as a buffer containing a 65-byte
+// buffer of uncompressed EC points) and a private key (32byte buffer) and
+// return 2 crypto keys.
+async function importKeyPair(publicKeyBuffer, privateKeyBuffer) {
+ let jwk = {
+ kty: "EC",
+ crv: "P-256",
+ x: to64(publicKeyBuffer.slice(1, 33)),
+ y: to64(publicKeyBuffer.slice(33, 65)),
+ ext: true,
+ };
+ let publicKey = await crypto.subtle.importKey('jwk', jwk,
+ { name: 'ECDH', namedCurve: 'P-256' },
+ true, []);
+ jwk.d = to64(privateKeyBuffer);
+ let privateKey = await crypto.subtle.importKey('jwk', jwk,
+ { name: 'ECDH', namedCurve: 'P-256' },
+ true, ['deriveBits']);
+ return {publicKey, privateKey};
+}
+
+// The example from draft-ietf-webpush-encryption-09.
+add_task(async function static_aes128gcm() {
+ let fixture = {
+ ciphertext: from64(`DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml
+ mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT
+ pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN`),
+ plaintext: new TextEncoder("utf-8").encode("When I grow up, I want to be a watermelon"),
+ authSecret: from64("BTBZMqHH6r4Tts7J_aSIgg"),
+ receiver: {
+ private: from64("q1dXpw3UpT5VOmu_cf_v6ih07Aems3njxI-JWgLcM94"),
+ public: from64(`BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx
+ aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4`),
+ },
+ sender: {
+ private: from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"),
+ public: from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIg
+ Dll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`),
+ },
+ salt: from64("DGv6ra1nlYgDCS1FRnbzlw"),
+ };
+
+
+ let publicKeyBuffer = from64(`BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZ
+ IIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8`);
+ let privateKeyBuffer = from64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw");
+ let options = {
+ senderKeyPair: await importKeyPair(fixture.sender.public, fixture.sender.private),
+ salt: fixture.salt,
+ }
+
+ let {ciphertext, encoding} = await PushCrypto.encrypt(fixture.plaintext,
+ fixture.receiver.public,
+ fixture.authSecret,
+ options);
+
+ Assert.deepEqual(ciphertext, fixture.ciphertext);
+ Assert.equal(encoding, "aes128gcm");
+
+ // and for fun, decrypt it and check the plaintext.
+ let recvKeyPair = await importKeyPair(fixture.receiver.public, fixture.receiver.private);
+ let jwk = await crypto.subtle.exportKey("jwk", recvKeyPair.privateKey);
+ let plaintext = await PushCrypto.decrypt(jwk, fixture.receiver.public,
+ fixture.authSecret,
+ {encoding: "aes128gcm"},
+ ciphertext);
+ Assert.deepEqual(plaintext, fixture.plaintext);
+});
+
+// This is how we expect real code to interact with .encrypt.
+add_task(async function aes128gcm_simple() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ let message = new TextEncoder("utf-8").encode("Fast for good.");
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret);
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+ authSecret,
+ {encoding},
+ ciphertext);
+ deepEqual(message, plaintext);
+});
+
+// Variable record size tests
+add_task(async function aes128gcm_rs() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+ let payload = "x".repeat(1024 * 10);
+
+ for (let rs of [-1, 0, 1, 17]) {
+ info(`testing expected failure with rs=${rs}`);
+ let message = new TextEncoder("utf-8").encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ await Assert.rejects(PushCrypto.encrypt(message, recvPublicKey, authSecret, {rs}),
+ /recordsize is too small/);
+ }
+ for (let rs of [18, 50, 1024, 4096, 16384]) {
+ info(`testing expected success with rs=${rs}`);
+ let message = new TextEncoder("utf-8").encode(payload);
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret, {rs});
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+ authSecret,
+ {encoding},
+ ciphertext);
+ deepEqual(message, plaintext);
+ }
+});
+
+// And try and hit some edge-cases.
+add_task(async function aes128gcm_edgecases() {
+ let [recvPublicKey, recvPrivateKey] = await PushCrypto.generateKeys();
+
+ for (let size of [0, 4096-16, 4096-16-1, 4096-16+1,
+ 4095, 4096, 4097,
+ 1024*100]) {
+ info(`testing encryption of ${size} byte payload`);
+ let message = new TextEncoder("utf-8").encode("x".repeat(size));
+ let authSecret = crypto.getRandomValues(new Uint8Array(16));
+ let {ciphertext, encoding} = await PushCrypto.encrypt(message, recvPublicKey, authSecret);
+ Assert.equal(encoding, "aes128gcm");
+ // and decrypt it.
+ let plaintext = await PushCrypto.decrypt(recvPrivateKey, recvPublicKey,
+ authSecret,
+ {encoding},
+ ciphertext);
+ deepEqual(message, plaintext);
+ }
+});
--- a/dom/push/test/xpcshell/xpcshell.ini
+++ b/dom/push/test/xpcshell/xpcshell.ini
@@ -1,16 +1,17 @@
[DEFAULT]
head = head.js head-http2.js
# Push notifications and alarms are currently disabled on Android.
skip-if = toolkit == 'android'
[test_clear_forgetAboutSite.js]
[test_clear_origin_data.js]
[test_crypto.js]
+[test_crypto_encrypt.js]
[test_drop_expired.js]
[test_handler_service.js]
support-files = PushServiceHandler.js PushServiceHandler.manifest
[test_notification_ack.js]
[test_notification_data.js]
[test_notification_duplicate.js]
[test_notification_error.js]
[test_notification_incomplete.js]