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 }