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
// 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.
// The ArchiveUtils module is designed to be imported in both worker and
// main thread contexts.
import { ArchiveUtils } from "resource:///modules/backup/ArchiveUtils.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(
lazy,
{
BackupError: "resource:///modules/backup/BackupError.mjs",
ERRORS: "chrome://browser/content/backup/backup-constants.mjs",
},
{ global: "contextual" }
);
/**
* Both ArchiveEncryptor and ArchiveDecryptor maintain an internal nonce used as
* a big-endian chunk counter. That counter is Uint8Array(16) array, which makes
* doing simple things like adding to the counter somewhat cumbersome.
* NonceUtils contains helper methods to do nonce-related management and
* arithmetic.
*/
export const NonceUtils = {
/**
* Flips the bit in the nonce to indicate that the nonce will be used for the
* last chunk to be encrypted. The specification calls for this bit to be the
* 12th bit from the end.
*
* @param {Uint8Array} nonce
* The nonce to flip the bit on.
*/
setLastChunkOnNonce(nonce) {
if (nonce[4] != 0) {
throw new lazy.BackupError(
"Last chunk byte on nonce already set!",
lazy.ERRORS.ENCRYPTION_FAILED
);
}
// The nonce is 16 bytes so that we can use DataView / getBigUint64 for
// arithmetic, but the spec says that we set the top byte of a 12-byte nonce
// to 0x01. We ignore the first 4 bytes of the 16-byte nonce then, and stick
// the 1 on the 12th byte (which in big-endian order is the 4th byte).
nonce[4] = 1;
},
/**
* Returns true if `setLastChunkOnNonce` has been called on the nonce already.
*
* @param {Uint8Array} nonce
* The nonce to check for the bit on.
* @returns {boolean}
*/
lastChunkSetOnNonce(nonce) {
return nonce[4] == 1;
},
/**
* Increments a nonce by some amount (defaulting to 1). The nonce should be
* incremented once per chunk of maximum ARCHIVE_CHUNK_MAX_BYTES_SIZE bytes.
* If this incrementing indicates that the number of bytes encrypted exceeds
* ARCHIVE_MAX_BYTES_SIZE, an exception is thrown.
*
* @param {Uint8Array} nonce
* The nonce to increment.
* @param {number} [incrementBy=1]
* The amount to increment the nonce by, defaulting to 1.
*/
incrementNonce(nonce, incrementBy = 1) {
let view = new DataView(nonce.buffer, 8);
let nonceBigInt = view.getBigUint64(0);
nonceBigInt += BigInt(incrementBy);
if (
nonceBigInt * BigInt(ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) >
BigInt(ArchiveUtils.ARCHIVE_MAX_BYTES_SIZE)
) {
throw new lazy.BackupError(
"Exceeded archive maximum size.",
lazy.ERRORS.ENCRYPTION_FAILED
);
}
view.setBigUint64(0, nonceBigInt);
},
};
/**
* A class that is used to encrypt one or more chunks of a backup archive.
* Callers must use the async static initialize() method to create an
* ArchiveEncryptor, and then can encrypt() individual chunks. Callers can
* call confirm() to generate the serializable JSON block to be included with
* the archive.
*/
export class ArchiveEncryptor {
/**
* A hack that lets us ensure that an ArchiveEncryptor cannot be
* constructed except via the ArchiveEncryptor.initialize static
* method.
*
*/
static #isInternalConstructing = false;
/**
* The RSA-OAEP public key generated via an ArchiveEncryptionState to
* encrypt a backup.
*
* @type {CryptoKey}
*/
#publicKey = null;
/**
* A unique key generated for the individual archive, used to MAC the
* metadata for a backup.
*
* @type {CryptoKey}
*/
#authKey = null;
/**
* The wrapped archive encryption key material. The archive encryption key
* material is randomly generated per backup to derive the encryption keys
* for encrypting the backup, and is then wrapped using the #publicKey.
*
* @type {Uint8Array}
*/
#wrappedArchiveKeyMaterial = null;
/**
* The derived AES-GCM encryption key used to encrypt chunks of the archive.
*
* @type {CryptoKey}
*/
#encKey = null;
/**
* A big-endian counter nonce, incremented for each subsequent chunk of the
* encrypted archive. The size of the nonce must be a multiple of 8 in order
* to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
*
* @type {Uint8Array}
*/
#nonce = new Uint8Array(16);
/**
* @see ArchiveEncryptor.#isInternalConstructing
*/
constructor() {
if (!ArchiveEncryptor.#isInternalConstructing) {
throw new lazy.BackupError(
"ArchiveEncryptor is not constructable.",
lazy.ERRORS.UNKNOWN
);
}
ArchiveEncryptor.#isInternalConstructing = false;
}
/**
* True if the last chunk flag has been set on the nonce already. Once this
* returns true, no further chunks can be encrypted.
*
* @returns {boolean}
*/
#isDone() {
return NonceUtils.lastChunkSetOnNonce(this.#nonce);
}
/**
* Constructs an ArchiveEncryptor to prepare it to encrypt chunks of an
* archive. This must only be called via the ArchiveEncryptor.initialize
* static method.
*
* @param {CryptoKey} publicKey
* The RSA-OAEP public key generated by an ArchiveEncryptionState.
* @param {CryptoKey} backupAuthKey
* The AES-GCM BackupAuthKey generated by an ArchiveEncryptionState.
* @returns {Promise<undefined>}
*/
async #initialize(publicKey, backupAuthKey) {
this.#publicKey = publicKey;
// Generate a random archive key ArchiveKey. The key material is 256 random
// bits.
let archiveKeyMaterial = crypto.getRandomValues(new Uint8Array(32));
// Encrypt ArchiveKey with the RSA-OEAP Public Key to form WrappedArchiveKey
this.#wrappedArchiveKeyMaterial = new Uint8Array(
await crypto.subtle.encrypt(
{
name: "RSA-OAEP",
},
this.#publicKey,
archiveKeyMaterial
)
);
let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
archiveKeyMaterial,
backupAuthKey
);
this.#authKey = authKey;
this.#encKey = archiveEncKey;
}
/**
* Encrypts a chunk from a backup archive.
*
* @param {Uint8Array} plaintextChunk
* The plaintext chunk of bytes to encrypt.
* @param {boolean} [isLastChunk=false]
* Callers should set this to true if the chunk being encrypted is the
* last chunk. Once this is done, no additional chunk can be encrypted.
* @returns {Promise<Uint8Array>}
*/
async encrypt(plaintextChunk, isLastChunk = false) {
if (this.#isDone()) {
throw new lazy.BackupError(
"Cannot encrypt any more chunks with this ArchiveEncryptor.",
lazy.ERRORS.ENCRYPTION_FAILED
);
}
if (plaintextChunk.byteLength > ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) {
throw new lazy.BackupError(
`Chunk is too large to encrypt: ${plaintextChunk.byteLength} bytes`,
lazy.ERRORS.ENCRYPTION_FAILED
);
}
if (
plaintextChunk.byteLength != ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE &&
!isLastChunk
) {
throw new lazy.BackupError(
"Only last chunk can be smaller than the chunk max size",
lazy.ERRORS.ENCRYPTION_FAILED
);
}
if (isLastChunk) {
NonceUtils.setLastChunkOnNonce(this.#nonce);
}
let ciphertextChunk;
try {
ciphertextChunk = await crypto.subtle.encrypt(
{
name: "AES-GCM",
// Take only the last 12 bytes of the nonce, since the WebCrypto API
// starts to behave differently when the IV is > 96 bits.
iv: this.#nonce.subarray(4),
tagLength: ArchiveUtils.TAG_LENGTH,
},
this.#encKey,
plaintextChunk
);
} catch (e) {
throw new lazy.BackupError(
"Failed to encrypt a chunk.",
lazy.ERRORS.ENCRYPTION_FAILED
);
}
NonceUtils.incrementNonce(this.#nonce);
return new Uint8Array(ciphertextChunk);
}
/**
* Signs the metadata of a backup archive. This signature is used to both
* provide an easy way of checking that a recovery code is valid, but also to
* ensure that the metadata has not been tampered with. The returned Promise
* resolves with the JSON block that can be written to the backup archive
* file.
*
* @param {object} meta
* The metadata of a backup archive.
* @param {Uint8Array} wrappedSecrets
* The encrypted backup secrets computed by ArchiveEncryptionState.
* @param {Uint8Array} salt
* The salt used by ArchiveEncryptionState for the PBKDF2 stretching of the
* recovery code.
* @param {Uint8Array} nonce
* The nonce used by ArchiveEncryptionState when wrapping the private key
* and OSKeyStore secret
* @returns {Promise<Uint8Array>}
* The confirmation signature of the JSON block.
*/
async confirm(meta, wrappedSecrets, salt, nonce) {
let textEncoder = new TextEncoder();
let metaBytes = textEncoder.encode(JSON.stringify(meta));
let confirmation = new Uint8Array(
await crypto.subtle.sign("HMAC", this.#authKey, metaBytes)
);
return {
version: ArchiveUtils.SCHEMA_VERSION,
encConfig: {
wrappedSecrets: ArchiveUtils.arrayToBase64(wrappedSecrets),
wrappedArchiveKeyMaterial: ArchiveUtils.arrayToBase64(
this.#wrappedArchiveKeyMaterial
),
salt: ArchiveUtils.arrayToBase64(salt),
nonce: ArchiveUtils.arrayToBase64(nonce),
confirmation: ArchiveUtils.arrayToBase64(confirmation),
},
meta,
};
}
/**
* Initializes an ArchiveEncryptor so that a caller can begin encrypting
* chunks of a backup archive.
*
* @param {CryptoKey} publicKey
* The RSA-OAEP public key from an ArchiveEncryptionState.
* @param {CryptoKey} backupAuthKey
* The AES-GCM BackupAuthKey from an ArchiveEncryptionState.
* @returns {Promise<ArchiveEncryptor>}
*/
static async initialize(publicKey, backupAuthKey) {
ArchiveEncryptor.#isInternalConstructing = true;
let instance = new ArchiveEncryptor();
await instance.#initialize(publicKey, backupAuthKey);
return instance;
}
}
/**
* A class that is used to decrypt one or more chunks of a backup archive.
* Callers must use the async static initialize() method to create an
* ArchiveDecryptor, and then can decrypt() individual chunks.
*/
export class ArchiveDecryptor {
/**
* A hack that lets us ensure that an ArchiveEncryptor cannot be
* constructed except via the ArchiveEncryptor.initialize static
* method.
*
*/
static #isInternalConstructing = false;
/**
* The unwrapped RSA-OAEP private key extracted from the wrapped secrets of
* a backup.
*
* @type {CryptoKey}
*/
#privateKey = null;
/**
* The unique AES-GCM encryption key used to encrypt this particular backup,
* derived from the wrappedArchiveKeyMaterial.
*
* @type {CryptoKey}
*/
#archiveEncKey = null;
/**
* @see ArchiveDecryptor.OSKeyStoreSecret
*
* @type {string}
*/
#_OSKeyStoreSecret = null;
/**
* A big-endian counter nonce, incremented for each subsequent chunk of the
* encrypted archive. The size of the nonce must be a multiple of 8 in order
* to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
*
* @type {Uint8Array}
*/
#nonce = new Uint8Array(16);
/**
* @see ArchiveDecryptor.#isInternalConstructing
*/
constructor() {
if (!ArchiveDecryptor.#isInternalConstructing) {
throw new lazy.BackupError(
"ArchiveDecryptor is not constructable.",
lazy.ERRORS.UNKNOWN
);
}
ArchiveDecryptor.#isInternalConstructing = false;
}
/**
* The unwrapped OSKeyStore secret that was stored within the JSON block.
*
* @type {string}
*/
get OSKeyStoreSecret() {
if (!this.isDone()) {
throw new lazy.BackupError(
"Cannot access OSKeyStoreSecret until all chunks are decrypted.",
lazy.ERRORS.UNKNOWN
);
}
return this.#_OSKeyStoreSecret;
}
/**
* Initializes an ArchiveDecryptor to decrypt a backup. This will throw if
* the recovery code is not valid, or the meta property of the JSON block
* appears to have been tampered with since signing. It is assumed that a
* caller of this function has already validated that the JSON block has been
* validated against the appropriate ArchiveJSONBlock JSON schema.
*
* @param {string} recoveryCode
* The recovery code originally used to encrypt the backup archive.
* @param {object} jsonBlock
* The parsed JSON block that was stored with the backup archive. See the
* ArchiveJSONBlock JSON schema.
*/
async #initialize(recoveryCode, jsonBlock) {
if (jsonBlock.version > ArchiveUtils.SCHEMA_VERSION) {
throw new lazy.BackupError(
`JSON block version ${jsonBlock.version} is greater than we can handle`,
lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION
);
}
let { encConfig, meta } = jsonBlock;
let salt = ArchiveUtils.stringToArray(encConfig.salt);
let nonce = ArchiveUtils.stringToArray(encConfig.nonce);
let wrappedSecrets = ArchiveUtils.stringToArray(encConfig.wrappedSecrets);
let wrappedArchiveKeyMaterial = ArchiveUtils.stringToArray(
encConfig.wrappedArchiveKeyMaterial
);
let confirmation = ArchiveUtils.stringToArray(encConfig.confirmation);
// First, recompute the BackupAuthKey and BackupEncKey from the recovery
// code and salt
let { backupAuthKey, backupEncKey } = await ArchiveUtils.computeBackupKeys(
recoveryCode,
salt
);
// Next, unwrap the secrets - the private RSA-OAEP key, and the
// OSKeyStore secret.
let unwrappedSecrets;
try {
unwrappedSecrets = new Uint8Array(
await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: nonce,
},
backupEncKey,
wrappedSecrets
)
);
} catch (e) {
throw new lazy.BackupError("Unauthenticated", lazy.ERRORS.UNAUTHORIZED);
}
let textDecoder = new TextDecoder();
let secrets = JSON.parse(textDecoder.decode(unwrappedSecrets));
this.#privateKey = await crypto.subtle.importKey(
"jwk",
secrets.privateKey,
{ name: "RSA-OAEP", hash: "SHA-256" },
true /* extractable */,
["decrypt"]
);
this.#_OSKeyStoreSecret = secrets.OSKeyStoreSecret;
// Now use the private key to decrypt the wrappedArchiveKeyMaterial
let archiveKeyMaterial = await crypto.subtle.decrypt(
{
name: "RSA-OAEP",
},
this.#privateKey,
wrappedArchiveKeyMaterial
);
let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
archiveKeyMaterial,
backupAuthKey
);
this.#archiveEncKey = archiveEncKey;
// Now ensure that the backup metadata has not been tampered with.
let textEncoder = new TextEncoder();
let jsonBlockBytes = textEncoder.encode(JSON.stringify(meta));
let verified = await crypto.subtle.verify(
"HMAC",
authKey,
confirmation,
jsonBlockBytes
);
if (!verified) {
this.#poisonSelf();
throw new lazy.BackupError(
"Backup has been corrupted.",
lazy.ERRORS.CORRUPTED_ARCHIVE
);
}
}
/**
* Decrypts a chunk from a backup archive. This will throw if the cipherText
* chunk appears to be too large (is greater than ARCHIVE_CHUNK_MAX)
*
* @param {Uint8Array} ciphertextChunk
* The ciphertext chunk of bytes to decrypt.
* @param {boolean} [isLastChunk=false]
* Callers should set this to true if the chunk being decrypted is the
* last chunk. Once this is done, no additional chunks can be decrypted.
* @returns {Promise<Uint8Array>}
*/
async decrypt(ciphertextChunk, isLastChunk = false) {
if (this.isDone()) {
throw new lazy.BackupError(
"Cannot decrypt any more chunks with this ArchiveDecryptor.",
lazy.ERRORS.DECRYPTION_FAILED
);
}
if (
ciphertextChunk.byteLength >
ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE + ArchiveUtils.TAG_LENGTH_BYTES
) {
throw new lazy.BackupError(
`Chunk is too large to decrypt: ${ciphertextChunk.byteLength} bytes`,
lazy.ERRORS.DECRYPTION_FAILED
);
}
if (
ciphertextChunk.byteLength !=
ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE +
ArchiveUtils.TAG_LENGTH_BYTES &&
!isLastChunk
) {
throw new lazy.BackupError(
"Only last chunk can be smaller than the chunk max size",
lazy.ERRORS.DECRYPTION_FAILED
);
}
if (isLastChunk) {
NonceUtils.setLastChunkOnNonce(this.#nonce);
}
let plaintextChunk;
try {
plaintextChunk = await crypto.subtle.decrypt(
{
name: "AES-GCM",
// Take only the last 12 bytes of the nonce, since the WebCrypto API
// starts to behave differently when the IV is > 96 bits.
iv: this.#nonce.subarray(4),
tagLength: ArchiveUtils.TAG_LENGTH,
},
this.#archiveEncKey,
ciphertextChunk
);
} catch (e) {
this.#poisonSelf();
throw new lazy.BackupError(
"Failed to decrypt a chunk.",
lazy.ERRORS.DECRYPTION_FAILED
);
}
NonceUtils.incrementNonce(this.#nonce);
return new Uint8Array(plaintextChunk);
}
/**
* Something has gone wrong during decryption. We want to make sure we cannot
* possibly decrypt anything further, so we blow away our internal state,
* effectively breaking this ArchiveDecryptor.
*/
#poisonSelf() {
this.#privateKey = null;
this.#archiveEncKey = null;
this.#_OSKeyStoreSecret = null;
this.#nonce = null;
}
/**
* True if the last chunk flag has been set on the nonce already. Once this
* returns true, no further chunks can be decrypted.
*
* @returns {boolean}
*/
isDone() {
return NonceUtils.lastChunkSetOnNonce(this.#nonce);
}
/**
* Initializes an ArchiveDecryptor using the recovery code and the JSON
* block that was extracted from the archive. The caller is expected to have
* already checked that the JSON block adheres to the ArchiveJSONBlock
* schema. The initialization may fail, and the Promise rejected, if the
* recovery code is not correct, or the meta data of the JSON block has
* changed since it was signed.
*
* @param {string} recoveryCode
* The recovery code to attempt to begin decryption with.
* @param {object} jsonBlock
* See the ArchiveJSONBlock schema for details.
* @returns {Promise<ArchiveDecryptor>}
*/
static async initialize(recoveryCode, jsonBlock) {
ArchiveDecryptor.#isInternalConstructing = true;
let instance = new ArchiveDecryptor();
await instance.#initialize(recoveryCode, jsonBlock);
return instance;
}
}