mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-11-24 05:11:16 +00:00
6a99b2e0d4
Differential Revision: https://phabricator.services.mozilla.com/D202962
880 lines
26 KiB
JavaScript
880 lines
26 KiB
JavaScript
/* 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/. */
|
|
|
|
const lazy = {};
|
|
|
|
ChromeUtils.defineLazyGetter(lazy, "gDOMBundle", () =>
|
|
Services.strings.createBundle("chrome://global/locale/dom/dom.properties")
|
|
);
|
|
|
|
// getCryptoParamsFromHeaders is exported for test purposes.
|
|
const UTF8 = new TextEncoder();
|
|
|
|
const ECDH_KEY = { name: "ECDH", namedCurve: "P-256" };
|
|
const ECDSA_KEY = { name: "ECDSA", namedCurve: "P-256" };
|
|
const HMAC_SHA256 = { name: "HMAC", hash: "SHA-256" };
|
|
const NONCE_INFO = UTF8.encode("Content-Encoding: nonce");
|
|
|
|
// A default keyid with a name that won't conflict with a real keyid.
|
|
const 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 lazy.gDOMBundle.formatStringFromName(this.property, params);
|
|
}
|
|
}
|
|
|
|
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, {});
|
|
}
|
|
|
|
// 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,
|
|
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.
|
|
export function getCryptoParamsFromHeaders(headers) {
|
|
if (!headers) {
|
|
return null;
|
|
}
|
|
|
|
var keymap;
|
|
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);
|
|
}
|
|
} 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
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
var salt = base64URLDecode(enc.salt);
|
|
if (!salt) {
|
|
throw new CryptoError("Invalid 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);
|
|
}
|
|
return {
|
|
salt,
|
|
rs,
|
|
senderKey,
|
|
};
|
|
}
|
|
|
|
// Decodes an unpadded, base64url-encoded string.
|
|
function base64URLDecode(string) {
|
|
if (!string) {
|
|
return null;
|
|
}
|
|
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;
|
|
}
|
|
|
|
function concatArray(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));
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function encodeLength(buffer) {
|
|
return new Uint8Array([0, buffer.byteLength]);
|
|
}
|
|
|
|
class Decoder {
|
|
/**
|
|
* Creates a decoder for decrypting an incoming push message.
|
|
*
|
|
* @param {JsonWebKey} privateKey The static subscription private key.
|
|
* @param {BufferSource} publicKey The static subscription public key.
|
|
* @param {BufferSource} authenticationSecret The subscription authentication
|
|
* secret, or `null` if not used by the scheme.
|
|
* @param {Object} cryptoParams An object containing the ephemeral sender
|
|
* public key, salt, and record size.
|
|
* @param {BufferSource} ciphertext The encrypted message data.
|
|
*/
|
|
constructor(
|
|
privateKey,
|
|
publicKey,
|
|
authenticationSecret,
|
|
cryptoParams,
|
|
ciphertext
|
|
) {
|
|
this.privateKey = privateKey;
|
|
this.publicKey = publicKey;
|
|
this.authenticationSecret = authenticationSecret;
|
|
this.senderKey = cryptoParams.senderKey;
|
|
this.salt = cryptoParams.salt;
|
|
this.rs = cryptoParams.rs;
|
|
this.ciphertext = ciphertext;
|
|
}
|
|
|
|
/**
|
|
* Derives the decryption keys and decodes the push message.
|
|
*
|
|
* @throws {CryptoError} if decryption fails.
|
|
* @returns {Uint8Array} The decrypted message data.
|
|
*/
|
|
async decode() {
|
|
if (this.ciphertext.byteLength === 0) {
|
|
// Zero length messages will be passed as null.
|
|
return null;
|
|
}
|
|
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, 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
|
|
// context about what went wrong, so we throw a generic error instead.
|
|
throw new CryptoError("Bad encryption", BAD_CRYPTO);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Computes the ECDH shared secret, used as the input key material for HKDF.
|
|
*
|
|
* @throws if the static or ephemeral ECDH keys are invalid.
|
|
* @returns {ArrayBuffer} The shared secret.
|
|
*/
|
|
async computeSharedSecret() {
|
|
let [appServerKey, subscriptionPrivateKey] = await Promise.all([
|
|
crypto.subtle.importKey("raw", this.senderKey, ECDH_KEY, false, [
|
|
"deriveBits",
|
|
]),
|
|
crypto.subtle.importKey("jwk", this.privateKey, ECDH_KEY, false, [
|
|
"deriveBits",
|
|
]),
|
|
]);
|
|
return crypto.subtle.deriveBits(
|
|
{ name: "ECDH", public: appServerKey },
|
|
subscriptionPrivateKey,
|
|
256
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Derives the content encryption key and nonce.
|
|
*
|
|
* @param {BufferSource} ikm The ECDH shared secret.
|
|
* @returns {Array} A `[gcmBits, nonce]` tuple.
|
|
*/
|
|
async deriveKeyAndNonce() {
|
|
throw new Error("Missing `deriveKeyAndNonce` implementation");
|
|
}
|
|
|
|
/**
|
|
* Decrypts and removes padding from an encrypted record.
|
|
*
|
|
* @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, 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), 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.
|
|
*/
|
|
unpadChunk() {
|
|
throw new Error("Missing `unpadChunk` implementation");
|
|
}
|
|
|
|
/** The record chunking size. */
|
|
get chunkSize() {
|
|
throw new Error("Missing `chunkSize` implementation");
|
|
}
|
|
}
|
|
|
|
class OldSchemeDecoder extends Decoder {
|
|
async decode() {
|
|
// For aesgcm and aesgcm128, the ciphertext length can't fall on a record
|
|
// boundary.
|
|
if (
|
|
this.ciphertext.byteLength > 0 &&
|
|
this.ciphertext.byteLength % this.chunkSize === 0
|
|
) {
|
|
throw new CryptoError("Encrypted data truncated", BAD_CRYPTO);
|
|
}
|
|
return super.decode();
|
|
}
|
|
|
|
/**
|
|
* For aesgcm, the padding length is a 16-bit unsigned big endian integer.
|
|
* For aesgcm128, the padding is an 8-bit integer.
|
|
*/
|
|
unpadChunk(decoded) {
|
|
if (decoded.length < this.padSize) {
|
|
throw new CryptoError("Decoded array is too short!", BAD_PADDING);
|
|
}
|
|
var pad = decoded[0];
|
|
if (this.padSize == 2) {
|
|
pad = (pad << 8) | decoded[1];
|
|
}
|
|
if (pad > decoded.length - this.padSize) {
|
|
throw new CryptoError("Padding is wrong!", BAD_PADDING);
|
|
}
|
|
// All padded bytes must be zero except the first one.
|
|
for (var i = this.padSize; i < this.padSize + pad; i++) {
|
|
if (decoded[i] !== 0) {
|
|
throw new CryptoError("Padding is wrong!", BAD_PADDING);
|
|
}
|
|
}
|
|
return decoded.slice(pad + this.padSize);
|
|
}
|
|
|
|
/**
|
|
* aesgcm and aesgcm128 don't account for the authentication tag as part of
|
|
* the record size.
|
|
*/
|
|
get chunkSize() {
|
|
return this.rs + 16;
|
|
}
|
|
|
|
get padSize() {
|
|
throw new Error("Missing `padSize` implementation");
|
|
}
|
|
}
|
|
|
|
/** New encryption scheme (draft-ietf-httpbis-encryption-encoding-06). */
|
|
|
|
const AES128GCM_ENCODING = "aes128gcm";
|
|
const AES128GCM_KEY_INFO = UTF8.encode("Content-Encoding: aes128gcm\0");
|
|
const AES128GCM_AUTH_INFO = UTF8.encode("WebPush: info\0");
|
|
const 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) {
|
|
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(AES128GCM_NONCE_INFO, 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). */
|
|
|
|
const AESGCM_ENCODING = "aesgcm";
|
|
const AESGCM_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm\0");
|
|
const AESGCM_AUTH_INFO = UTF8.encode("Content-Encoding: auth\0"); // note nul-terminus
|
|
const AESGCM_P256DH_INFO = UTF8.encode("P-256\0");
|
|
|
|
class aesgcmDecoder extends OldSchemeDecoder {
|
|
/**
|
|
* Derives the aesgcm decryption key and nonce. We mix the authentication
|
|
* secret with the ikm using HKDF. The context string for the PRK is
|
|
* "Content-Encoding: auth\0". The context string for the key and nonce is
|
|
* "Content-Encoding: <blah>\0P-256\0" then the length and value of both the
|
|
* receiver key and sender key.
|
|
*/
|
|
async deriveKeyAndNonce(ikm) {
|
|
// Since we are using an authentication secret, we need to run an extra
|
|
// round of HKDF with the authentication secret as salt.
|
|
let authKdf = new hkdf(this.authenticationSecret, ikm);
|
|
let prk = await authKdf.extract(AESGCM_AUTH_INFO, 32);
|
|
let prkKdf = new hkdf(this.salt, prk);
|
|
let keyInfo = concatArray([
|
|
AESGCM_KEY_INFO,
|
|
AESGCM_P256DH_INFO,
|
|
encodeLength(this.publicKey),
|
|
this.publicKey,
|
|
encodeLength(this.senderKey),
|
|
this.senderKey,
|
|
]);
|
|
let nonceInfo = concatArray([
|
|
NONCE_INFO,
|
|
new Uint8Array([0]),
|
|
AESGCM_P256DH_INFO,
|
|
encodeLength(this.publicKey),
|
|
this.publicKey,
|
|
encodeLength(this.senderKey),
|
|
this.senderKey,
|
|
]);
|
|
return Promise.all([
|
|
prkKdf.extract(keyInfo, 16),
|
|
prkKdf.extract(nonceInfo, 12),
|
|
]);
|
|
}
|
|
|
|
get padSize() {
|
|
return 2;
|
|
}
|
|
}
|
|
|
|
/** Oldest encryption scheme (draft-thomson-http-encryption-02). */
|
|
|
|
const AESGCM128_ENCODING = "aesgcm128";
|
|
const AESGCM128_KEY_INFO = UTF8.encode("Content-Encoding: aesgcm128");
|
|
|
|
class aesgcm128Decoder extends OldSchemeDecoder {
|
|
constructor(privateKey, publicKey, cryptoParams, ciphertext) {
|
|
super(privateKey, publicKey, null, cryptoParams, ciphertext);
|
|
}
|
|
|
|
/**
|
|
* The aesgcm128 scheme ignores the authentication secret, and uses
|
|
* "Content-Encoding: <blah>" for the context string. It should eventually
|
|
* be removed: bug 1230038.
|
|
*/
|
|
deriveKeyAndNonce(ikm) {
|
|
let prkKdf = new hkdf(this.salt, ikm);
|
|
return Promise.all([
|
|
prkKdf.extract(AESGCM128_KEY_INFO, 16),
|
|
prkKdf.extract(NONCE_INFO, 12),
|
|
]);
|
|
}
|
|
|
|
get padSize() {
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
export var PushCrypto = {
|
|
concatArray,
|
|
|
|
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.
|
|
*
|
|
* @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} payload The encrypted message payload.
|
|
* @returns {Uint8Array} The decrypted message data.
|
|
*/
|
|
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 (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();
|
|
},
|
|
|
|
/**
|
|
* 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 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]);
|
|
}
|
|
}
|