tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

ArchiveEncryption.sys.mjs (19730B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 // This module expects to be able to load in both main-thread module contexts,
      6 // as well as ChromeWorker contexts. Do not ChromeUtils.importESModule
      7 // anything there at the top-level that's not compatible with both contexts.
      8 
      9 // The ArchiveUtils module is designed to be imported in both worker and
     10 // main thread contexts.
     11 import { ArchiveUtils } from "resource:///modules/backup/ArchiveUtils.sys.mjs";
     12 
     13 const lazy = {};
     14 
     15 ChromeUtils.defineESModuleGetters(
     16  lazy,
     17  {
     18    BackupError: "resource:///modules/backup/BackupError.mjs",
     19    ERRORS: "chrome://browser/content/backup/backup-constants.mjs",
     20  },
     21  { global: "contextual" }
     22 );
     23 
     24 /**
     25 * Both ArchiveEncryptor and ArchiveDecryptor maintain an internal nonce used as
     26 * a big-endian chunk counter. That counter is Uint8Array(16) array, which makes
     27 * doing simple things like adding to the counter somewhat cumbersome.
     28 * NonceUtils contains helper methods to do nonce-related management and
     29 * arithmetic.
     30 */
     31 export const NonceUtils = {
     32  /**
     33   * Flips the bit in the nonce to indicate that the nonce will be used for the
     34   * last chunk to be encrypted. The specification calls for this bit to be the
     35   * 12th bit from the end.
     36   *
     37   * @param {Uint8Array} nonce
     38   *   The nonce to flip the bit on.
     39   */
     40  setLastChunkOnNonce(nonce) {
     41    if (nonce[4] != 0) {
     42      throw new lazy.BackupError(
     43        "Last chunk byte on nonce already set!",
     44        lazy.ERRORS.ENCRYPTION_FAILED
     45      );
     46    }
     47 
     48    // The nonce is 16 bytes so that we can use DataView / getBigUint64 for
     49    // arithmetic, but the spec says that we set the top byte of a 12-byte nonce
     50    // to 0x01. We ignore the first 4 bytes of the 16-byte nonce then, and stick
     51    // the 1 on the 12th byte (which in big-endian order is the 4th byte).
     52    nonce[4] = 1;
     53  },
     54 
     55  /**
     56   * Returns true if `setLastChunkOnNonce` has been called on the nonce already.
     57   *
     58   * @param {Uint8Array} nonce
     59   *   The nonce to check for the bit on.
     60   * @returns {boolean}
     61   */
     62  lastChunkSetOnNonce(nonce) {
     63    return nonce[4] == 1;
     64  },
     65 
     66  /**
     67   * Increments a nonce by some amount (defaulting to 1). The nonce should be
     68   * incremented once per chunk of maximum ARCHIVE_CHUNK_MAX_BYTES_SIZE bytes.
     69   * If this incrementing indicates that the number of bytes encrypted exceeds
     70   * ARCHIVE_MAX_BYTES_SIZE, an exception is thrown.
     71   *
     72   * @param {Uint8Array} nonce
     73   *   The nonce to increment.
     74   * @param {number} [incrementBy=1]
     75   *   The amount to increment the nonce by, defaulting to 1.
     76   */
     77  incrementNonce(nonce, incrementBy = 1) {
     78    let view = new DataView(nonce.buffer, 8);
     79    let nonceBigInt = view.getBigUint64(0);
     80    nonceBigInt += BigInt(incrementBy);
     81    if (
     82      nonceBigInt * BigInt(ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) >
     83      BigInt(ArchiveUtils.ARCHIVE_MAX_BYTES_SIZE)
     84    ) {
     85      throw new lazy.BackupError(
     86        "Exceeded archive maximum size.",
     87        lazy.ERRORS.ENCRYPTION_FAILED
     88      );
     89    }
     90 
     91    view.setBigUint64(0, nonceBigInt);
     92  },
     93 };
     94 
     95 /**
     96 * A class that is used to encrypt one or more chunks of a backup archive.
     97 * Callers must use the async static initialize() method to create an
     98 * ArchiveEncryptor, and then can encrypt() individual chunks. Callers can
     99 * call confirm() to generate the serializable JSON block to be included with
    100 * the archive.
    101 */
    102 export class ArchiveEncryptor {
    103  /**
    104   * A hack that lets us ensure that an ArchiveEncryptor cannot be
    105   * constructed except via the ArchiveEncryptor.initialize static
    106   * method.
    107   *
    108   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
    109   */
    110  static #isInternalConstructing = false;
    111 
    112  /**
    113   * The RSA-OAEP public key generated via an ArchiveEncryptionState to
    114   * encrypt a backup.
    115   *
    116   * @type {CryptoKey}
    117   */
    118  #publicKey = null;
    119 
    120  /**
    121   * A unique key generated for the individual archive, used to MAC the
    122   * metadata for a backup.
    123   *
    124   * @type {CryptoKey}
    125   */
    126  #authKey = null;
    127 
    128  /**
    129   * The wrapped archive encryption key material. The archive encryption key
    130   * material is randomly generated per backup to derive the encryption keys
    131   * for encrypting the backup, and is then wrapped using the #publicKey.
    132   *
    133   * @type {Uint8Array}
    134   */
    135  #wrappedArchiveKeyMaterial = null;
    136 
    137  /**
    138   * The derived AES-GCM encryption key used to encrypt chunks of the archive.
    139   *
    140   * @type {CryptoKey}
    141   */
    142  #encKey = null;
    143 
    144  /**
    145   * A big-endian counter nonce, incremented for each subsequent chunk of the
    146   * encrypted archive. The size of the nonce must be a multiple of 8 in order
    147   * to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
    148   *
    149   * @type {Uint8Array}
    150   */
    151  #nonce = new Uint8Array(16);
    152 
    153  /**
    154   * @see ArchiveEncryptor.#isInternalConstructing
    155   */
    156  constructor() {
    157    if (!ArchiveEncryptor.#isInternalConstructing) {
    158      throw new lazy.BackupError(
    159        "ArchiveEncryptor is not constructable.",
    160        lazy.ERRORS.UNKNOWN
    161      );
    162    }
    163    ArchiveEncryptor.#isInternalConstructing = false;
    164  }
    165 
    166  /**
    167   * True if the last chunk flag has been set on the nonce already. Once this
    168   * returns true, no further chunks can be encrypted.
    169   *
    170   * @returns {boolean}
    171   */
    172  #isDone() {
    173    return NonceUtils.lastChunkSetOnNonce(this.#nonce);
    174  }
    175 
    176  /**
    177   * Constructs an ArchiveEncryptor to prepare it to encrypt chunks of an
    178   * archive. This must only be called via the ArchiveEncryptor.initialize
    179   * static method.
    180   *
    181   * @param {CryptoKey} publicKey
    182   *   The RSA-OAEP public key generated by an ArchiveEncryptionState.
    183   * @param {CryptoKey} backupAuthKey
    184   *   The AES-GCM BackupAuthKey generated by an ArchiveEncryptionState.
    185   * @returns {Promise<undefined>}
    186   */
    187  async #initialize(publicKey, backupAuthKey) {
    188    this.#publicKey = publicKey;
    189 
    190    // Generate a random archive key ArchiveKey. The key material is 256 random
    191    // bits.
    192    let archiveKeyMaterial = crypto.getRandomValues(new Uint8Array(32));
    193 
    194    // Encrypt ArchiveKey with the RSA-OEAP Public Key to form WrappedArchiveKey
    195    this.#wrappedArchiveKeyMaterial = new Uint8Array(
    196      await crypto.subtle.encrypt(
    197        {
    198          name: "RSA-OAEP",
    199        },
    200        this.#publicKey,
    201        archiveKeyMaterial
    202      )
    203    );
    204 
    205    let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
    206      archiveKeyMaterial,
    207      backupAuthKey
    208    );
    209    this.#authKey = authKey;
    210    this.#encKey = archiveEncKey;
    211  }
    212 
    213  /**
    214   * Encrypts a chunk from a backup archive.
    215   *
    216   * @param {Uint8Array} plaintextChunk
    217   *   The plaintext chunk of bytes to encrypt.
    218   * @param {boolean} [isLastChunk=false]
    219   *   Callers should set this to true if the chunk being encrypted is the
    220   *   last chunk. Once this is done, no additional chunk can be encrypted.
    221   * @returns {Promise<Uint8Array>}
    222   */
    223  async encrypt(plaintextChunk, isLastChunk = false) {
    224    if (this.#isDone()) {
    225      throw new lazy.BackupError(
    226        "Cannot encrypt any more chunks with this ArchiveEncryptor.",
    227        lazy.ERRORS.ENCRYPTION_FAILED
    228      );
    229    }
    230 
    231    if (plaintextChunk.byteLength > ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE) {
    232      throw new lazy.BackupError(
    233        `Chunk is too large to encrypt: ${plaintextChunk.byteLength} bytes`,
    234        lazy.ERRORS.ENCRYPTION_FAILED
    235      );
    236    }
    237    if (
    238      plaintextChunk.byteLength != ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE &&
    239      !isLastChunk
    240    ) {
    241      throw new lazy.BackupError(
    242        "Only last chunk can be smaller than the chunk max size",
    243        lazy.ERRORS.ENCRYPTION_FAILED
    244      );
    245    }
    246 
    247    if (isLastChunk) {
    248      NonceUtils.setLastChunkOnNonce(this.#nonce);
    249    }
    250 
    251    let ciphertextChunk;
    252    try {
    253      ciphertextChunk = await crypto.subtle.encrypt(
    254        {
    255          name: "AES-GCM",
    256          // Take only the last 12 bytes of the nonce, since the WebCrypto API
    257          // starts to behave differently when the IV is > 96 bits.
    258          iv: this.#nonce.subarray(4),
    259          tagLength: ArchiveUtils.TAG_LENGTH,
    260        },
    261        this.#encKey,
    262        plaintextChunk
    263      );
    264    } catch (e) {
    265      throw new lazy.BackupError(
    266        "Failed to encrypt a chunk.",
    267        lazy.ERRORS.ENCRYPTION_FAILED
    268      );
    269    }
    270 
    271    NonceUtils.incrementNonce(this.#nonce);
    272 
    273    return new Uint8Array(ciphertextChunk);
    274  }
    275 
    276  /**
    277   * Signs the metadata of a backup archive. This signature is used to both
    278   * provide an easy way of checking that a recovery code is valid, but also to
    279   * ensure that the metadata has not been tampered with. The returned Promise
    280   * resolves with the JSON block that can be written to the backup archive
    281   * file.
    282   *
    283   * @param {object} meta
    284   *   The metadata of a backup archive.
    285   * @param {Uint8Array} wrappedSecrets
    286   *   The encrypted backup secrets computed by ArchiveEncryptionState.
    287   * @param {Uint8Array} salt
    288   *   The salt used by ArchiveEncryptionState for the PBKDF2 stretching of the
    289   *   recovery code.
    290   * @param {Uint8Array} nonce
    291   *   The nonce used by ArchiveEncryptionState when wrapping the private key
    292   *   and OSKeyStore secret
    293   * @returns {Promise<Uint8Array>}
    294   *   The confirmation signature of the JSON block.
    295   */
    296  async confirm(meta, wrappedSecrets, salt, nonce) {
    297    let textEncoder = new TextEncoder();
    298    let metaBytes = textEncoder.encode(JSON.stringify(meta));
    299    let confirmation = new Uint8Array(
    300      await crypto.subtle.sign("HMAC", this.#authKey, metaBytes)
    301    );
    302 
    303    return {
    304      version: ArchiveUtils.SCHEMA_VERSION,
    305      encConfig: {
    306        wrappedSecrets: ArchiveUtils.arrayToBase64(wrappedSecrets),
    307        wrappedArchiveKeyMaterial: ArchiveUtils.arrayToBase64(
    308          this.#wrappedArchiveKeyMaterial
    309        ),
    310        salt: ArchiveUtils.arrayToBase64(salt),
    311        nonce: ArchiveUtils.arrayToBase64(nonce),
    312        confirmation: ArchiveUtils.arrayToBase64(confirmation),
    313      },
    314      meta,
    315    };
    316  }
    317 
    318  /**
    319   * Initializes an ArchiveEncryptor so that a caller can begin encrypting
    320   * chunks of a backup archive.
    321   *
    322   * @param {CryptoKey} publicKey
    323   *   The RSA-OAEP public key from an ArchiveEncryptionState.
    324   * @param {CryptoKey} backupAuthKey
    325   *   The AES-GCM BackupAuthKey from an ArchiveEncryptionState.
    326   * @returns {Promise<ArchiveEncryptor>}
    327   */
    328  static async initialize(publicKey, backupAuthKey) {
    329    ArchiveEncryptor.#isInternalConstructing = true;
    330    let instance = new ArchiveEncryptor();
    331    await instance.#initialize(publicKey, backupAuthKey);
    332    return instance;
    333  }
    334 }
    335 
    336 /**
    337 * A class that is used to decrypt one or more chunks of a backup archive.
    338 * Callers must use the async static initialize() method to create an
    339 * ArchiveDecryptor, and then can decrypt() individual chunks.
    340 */
    341 export class ArchiveDecryptor {
    342  /**
    343   * A hack that lets us ensure that an ArchiveEncryptor cannot be
    344   * constructed except via the ArchiveEncryptor.initialize static
    345   * method.
    346   *
    347   * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors
    348   */
    349  static #isInternalConstructing = false;
    350 
    351  /**
    352   * The unwrapped RSA-OAEP private key extracted from the wrapped secrets of
    353   * a backup.
    354   *
    355   * @type {CryptoKey}
    356   */
    357  #privateKey = null;
    358 
    359  /**
    360   * The unique AES-GCM encryption key used to encrypt this particular backup,
    361   * derived from the wrappedArchiveKeyMaterial.
    362   *
    363   * @type {CryptoKey}
    364   */
    365  #archiveEncKey = null;
    366 
    367  /**
    368   * @see ArchiveDecryptor.OSKeyStoreSecret
    369   *
    370   * @type {string}
    371   */
    372  #_OSKeyStoreSecret = null;
    373 
    374  /**
    375   * A big-endian counter nonce, incremented for each subsequent chunk of the
    376   * encrypted archive. The size of the nonce must be a multiple of 8 in order
    377   * to simplify the arithmetic via DataView / getBigUint64 / setBigUint64.
    378   *
    379   * @type {Uint8Array}
    380   */
    381  #nonce = new Uint8Array(16);
    382 
    383  /**
    384   * @see ArchiveDecryptor.#isInternalConstructing
    385   */
    386  constructor() {
    387    if (!ArchiveDecryptor.#isInternalConstructing) {
    388      throw new lazy.BackupError(
    389        "ArchiveDecryptor is not constructable.",
    390        lazy.ERRORS.UNKNOWN
    391      );
    392    }
    393    ArchiveDecryptor.#isInternalConstructing = false;
    394  }
    395 
    396  /**
    397   * The unwrapped OSKeyStore secret that was stored within the JSON block.
    398   *
    399   * @type {string}
    400   */
    401  get OSKeyStoreSecret() {
    402    if (!this.isDone()) {
    403      throw new lazy.BackupError(
    404        "Cannot access OSKeyStoreSecret until all chunks are decrypted.",
    405        lazy.ERRORS.UNKNOWN
    406      );
    407    }
    408    return this.#_OSKeyStoreSecret;
    409  }
    410 
    411  /**
    412   * Initializes an ArchiveDecryptor to decrypt a backup. This will throw if
    413   * the recovery code is not valid, or the meta property of the JSON block
    414   * appears to have been tampered with since signing. It is assumed that a
    415   * caller of this function has already validated that the JSON block has been
    416   * validated against the appropriate ArchiveJSONBlock JSON schema.
    417   *
    418   * @param {string} recoveryCode
    419   *   The recovery code originally used to encrypt the backup archive.
    420   * @param {object} jsonBlock
    421   *   The parsed JSON block that was stored with the backup archive. See the
    422   *   ArchiveJSONBlock JSON schema.
    423   */
    424  async #initialize(recoveryCode, jsonBlock) {
    425    if (jsonBlock.version > ArchiveUtils.SCHEMA_VERSION) {
    426      throw new lazy.BackupError(
    427        `JSON block version ${jsonBlock.version} is greater than we can handle`,
    428        lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION
    429      );
    430    }
    431 
    432    let { encConfig, meta } = jsonBlock;
    433    let salt = ArchiveUtils.stringToArray(encConfig.salt);
    434    let nonce = ArchiveUtils.stringToArray(encConfig.nonce);
    435    let wrappedSecrets = ArchiveUtils.stringToArray(encConfig.wrappedSecrets);
    436    let wrappedArchiveKeyMaterial = ArchiveUtils.stringToArray(
    437      encConfig.wrappedArchiveKeyMaterial
    438    );
    439    let confirmation = ArchiveUtils.stringToArray(encConfig.confirmation);
    440 
    441    // First, recompute the BackupAuthKey and BackupEncKey from the recovery
    442    // code and salt
    443    let { backupAuthKey, backupEncKey } = await ArchiveUtils.computeBackupKeys(
    444      recoveryCode,
    445      salt
    446    );
    447 
    448    // Next, unwrap the secrets - the private RSA-OAEP key, and the
    449    // OSKeyStore secret.
    450    let unwrappedSecrets;
    451    try {
    452      unwrappedSecrets = new Uint8Array(
    453        await crypto.subtle.decrypt(
    454          {
    455            name: "AES-GCM",
    456            iv: nonce,
    457          },
    458          backupEncKey,
    459          wrappedSecrets
    460        )
    461      );
    462    } catch (e) {
    463      throw new lazy.BackupError("Unauthenticated", lazy.ERRORS.UNAUTHORIZED);
    464    }
    465 
    466    let textDecoder = new TextDecoder();
    467    let secrets = JSON.parse(textDecoder.decode(unwrappedSecrets));
    468 
    469    this.#privateKey = await crypto.subtle.importKey(
    470      "jwk",
    471      secrets.privateKey,
    472      { name: "RSA-OAEP", hash: "SHA-256" },
    473      true /* extractable */,
    474      ["decrypt"]
    475    );
    476 
    477    this.#_OSKeyStoreSecret = secrets.OSKeyStoreSecret;
    478 
    479    // Now use the private key to decrypt the wrappedArchiveKeyMaterial
    480    let archiveKeyMaterial = await crypto.subtle.decrypt(
    481      {
    482        name: "RSA-OAEP",
    483      },
    484      this.#privateKey,
    485      wrappedArchiveKeyMaterial
    486    );
    487 
    488    let { archiveEncKey, authKey } = await ArchiveUtils.computeEncryptionKeys(
    489      archiveKeyMaterial,
    490      backupAuthKey
    491    );
    492 
    493    this.#archiveEncKey = archiveEncKey;
    494 
    495    // Now ensure that the backup metadata has not been tampered with.
    496    let textEncoder = new TextEncoder();
    497    let jsonBlockBytes = textEncoder.encode(JSON.stringify(meta));
    498    let verified = await crypto.subtle.verify(
    499      "HMAC",
    500      authKey,
    501      confirmation,
    502      jsonBlockBytes
    503    );
    504    if (!verified) {
    505      this.#poisonSelf();
    506      throw new lazy.BackupError(
    507        "Backup has been corrupted.",
    508        lazy.ERRORS.CORRUPTED_ARCHIVE
    509      );
    510    }
    511  }
    512 
    513  /**
    514   * Decrypts a chunk from a backup archive. This will throw if the cipherText
    515   * chunk appears to be too large (is greater than ARCHIVE_CHUNK_MAX)
    516   *
    517   * @param {Uint8Array} ciphertextChunk
    518   *   The ciphertext chunk of bytes to decrypt.
    519   * @param {boolean} [isLastChunk=false]
    520   *   Callers should set this to true if the chunk being decrypted is the
    521   *   last chunk. Once this is done, no additional chunks can be decrypted.
    522   * @returns {Promise<Uint8Array>}
    523   */
    524  async decrypt(ciphertextChunk, isLastChunk = false) {
    525    if (this.isDone()) {
    526      throw new lazy.BackupError(
    527        "Cannot decrypt any more chunks with this ArchiveDecryptor.",
    528        lazy.ERRORS.DECRYPTION_FAILED
    529      );
    530    }
    531 
    532    if (
    533      ciphertextChunk.byteLength >
    534      ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE + ArchiveUtils.TAG_LENGTH_BYTES
    535    ) {
    536      throw new lazy.BackupError(
    537        `Chunk is too large to decrypt: ${ciphertextChunk.byteLength} bytes`,
    538        lazy.ERRORS.DECRYPTION_FAILED
    539      );
    540    }
    541 
    542    if (
    543      ciphertextChunk.byteLength !=
    544        ArchiveUtils.ARCHIVE_CHUNK_MAX_BYTES_SIZE +
    545          ArchiveUtils.TAG_LENGTH_BYTES &&
    546      !isLastChunk
    547    ) {
    548      throw new lazy.BackupError(
    549        "Only last chunk can be smaller than the chunk max size",
    550        lazy.ERRORS.DECRYPTION_FAILED
    551      );
    552    }
    553 
    554    if (isLastChunk) {
    555      NonceUtils.setLastChunkOnNonce(this.#nonce);
    556    }
    557 
    558    let plaintextChunk;
    559 
    560    try {
    561      plaintextChunk = await crypto.subtle.decrypt(
    562        {
    563          name: "AES-GCM",
    564          // Take only the last 12 bytes of the nonce, since the WebCrypto API
    565          // starts to behave differently when the IV is > 96 bits.
    566          iv: this.#nonce.subarray(4),
    567          tagLength: ArchiveUtils.TAG_LENGTH,
    568        },
    569        this.#archiveEncKey,
    570        ciphertextChunk
    571      );
    572    } catch (e) {
    573      this.#poisonSelf();
    574      throw new lazy.BackupError(
    575        "Failed to decrypt a chunk.",
    576        lazy.ERRORS.DECRYPTION_FAILED
    577      );
    578    }
    579 
    580    NonceUtils.incrementNonce(this.#nonce);
    581 
    582    return new Uint8Array(plaintextChunk);
    583  }
    584 
    585  /**
    586   * Something has gone wrong during decryption. We want to make sure we cannot
    587   * possibly decrypt anything further, so we blow away our internal state,
    588   * effectively breaking this ArchiveDecryptor.
    589   */
    590  #poisonSelf() {
    591    this.#privateKey = null;
    592    this.#archiveEncKey = null;
    593    this.#_OSKeyStoreSecret = null;
    594    this.#nonce = null;
    595  }
    596 
    597  /**
    598   * True if the last chunk flag has been set on the nonce already. Once this
    599   * returns true, no further chunks can be decrypted.
    600   *
    601   * @returns {boolean}
    602   */
    603  isDone() {
    604    return NonceUtils.lastChunkSetOnNonce(this.#nonce);
    605  }
    606 
    607  /**
    608   * Initializes an ArchiveDecryptor using the recovery code and the JSON
    609   * block that was extracted from the archive. The caller is expected to have
    610   * already checked that the JSON block adheres to the ArchiveJSONBlock
    611   * schema. The initialization may fail, and the Promise rejected, if the
    612   * recovery code is not correct, or the meta data of the JSON block has
    613   * changed since it was signed.
    614   *
    615   * @param {string} recoveryCode
    616   *   The recovery code to attempt to begin decryption with.
    617   * @param {object} jsonBlock
    618   *   See the ArchiveJSONBlock schema for details.
    619   * @returns {Promise<ArchiveDecryptor>}
    620   */
    621  static async initialize(recoveryCode, jsonBlock) {
    622    ArchiveDecryptor.#isInternalConstructing = true;
    623    let instance = new ArchiveDecryptor();
    624    await instance.#initialize(recoveryCode, jsonBlock);
    625    return instance;
    626  }
    627 }