/* 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. // 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. * * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors */ 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} */ 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} */ 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} * 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} */ 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. * * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors */ 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} */ 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} */ static async initialize(recoveryCode, jsonBlock) { ArchiveDecryptor.#isInternalConstructing = true; let instance = new ArchiveDecryptor(); await instance.#initialize(recoveryCode, jsonBlock); return instance; } }