ArchiveEncryptionState.sys.mjs (11019B)
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 const lazy = {}; 6 7 ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { 8 return console.createInstance({ 9 prefix: "BackupService::ArchiveEncryption", 10 maxLogLevel: Services.prefs.getBoolPref("browser.backup.log", false) 11 ? "Debug" 12 : "Warn", 13 }); 14 }); 15 16 ChromeUtils.defineESModuleGetters(lazy, { 17 ArchiveUtils: "resource:///modules/backup/ArchiveUtils.sys.mjs", 18 OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs", 19 BackupError: "resource:///modules/backup/BackupError.mjs", 20 ERRORS: "chrome://browser/content/backup/backup-constants.mjs", 21 }); 22 23 /** 24 * ArchiveEncryptionState encapsulates key primitives and wrapped secrets that 25 * can be safely serialized to the filesystem. An ArchiveEncryptionState is 26 * used to compute the necessary keys for encrypting a backup archive. 27 */ 28 export class ArchiveEncryptionState { 29 /** 30 * A hack that lets us ensure that an ArchiveEncryptionState cannot be 31 * constructed except via the ArchiveEncryptionState.initialize static 32 * method. 33 * 34 * See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties#simulating_private_constructors 35 */ 36 static #isInternalConstructing = false; 37 38 /** 39 * A reference to an object holding the current state of the 40 * ArchiveEncryptionState instance. When this reference is null, encryption 41 * is not considered enabled. 42 */ 43 #state = null; 44 45 /** 46 * The current version number of the ArchiveEncryptionState. This is encoded 47 * in the serialized state, and is also used during calculation of the salt 48 * in enable(). 49 * 50 * @type {number} 51 */ 52 static get VERSION() { 53 return 1; 54 } 55 56 /** 57 * The number of characters to generate with a CSRNG (crypto.getRandomValues) 58 * if no recovery code is passed in to enable(); 59 * 60 * @type {number} 61 */ 62 static get GENERATED_RECOVERY_CODE_LENGTH() { 63 return 14; 64 } 65 66 /** 67 * The RSA-OAEP public key that will be used to derive keys for encrypting 68 * backups. 69 * 70 * @type {CryptoKey} 71 */ 72 get publicKey() { 73 return this.#state.publicKey; 74 } 75 76 /** 77 * The AES-GCM key that will be used to authenticate the owner of the backup. 78 * 79 * @type {CryptoKey} 80 */ 81 get backupAuthKey() { 82 return this.#state.backupAuthKey; 83 } 84 85 /** 86 * A salt computed for the PBKDF2 stretching of the recovery code. 87 * 88 * @type {Uint8Array} 89 */ 90 get salt() { 91 return this.#state.salt; 92 } 93 94 /** 95 * A nonce computed when wrapping the private key and OSKeyStore secret. 96 * 97 * @type {Uint8Array} 98 */ 99 get nonce() { 100 return this.#state.nonce; 101 } 102 103 /** 104 * The wrapped static secrets, including the RSA-OAEP private key, and the 105 * OSKeyStore secret. 106 * 107 * @type {Uint8Array} 108 */ 109 get wrappedSecrets() { 110 return this.#state.wrappedSecrets; 111 } 112 113 constructor() { 114 if (!ArchiveEncryptionState.#isInternalConstructing) { 115 throw new lazy.BackupError( 116 "ArchiveEncryptionState is not constructable.", 117 lazy.ERRORS.UNKNOWN 118 ); 119 } 120 ArchiveEncryptionState.#isInternalConstructing = false; 121 } 122 123 /** 124 * Calculates various encryption keys and other information necessary to 125 * encrypt backups, based on the passed in recoveryCode. 126 * 127 * This will throw if encryption is already enabled for this 128 * ArchiveEncryptionState. 129 * 130 * @throws {Exception} 131 * @param {string} [recoveryCode=null] 132 * A recovery code that will be used to drive the various encryption keys 133 * and data for backup encryption. If not supplied by the caller, a 134 * recovery code will be generated. 135 * @returns {Promise<string>} 136 * Resolves with the recovery code string. If callers did not pass the 137 * recovery code in as an argument, they should not store it. They should 138 * instead display this string to the user, and then forget it altogether. 139 */ 140 async #enable(recoveryCode = null) { 141 lazy.logConsole.debug("Creating new enabled ArchiveEncryptionState"); 142 143 lazy.logConsole.debug("Generating an RSA-OEAP keyPair"); 144 let keyPair = await crypto.subtle.generateKey( 145 { 146 name: "RSA-OAEP", 147 modulusLength: 2048, 148 publicExponent: new Uint8Array([1, 0, 1]), 149 hash: { name: "SHA-256" }, 150 }, 151 true /* extractable */, 152 ["encrypt", "decrypt"] 153 ); 154 155 if (!recoveryCode) { 156 // A recovery code wasn't provided, so we'll generate one using 157 // getRandomValues, and make sure it's GENERATED_RECOVERY_CODE_LENGTH 158 // characters long. 159 recoveryCode = ""; 160 // We've intentionally replaced some lookalike characters (O, o, 0, l, I, 161 // 1) with symbols. 162 const charset = 163 "ABCDEFGH#JKLMN@PQRSTUVWXYZabcdefgh=jklmn+pqrstuvwxyz%!23456789"; 164 // getRandomValues will return a value between 0-255. In order to not 165 // gain a bias on any particular character (due to wrap-around), we'll 166 // ensure that we only consider random values that are less than or 167 // equal to the highest multiple of charset.length that is less than 168 // 255. 169 let highestMultiple = 170 Math.floor((255 /* upper limit */ - 1) / charset.length) * 171 charset.length; 172 173 while ( 174 recoveryCode.length < 175 ArchiveEncryptionState.GENERATED_RECOVERY_CODE_LENGTH 176 ) { 177 let randomValue = new Uint8Array(1); 178 crypto.getRandomValues(randomValue); 179 // If the random value is higher than highestMultiple, try again. 180 if (randomValue > highestMultiple) { 181 continue; 182 } 183 // Otherwise, we're within the highest multiple, meaning we can mod 184 // the generated number to choose a character from charset. 185 let randomIndex = randomValue % charset.length; 186 recoveryCode += charset[randomIndex]; 187 } 188 } 189 190 // Next, we generate a 32-byte salt, and then concatenate a static suffix 191 // to it, including the version number. 192 lazy.logConsole.debug("Creating salt"); 193 let textEncoder = new TextEncoder(); 194 const SALT_SUFFIX = textEncoder.encode( 195 "backupkey-v" + ArchiveEncryptionState.VERSION 196 ); 197 let saltPrefix = new Uint8Array(32); 198 crypto.getRandomValues(saltPrefix); 199 200 let salt = new Uint8Array(saltPrefix.length + SALT_SUFFIX.length); 201 salt.set(saltPrefix); 202 salt.set(SALT_SUFFIX, saltPrefix.length); 203 204 let { backupAuthKey, backupEncKey } = 205 await lazy.ArchiveUtils.computeBackupKeys(recoveryCode, salt); 206 207 lazy.logConsole.debug("Encrypting secrets with encKey"); 208 const NONCE_SIZE = 96; 209 let nonce = crypto.getRandomValues(new Uint8Array(NONCE_SIZE)); 210 211 let secrets = JSON.stringify({ 212 privateKey: await crypto.subtle.exportKey("jwk", keyPair.privateKey), 213 OSKeyStoreSecret: await lazy.OSKeyStore.exportRecoveryPhrase(), 214 }); 215 let secretsBytes = textEncoder.encode(secrets); 216 217 let wrappedSecrets = new Uint8Array( 218 await crypto.subtle.encrypt( 219 { 220 name: "AES-GCM", 221 iv: nonce, 222 }, 223 backupEncKey, 224 secretsBytes 225 ) 226 ); 227 228 this.#state = { 229 publicKey: keyPair.publicKey, 230 salt, 231 backupAuthKey, 232 nonce, 233 wrappedSecrets, 234 }; 235 236 return recoveryCode; 237 } 238 239 /** 240 * Serializes an ArchiveEncryptionState instance into an object that can be 241 * safely persisted to disk. 242 * 243 * @returns {Promise<object>} 244 */ 245 async serialize() { 246 let publicKey = await crypto.subtle.exportKey("jwk", this.#state.publicKey); 247 let salt = lazy.ArchiveUtils.arrayToBase64(this.#state.salt); 248 let backupAuthKey = lazy.ArchiveUtils.arrayToBase64( 249 this.#state.backupAuthKey 250 ); 251 let nonce = lazy.ArchiveUtils.arrayToBase64(this.#state.nonce); 252 let wrappedSecrets = lazy.ArchiveUtils.arrayToBase64( 253 this.#state.wrappedSecrets 254 ); 255 let result = { 256 publicKey, 257 salt, 258 backupAuthKey, 259 nonce, 260 wrappedSecrets, 261 version: ArchiveEncryptionState.VERSION, 262 }; 263 264 return result; 265 } 266 267 /** 268 * Deserializes an object created via serialize() and updates its internal 269 * state to match the deserialization. 270 * 271 * @param {object} stateData 272 * The object generated via serialize() 273 * @returns {Promise<undefined>} 274 */ 275 async #deserialize(stateData) { 276 lazy.logConsole.debug( 277 "Deserializing from state with version ", 278 stateData.version 279 ); 280 281 // If we ever need to do a migration from one ArchiveEncryptionState 282 // version to another, this is where we might do it. We don't currently 283 // have any need to do migrations just yet though, so any version that 284 // doesn't match the one that we can accept is rejected. 285 if (stateData.version != ArchiveEncryptionState.VERSION) { 286 throw new lazy.BackupError( 287 "The ArchiveEncryptionState version is from a newer version.", 288 lazy.ERRORS.UNSUPPORTED_BACKUP_VERSION 289 ); 290 } 291 292 let publicKey = await crypto.subtle.importKey( 293 "jwk", 294 stateData.publicKey, 295 { name: "RSA-OAEP", hash: "SHA-256" }, 296 true /* extractable */, 297 ["encrypt"] 298 ); 299 let backupAuthKey = lazy.ArchiveUtils.stringToArray( 300 stateData.backupAuthKey 301 ); 302 let salt = lazy.ArchiveUtils.stringToArray(stateData.salt); 303 let nonce = lazy.ArchiveUtils.stringToArray(stateData.nonce); 304 let wrappedSecrets = lazy.ArchiveUtils.stringToArray( 305 stateData.wrappedSecrets 306 ); 307 308 this.#state = { 309 publicKey, 310 backupAuthKey, 311 salt, 312 nonce, 313 wrappedSecrets, 314 }; 315 } 316 317 /** 318 * @typedef {object} InitializationResult 319 * @property {string|undefined} recoveryCode 320 * The generated recovery code if the initialization happened without 321 * deserialization. 322 * @property {ArchiveEncryptionState} instance 323 * The constructed ArchiveEncryptionState. 324 */ 325 326 /** 327 * Constructs a new ArchiveEncryptionState. If a stateData object is passed, 328 * the ArchiveEncryptionState will attempt to be deserialized from it - 329 * otherwise, new state data will be generated automatically. This might 330 * reject if the user is prompted to authenticate to their OSKeyStore, and 331 * they cancel the authentication. 332 * 333 * @param {object|string|undefined} stateDataOrRecoveryCode 334 * Either the object generated via serialize(), a recovery code to be 335 * used to generate the state, or undefined. 336 * @returns {Promise<InitializationResult>} 337 */ 338 static async initialize(stateDataOrRecoveryCode) { 339 ArchiveEncryptionState.#isInternalConstructing = true; 340 let instance = new ArchiveEncryptionState(); 341 if (typeof stateDataOrRecoveryCode == "object") { 342 await instance.#deserialize(stateDataOrRecoveryCode); 343 return { instance }; 344 } 345 let recoveryCode = await instance.#enable(stateDataOrRecoveryCode); 346 return { instance, recoveryCode }; 347 } 348 }