ArchiveUtils.sys.mjs (9613B)
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 export const ArchiveUtils = { 10 /** 11 * Convert an array containing only two bytes unsigned numbers to a base64 12 * encoded string. 13 * 14 * @param {number[]} anArray 15 * The array that needs to be converted. 16 * @returns {string} 17 * The string representation of the array. 18 */ 19 arrayToBase64(anArray) { 20 let result = ""; 21 let bytes = new Uint8Array(anArray); 22 for (let i = 0; i < bytes.length; i++) { 23 result += String.fromCharCode(bytes[i]); 24 } 25 return btoa(result); 26 }, 27 28 /** 29 * Convert a base64 encoded string to an Uint8Array. 30 * 31 * @param {string} base64Str 32 * The base64 encoded string that needs to be converted. 33 * @returns {Uint8Array[]} 34 * The array representation of the string. 35 */ 36 stringToArray(base64Str) { 37 let binaryStr = atob(base64Str); 38 let len = binaryStr.length; 39 let bytes = new Uint8Array(len); 40 for (let i = 0; i < len; i++) { 41 bytes[i] = binaryStr.charCodeAt(i); 42 } 43 return bytes; 44 }, 45 46 /** 47 * The current shared schema version between the BackupManifest and the 48 * ArchiveJSONBlock schemas. 49 * 50 * @type {number} 51 */ 52 get SCHEMA_VERSION() { 53 return 1; 54 }, 55 56 /** 57 * The version of the single-file archive that this version of the 58 * application is expected to produce. Versions greater than this are not 59 * interpretable by the application, and will cause an exception to be 60 * thrown when loading the archive. 61 * 62 * Note: Until we can interpolate strings in our templates, changing this 63 * value will require manual changes to the archive.template.html version 64 * number in the header, as well as any test templates. 65 * 66 * @type {number} 67 */ 68 get ARCHIVE_FILE_VERSION() { 69 return 1; 70 }, 71 72 /** 73 * The HTML document comment start block, also indicating the start of the 74 * inline MIME message block. 75 * 76 * @type {string} 77 */ 78 get INLINE_MIME_START_MARKER() { 79 return "<!-- Begin inline MIME --"; 80 }, 81 82 /** 83 * The HTML document comment end block, also indicating the end of the 84 * inline MIME message block. 85 * 86 * @type {string} 87 */ 88 get INLINE_MIME_END_MARKER() { 89 return "---- End inline MIME -->"; 90 }, 91 92 /** 93 * The maximum number of bytes to read and encode when constructing the 94 * single-file archive. 95 * 96 * @type {number} 97 */ 98 get ARCHIVE_CHUNK_MAX_BYTES_SIZE() { 99 return 1048576; // 2 ^ 20 bytes, per guidance from security engineering. 100 }, 101 102 /** 103 * The maximum size of a backup archive, in bytes, prior to base64 encoding. 104 * 105 * @type {number} 106 */ 107 get ARCHIVE_MAX_BYTES_SIZE() { 108 return 34359738368; // 2 ^ 35 bytes (32 GiB) 109 }, 110 111 /** 112 * The AES-GCM tag length applied to each encrypted chunk, in bits. 113 * 114 * @type {number} 115 */ 116 get TAG_LENGTH() { 117 return 128; 118 }, 119 120 /** 121 * The AES-GCM tag length applied to each encrypted chunk, in bytes. 122 * 123 * @type {number} 124 */ 125 get TAG_LENGTH_BYTES() { 126 return this.TAG_LENGTH / 8; 127 }, 128 129 /** 130 * @typedef {object} ComputeKeysResult 131 * @property {Uint8Array} backupAuthKey 132 * The computed BackupAuthKey. This is returned as a Uint8Array because 133 * this key is used as a salt for other derived keys. 134 * @property {CryptoKey} backupEncKey 135 * The computed BackupEncKey. This is an AES-GCM key used to encrypt and 136 * decrypt the secrets contained within a backup archive. 137 */ 138 139 /** 140 * Computes the BackupAuthKey and BackupEncKey from a recovery code and a 141 * salt. 142 * 143 * @param {string} recoveryCode 144 * A recovery code. Callers are responsible for checking the length / 145 * entropy of the recovery code. 146 * @param {Uint8Array} salt 147 * A salt that should be used for computing the keys. 148 * @returns {ComputeKeysResult} 149 */ 150 async computeBackupKeys(recoveryCode, salt) { 151 let textEncoder = new TextEncoder(); 152 let recoveryCodeBytes = textEncoder.encode(recoveryCode); 153 154 let keyMaterial = await crypto.subtle.importKey( 155 "raw", 156 recoveryCodeBytes, 157 "PBKDF2", 158 false /* extractable */, 159 ["deriveBits"] 160 ); 161 162 // Then we derive the "backup key", using 163 // PBKDF2(recoveryCode, saltPrefix || SALT_SUFFIX, SHA-256, 600,000) 164 const ITERATIONS = 600_000; 165 166 let backupKeyBits = await crypto.subtle.deriveBits( 167 { 168 name: "PBKDF2", 169 salt, 170 iterations: ITERATIONS, 171 hash: "SHA-256", 172 }, 173 keyMaterial, 174 256 175 ); 176 177 // This is a little awkward, but the way that the WebCrypto API currently 178 // works is that we have to read in those bits as a "raw HKDF key", and 179 // only then can we derive our other HKDF keys from it. 180 let backupKeyHKDF = await crypto.subtle.importKey( 181 "raw", 182 backupKeyBits, 183 { 184 name: "HKDF", 185 hash: "SHA-256", 186 }, 187 false /* extractable */, 188 ["deriveKey", "deriveBits"] 189 ); 190 191 // Re-derive BackupAuthKey as HKDF(backupKey, “backupkey-auth”, salt=None) 192 let backupAuthKey = new Uint8Array( 193 await crypto.subtle.deriveBits( 194 { 195 name: "HKDF", 196 salt: new Uint8Array(0), // no salt 197 info: textEncoder.encode("backupkey-auth"), 198 hash: "SHA-256", 199 }, 200 backupKeyHKDF, 201 256 202 ) 203 ); 204 205 let backupEncKey = await crypto.subtle.deriveKey( 206 { 207 name: "HKDF", 208 salt: new Uint8Array(0), // no salt 209 info: textEncoder.encode("backupkey-enc-key"), 210 hash: "SHA-256", 211 }, 212 backupKeyHKDF, 213 { name: "AES-GCM", length: 256 }, 214 false /* extractable */, 215 ["encrypt", "decrypt", "wrapKey"] 216 ); 217 218 return { backupAuthKey, backupEncKey }; 219 }, 220 221 /** 222 * @typedef {object} ComputeEncryptionKeysResult 223 * @property {CryptoKey} archiveEncKey 224 * This is an AES-GCM key used to encrypt chunks of a backup archive. 225 * @property {CryptoKey} authKey 226 * This is a unique authKey for a particular backup that lets us 227 * generate the confirmation HMAC for the backup metadata. 228 */ 229 230 /** 231 * Computes the encryption keys for a particular archive. 232 * 233 * @param {Uint8Array} archiveKeyMaterial 234 * The key material used to generate the encryption keys. 235 * @param {Uint8Array} backupAuthKey 236 * The backupAuthKey returned from computeBackupKeys. 237 * @returns {ComputeEncryptionKeysResult} 238 */ 239 async computeEncryptionKeys(archiveKeyMaterial, backupAuthKey) { 240 let archiveKey = await crypto.subtle.importKey( 241 "raw", 242 archiveKeyMaterial, 243 { name: "HKDF" }, 244 false, // Not extractable 245 ["deriveKey", "deriveBits"] 246 ); 247 248 let textEncoder = new TextEncoder(); 249 // Derive the EncKey as HKDF(salt=BackupAuthkey, key=ArchiveKey,info=’archive-enc-key’) 250 let archiveEncKey = await crypto.subtle.deriveKey( 251 { 252 name: "HKDF", 253 salt: backupAuthKey, 254 info: textEncoder.encode("archive-enc-key"), 255 hash: "SHA-256", 256 }, 257 archiveKey, 258 { name: "AES-GCM", length: 256 }, 259 true /* extractable */, 260 ["decrypt", "encrypt"] 261 ); 262 263 // Derive the AuthKey as HKDF(salt=BackupAuthkey, key=ArchiveKey, info=‘archive-auth-key’) 264 // Note - this is distinct for this particular backup. It is not the same as 265 // the BackupAuthKey from ArchiveEncryptionState. It only uses the 266 // BackupAuthKey from the ArchiveEncryptionState as a salt. 267 let authKey = await crypto.subtle.deriveKey( 268 { 269 name: "HKDF", 270 salt: backupAuthKey, 271 info: textEncoder.encode("archive-auth-key"), 272 hash: "SHA-256", 273 }, 274 archiveKey, 275 { name: "HMAC", hash: "SHA-256", length: 256 }, 276 false /* extractable */, 277 ["sign", "verify"] 278 ); 279 280 return { archiveEncKey, authKey }; 281 }, 282 283 /** 284 * Given a string decoded from a byte buffer by `TextDecoder.decode`, 285 * returns the number of bytes (0-3) at the start of the string that 286 * could not be decoded. 287 * 288 * This assumes undecoded content will only appear at the beginning of 289 * the string. This also assumes undecoded content spans no more than 290 * 3 bytes. These assumptions are based on running `TextDecoder.decode` 291 * on an arbitrary span of bytes from a valid UTF-8 string. 292 * 293 * @param {string} str 294 * String whose beginning you want to inspect for the Unicode replacement 295 * character: U+FFFD (�). 296 * @returns {number} 297 * Number of characters, between 0 and 3, at the beginning of the string 298 * that could not be decoded by `TextDecoder` and so were replaced by the 299 * Unicode replacement character: U+FFFD (�). 300 * 301 * @see https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder/fatal 302 * 303 * @example countReplacementCharacters("\uFFFD\uFFFD\uFFFD🌞") == 3 304 * @example countReplacementCharacters("\uFFFD\uFFFD🌞") == 2 305 * @example countReplacementCharacters("\uFFFD🌞") == 1 306 * @example countReplacementCharacters("🌞") == 0 307 */ 308 countReplacementCharacters(str) { 309 let count = 0; 310 let lengthToCheck = Math.min(4, str.length); 311 312 for (let index = 0; index < lengthToCheck; index += 1) { 313 if (str[index] == "\uFFFD") { 314 count += 1; 315 } 316 } 317 318 return count; 319 }, 320 };