Source code

Revision control

Copy as Markdown

Other Tools

/* 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/. */
// This module expects to be able to load in both main-thread module contexts,
// as well as ChromeWorker contexts. Do not ChromeUtils.importESModule
// anything there at the top-level that's not compatible with both contexts.
export const ArchiveUtils = {
/**
* Convert an array containing only two bytes unsigned numbers to a base64
* encoded string.
*
* @param {number[]} anArray
* The array that needs to be converted.
* @returns {string}
* The string representation of the array.
*/
arrayToBase64(anArray) {
let result = "";
let bytes = new Uint8Array(anArray);
for (let i = 0; i < bytes.length; i++) {
result += String.fromCharCode(bytes[i]);
}
return btoa(result);
},
/**
* Convert a base64 encoded string to an Uint8Array.
*
* @param {string} base64Str
* The base64 encoded string that needs to be converted.
* @returns {Uint8Array[]}
* The array representation of the string.
*/
stringToArray(base64Str) {
let binaryStr = atob(base64Str);
let len = binaryStr.length;
let bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return bytes;
},
/**
* The current shared schema version between the BackupManifest and the
* ArchiveJSONBlock schemas.
*
* @type {number}
*/
get SCHEMA_VERSION() {
return 1;
},
/**
* The version of the single-file archive that this version of the
* application is expected to produce. Versions greater than this are not
* interpretable by the application, and will cause an exception to be
* thrown when loading the archive.
*
* Note: Until we can interpolate strings in our templates, changing this
* value will require manual changes to the archive.template.html version
* number in the header, as well as any test templates.
*
* @type {number}
*/
get ARCHIVE_FILE_VERSION() {
return 1;
},
/**
* The HTML document comment start block, also indicating the start of the
* inline MIME message block.
*
* @type {string}
*/
get INLINE_MIME_START_MARKER() {
return "<!-- Begin inline MIME --";
},
/**
* The HTML document comment end block, also indicating the end of the
* inline MIME message block.
*
* @type {string}
*/
get INLINE_MIME_END_MARKER() {
return "---- End inline MIME -->";
},
/**
* The maximum number of bytes to read and encode when constructing the
* single-file archive.
*
* @type {number}
*/
get ARCHIVE_CHUNK_MAX_BYTES_SIZE() {
return 1048576; // 2 ^ 20 bytes, per guidance from security engineering.
},
/**
* The maximum size of a backup archive, in bytes, prior to base64 encoding.
*
* @type {number}
*/
get ARCHIVE_MAX_BYTES_SIZE() {
return 34359738368; // 2 ^ 35 bytes (32 GiB)
},
/**
* The AES-GCM tag length applied to each encrypted chunk, in bits.
*
* @type {number}
*/
get TAG_LENGTH() {
return 128;
},
/**
* The AES-GCM tag length applied to each encrypted chunk, in bytes.
*
* @type {number}
*/
get TAG_LENGTH_BYTES() {
return this.TAG_LENGTH / 8;
},
/**
* @typedef {object} ComputeKeysResult
* @property {Uint8Array} backupAuthKey
* The computed BackupAuthKey. This is returned as a Uint8Array because
* this key is used as a salt for other derived keys.
* @property {CryptoKey} backupEncKey
* The computed BackupEncKey. This is an AES-GCM key used to encrypt and
* decrypt the secrets contained within a backup archive.
*/
/**
* Computes the BackupAuthKey and BackupEncKey from a recovery code and a
* salt.
*
* @param {string} recoveryCode
* A recovery code. Callers are responsible for checking the length /
* entropy of the recovery code.
* @param {Uint8Array} salt
* A salt that should be used for computing the keys.
* @returns {ComputeKeysResult}
*/
async computeBackupKeys(recoveryCode, salt) {
let textEncoder = new TextEncoder();
let recoveryCodeBytes = textEncoder.encode(recoveryCode);
let keyMaterial = await crypto.subtle.importKey(
"raw",
recoveryCodeBytes,
"PBKDF2",
false /* extractable */,
["deriveBits"]
);
// Then we derive the "backup key", using
// PBKDF2(recoveryCode, saltPrefix || SALT_SUFFIX, SHA-256, 600,000)
const ITERATIONS = 600_000;
let backupKeyBits = await crypto.subtle.deriveBits(
{
name: "PBKDF2",
salt,
iterations: ITERATIONS,
hash: "SHA-256",
},
keyMaterial,
256
);
// This is a little awkward, but the way that the WebCrypto API currently
// works is that we have to read in those bits as a "raw HKDF key", and
// only then can we derive our other HKDF keys from it.
let backupKeyHKDF = await crypto.subtle.importKey(
"raw",
backupKeyBits,
{
name: "HKDF",
hash: "SHA-256",
},
false /* extractable */,
["deriveKey", "deriveBits"]
);
// Re-derive BackupAuthKey as HKDF(backupKey, “backupkey-auth”, salt=None)
let backupAuthKey = new Uint8Array(
await crypto.subtle.deriveBits(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-auth"),
hash: "SHA-256",
},
backupKeyHKDF,
256
)
);
let backupEncKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
salt: new Uint8Array(0), // no salt
info: textEncoder.encode("backupkey-enc-key"),
hash: "SHA-256",
},
backupKeyHKDF,
{ name: "AES-GCM", length: 256 },
false /* extractable */,
["encrypt", "decrypt", "wrapKey"]
);
return { backupAuthKey, backupEncKey };
},
/**
* @typedef {object} ComputeEncryptionKeysResult
* @property {CryptoKey} archiveEncKey
* This is an AES-GCM key used to encrypt chunks of a backup archive.
* @property {CryptoKey} authKey
* This is a unique authKey for a particular backup that lets us
* generate the confirmation HMAC for the backup metadata.
*/
/**
* Computes the encryption keys for a particular archive.
*
* @param {Uint8Array} archiveKeyMaterial
* The key material used to generate the encryption keys.
* @param {Uint8Array} backupAuthKey
* The backupAuthKey returned from computeBackupKeys.
* @returns {ComputeEncryptionKeysResult}
*/
async computeEncryptionKeys(archiveKeyMaterial, backupAuthKey) {
let archiveKey = await crypto.subtle.importKey(
"raw",
archiveKeyMaterial,
{ name: "HKDF" },
false, // Not extractable
["deriveKey", "deriveBits"]
);
let textEncoder = new TextEncoder();
// Derive the EncKey as HKDF(salt=BackupAuthkey, key=ArchiveKey,info=’archive-enc-key’)
let archiveEncKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
salt: backupAuthKey,
info: textEncoder.encode("archive-enc-key"),
hash: "SHA-256",
},
archiveKey,
{ name: "AES-GCM", length: 256 },
true /* extractable */,
["decrypt", "encrypt"]
);
// Derive the AuthKey as HKDF(salt=BackupAuthkey, key=ArchiveKey, info=‘archive-auth-key’)
// Note - this is distinct for this particular backup. It is not the same as
// the BackupAuthKey from ArchiveEncryptionState. It only uses the
// BackupAuthKey from the ArchiveEncryptionState as a salt.
let authKey = await crypto.subtle.deriveKey(
{
name: "HKDF",
salt: backupAuthKey,
info: textEncoder.encode("archive-auth-key"),
hash: "SHA-256",
},
archiveKey,
{ name: "HMAC", hash: "SHA-256", length: 256 },
false /* extractable */,
["sign", "verify"]
);
return { archiveEncKey, authKey };
},
/**
* Given a string decoded from a byte buffer by `TextDecoder.decode`,
* returns the number of bytes (0-3) at the start of the string that
* could not be decoded.
*
* This assumes undecoded content will only appear at the beginning of
* the string. This also assumes undecoded content spans no more than
* 3 bytes. These assumptions are based on running `TextDecoder.decode`
* on an arbitrary span of bytes from a valid UTF-8 string.
*
* @param {string} str
* String whose beginning you want to inspect for the Unicode replacement
* character: U+FFFD (�).
* @returns {number}
* Number of characters, between 0 and 3, at the beginning of the string
* that could not be decoded by `TextDecoder` and so were replaced by the
* Unicode replacement character: U+FFFD (�).
*
*
* @example countReplacementCharacters("\uFFFD\uFFFD\uFFFD🌞") == 3
* @example countReplacementCharacters("\uFFFD\uFFFD🌞") == 2
* @example countReplacementCharacters("\uFFFD🌞") == 1
* @example countReplacementCharacters("🌞") == 0
*/
countReplacementCharacters(str) {
let count = 0;
let lengthToCheck = Math.min(4, str.length);
for (let index = 0; index < lengthToCheck; index += 1) {
if (str[index] == "\uFFFD") {
count += 1;
}
}
return count;
},
};