diff --git a/cli/helpers.js b/cli/helpers.js index a0f8a91..2593e6e 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -2,7 +2,7 @@ const pathModule = require("path"); const fs = require("fs"); const readline = require("readline"); -const { generateRandomSalt, generateRandomString } = require("../lib/cryptoEngine.js"); +const { generateRandomSaltString, generateRandomString } = require("../lib/cryptoEngine.js"); const { renderTemplate } = require("../lib/formater.js"); const Yargs = require("yargs"); @@ -74,14 +74,14 @@ function prompt(question) { } /** - * @param {string} password + * @param {string} passwordString * @param {boolean} isShortAllowed * @returns {Promise} */ -async function validatePassword(password, isShortAllowed) { - if (password.length < 14 && !isShortAllowed) { +async function validatePasswordString(passwordString, isShortAllowed) { + if (passwordString.length < 14 && !isShortAllowed) { const shouldUseShort = await prompt( - `WARNING: Your password is less than 14 characters (length: ${password.length})` + + `WARNING: Your password is less than 14 characters (length: ${passwordString.length})` + " and it's easy to try brute-forcing on public files, so we recommend using a longer one. Here's a generated one: " + generateRandomString(21) + "\nYou can hide this warning by increasing your password length or adding the '--short' flag." + @@ -94,7 +94,7 @@ async function validatePassword(password, isShortAllowed) { } } } -exports.validatePassword = validatePassword; +exports.validatePasswordString = validatePasswordString; /** * Get the config from the config file. @@ -124,7 +124,7 @@ exports.writeConfig = writeConfig; * @param {string} passwordArgument - password from the command line * @returns {Promise} */ -async function getPassword(passwordArgument) { +async function getPasswordString(passwordArgument) { // try to get the password from the environment variable const envPassword = process.env.STATICRYPT_PASSWORD; const hasEnvPassword = envPassword !== undefined && envPassword !== ""; @@ -140,7 +140,7 @@ async function getPassword(passwordArgument) { // prompt the user for their password return prompt("Enter your long, unusual password: "); } -exports.getPassword = getPassword; +exports.getPasswordString = getPasswordString; /** * @param {string} filepath @@ -155,13 +155,26 @@ function getFileContent(filepath) { } exports.getFileContent = getFileContent; +/** + * @param {string} filepath + * @returns {Uint8Array} + */ +function getFileContentBytes(filepath) { + try { + return new Uint8Array(fs.readFileSync(filepath)); + } catch (e) { + exitWithError(`input file '${filepath}' does not exist!`); + } +} +exports.getFileContentBytes = getFileContentBytes; + /** * @param {object} namedArgs * @param {object} config * @returns {string} */ -function getValidatedSalt(namedArgs, config) { - const salt = getSalt(namedArgs, config); +function getValidatedSaltString(namedArgs, config) { + const salt = getSaltString(namedArgs, config); // validate the salt if (salt.length !== 32 || /[^a-f0-9]/.test(salt)) { @@ -174,16 +187,16 @@ function getValidatedSalt(namedArgs, config) { return salt; } -exports.getValidatedSalt = getValidatedSalt; +exports.getValidatedSaltString = getValidatedSaltString; /** * @param {object} namedArgs * @param {object} config * @returns {string} */ -function getSalt(namedArgs, config) { +function getSaltString(namedArgs, config) { // either a salt was provided by the user through the flag --salt - if (!!namedArgs.salt) { + if (namedArgs.salt) { return String(namedArgs.salt).toLowerCase(); } @@ -192,7 +205,7 @@ function getSalt(namedArgs, config) { return config.salt; } - return generateRandomSalt(); + return generateRandomSaltString(); } /** diff --git a/cli/index.js b/cli/index.js index 00cd8c3..9a349be 100755 --- a/cli/index.js +++ b/cli/index.js @@ -17,7 +17,7 @@ const fs = require("fs"); const cryptoEngine = require("../lib/cryptoEngine.js"); const codec = require("../lib/codec.js"); -const { generateRandomSalt } = cryptoEngine; +const { generateRandomSaltString } = cryptoEngine; const { decode, encodeWithHashedPassword } = codec.init(cryptoEngine); const { OUTPUT_DIRECTORY_DEFAULT_PATH, @@ -26,12 +26,13 @@ const { genFile, getConfig, getFileContent, - getPassword, - getValidatedSalt, + getFileContentBytes, + getPasswordString, + getValidatedSaltString, isOptionSetByUser, parseCommandLineArguments, recursivelyApplyCallbackToHtmlFiles, - validatePassword, + validatePasswordString, writeConfig, writeFile, getFullOutputPath, @@ -63,14 +64,14 @@ async function runStatiCrypt() { // if the 's' flag is passed without parameter, generate a salt, display & exit if (hasSaltFlag && !namedArgs.salt) { - const generatedSalt = generateRandomSalt(); + const generatedSaltString = generateRandomSaltString(); // show salt - console.log(generatedSalt); + console.log(generatedSaltstring); // write to config file if it doesn't exist if (!config.salt) { - config.salt = generatedSalt; + config.salt = generatedSaltstring; writeConfig(configPath, config); } @@ -78,16 +79,21 @@ async function runStatiCrypt() { } // get the salt & password - const salt = getValidatedSalt(namedArgs, config); - const password = await getPassword(namedArgs.password); + const saltString = getValidatedSaltString(namedArgs, config); + const salt = cryptoEngine.HexEncoder.parse(saltString); + + const passwordString = await getPasswordString(namedArgs.password); + const password = cryptoEngine.UTF8Encoder.parse(passwordString); + const hashedPassword = await cryptoEngine.hashPassword(password, salt); + const hashedPasswordString = cryptoEngine.HexEncoder.stringify(hashedPassword); // display the share link with the hashed password if the --share flag is set if (hasShareFlag) { - await validatePassword(password, namedArgs.short); + await validatePasswordString(passwordString, namedArgs.short); let url = namedArgs.share || ""; - url += "#staticrypt_pwd=" + hashedPassword; + url += "#staticrypt_pwd=" + hashedPasswordString; if (namedArgs.shareRemember) { url += `&remember_me`; @@ -124,11 +130,11 @@ async function runStatiCrypt() { return; } - await validatePassword(password, namedArgs.short); + await validatePasswordString(passwordString, namedArgs.short); // write salt to config file - if (config.salt !== salt) { - config.salt = salt; + if (config.salt !== saltString) { + config.salt = saltString; writeConfig(configPath, config); } @@ -157,7 +163,7 @@ async function runStatiCrypt() { fullPath, fullRootDirectory, hashedPassword, - salt, + saltString, baseTemplateData, isRememberEnabled, namedArgs @@ -169,20 +175,46 @@ async function runStatiCrypt() { }); } +function getEncryptedData(encryptedFileContent, path) { + let encrypted = null; + + const encryptedMatch = encryptedFileContent.match( + /data-encrypted="data:application\/octet-stream\;base64\,([^"]+)"/ + ); + if (!encryptedMatch) { + console.log(`ERROR: could not extract cipher text from ${path}`); + } else { + encrypted = cryptoEngine.Base64Encoder.parse(encryptedMatch[1]); + } + + return encrypted; +} + async function decodeAndGenerateFile(path, fullRootDirectory, hashedPassword, outputDirectory) { // get the file content const encryptedFileContent = getFileContent(path); // extract the cipher text from the encrypted file - const cipherTextMatch = encryptedFileContent.match(/"staticryptEncryptedMsgUniqueVariableName":\s*"([^"]+)"/); + const ivMatch = encryptedFileContent.match(/"staticryptIvUniqueVariableName":\s*"([^"]+)"/); + const hmacMatch = encryptedFileContent.match(/"staticryptHmacUniqueVariableName":\s*"([^"]+)"/); const saltMatch = encryptedFileContent.match(/"staticryptSaltUniqueVariableName":\s*"([^"]+)"/); - if (!cipherTextMatch || !saltMatch) { - return console.log(`ERROR: could not extract cipher text or salt from ${path}`); + if (!ivMatch || !hmacMatch || !saltMatch) { + return console.log(`ERROR: could not extract cipher text, iv, hmac, or salt from ${path}`); } + const iv = cryptoEngine.HexEncoder.parse(ivMatch[1]); + const hmac = cryptoEngine.HexEncoder.parse(hmacMatch[1]); + const salt = cryptoEngine.HexEncoder.parse(saltMatch[1]); + // decrypt input - const { success, decoded } = await decode(cipherTextMatch[1], hashedPassword, saltMatch[1]); + const { success, decoded } = await decode( + iv, + () => getEncryptedData(encryptedFileContent, path), + hmac, + hashedPassword, + salt + ); if (!success) { return console.log(`ERROR: could not decrypt ${path}`); @@ -197,28 +229,28 @@ async function encodeAndGenerateFile( path, rootDirectoryFromArguments, hashedPassword, - salt, + saltString, baseTemplateData, isRememberEnabled, namedArgs ) { - // get the file content - const contents = getFileContent(path); - // encrypt input - const encryptedMsg = await encodeWithHashedPassword(contents, hashedPassword); + const encryptedMsg = await encodeWithHashedPassword(() => getFileContentBytes(path), hashedPassword); let rememberDurationInDays = parseInt(namedArgs.remember); rememberDurationInDays = isNaN(rememberDurationInDays) ? 0 : rememberDurationInDays; const staticryptConfig = { - staticryptEncryptedMsgUniqueVariableName: encryptedMsg, + staticryptIvUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.iv), + staticryptHmacUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.hmac), + isRememberEnabled, rememberDurationInDays, - staticryptSaltUniqueVariableName: salt, + staticryptSaltUniqueVariableName: saltString, }; const templateData = { ...baseTemplateData, + encrypted_data: cryptoEngine.Base64Encoder.stringify(encryptedMsg.encrypted), staticrypt_config: staticryptConfig, }; diff --git a/lib/codec.js b/lib/codec.js index 1772181..ca9f7a5 100644 --- a/lib/codec.js +++ b/lib/codec.js @@ -6,46 +6,91 @@ function init(cryptoEngine) { const exports = {}; + /** + * Implement digest signing: + * + * hmac = sign( hashedPassword, iv + digest(encrypted) ) + * + * To avoid having to make copy of encrypted. + * + * @param {Uint8Array} iv + * @param {Uint8Array|null} encrypted + * @param {Uint8Array} hashedPassword + * @param {Uint8Array|null} encryptedDataHash + * + * @returns {Promise} The calculated hmac + */ + async function signDigest(iv, encrypted, hashedPassword, encryptedDataHash = null) { + // we use a hash of the encrypted bytes as a proxy for the actual bytes + // when generating the HMAC to avoid making a copy of the encrypted bytes + + if (!encryptedDataHash) { + encryptedDataHash = await cryptoEngine.digestMessage(encrypted); + } + + const messageBuffer = new Uint8Array(iv.length + encryptedDataHash.length); + messageBuffer.set(iv); + messageBuffer.set(encryptedDataHash, iv.length); + + const hmac = await cryptoEngine.signMessage(hashedPassword, messageBuffer); + + return hmac; + } + /** * Top-level function for encoding a message. * Includes password hashing, encryption, and signing. * - * @param {string} msg - * @param {string} password - * @param {string} salt + * @param {function(): Uint8Array} msgReader + * @param {Uint8Array} password + * @param {Uint8Array} salt * - * @returns {string} The encoded text + * @returns {Promise} The encoded text */ - async function encode(msg, password, salt) { + async function encode(msgReader, password, salt) { const hashedPassword = await cryptoEngine.hashPassword(password, salt); + const authEncryptionData = await encodeWithHashedPassword(msgReader, hashedPassword); - const encrypted = await cryptoEngine.encrypt(msg, hashedPassword); - - // we use the hashed password in the HMAC because this is effectively what will be used a password (so we can store - // it in localStorage safely, we don't use the clear text password) - const hmac = await cryptoEngine.signMessage(hashedPassword, encrypted); - - return hmac + encrypted; + return authEncryptionData; } exports.encode = encode; /** - * Encode using a password that has already been hashed. This is useful to encode multiple messages in a row, that way - * we don't need to hash the password multiple times. + * Encode using a password that has already been hashed. This is useful to + * encode multiple messages in a row, that way we don't need to hash the + * password multiple times. + * + * We take a message reader function instead of a message buffer so we + * can release its storage when it is no longer needed (caller + * isn't stuck holding a reference). * - * @param {string} msg - * @param {string} hashedPassword + * We compress data before encryption because it reduces size by about 30% + * with a minimal additional compute cost compared to encryption and signing. + * This can result in an encrypted file that's smaller than the original. * - * @returns {string} The encoded text + * @param {function(): Uint8Array} msgReader + * @param {Uint8Array} hashedPassword + * + * @returns { Promise<{iv: Uint8Array, encrypted: Uint8Array, hmac: Uint8Array}> } */ - async function encodeWithHashedPassword(msg, hashedPassword) { - const encrypted = await cryptoEngine.encrypt(msg, hashedPassword); + async function encodeWithHashedPassword(msgReader, hashedPassword) { + // compress then encrypt msg. nest function calls here to allow buffers + // to be collected without additional code (null assignments) + + const encryptionData = await cryptoEngine.encrypt( + await cryptoEngine.Compressor.compress(msgReader()), + hashedPassword + ); // we use the hashed password in the HMAC because this is effectively what will be used a password (so we can store // it in localStorage safely, we don't use the clear text password) - const hmac = await cryptoEngine.signMessage(hashedPassword, encrypted); - return hmac + encrypted; + const hmac = await signDigest(encryptionData.iv, encryptionData.encrypted, hashedPassword); + + return { + ...encryptionData, + hmac: hmac, + }; } exports.encodeWithHashedPassword = encodeWithHashedPassword; @@ -53,41 +98,50 @@ function init(cryptoEngine) { * Top-level function for decoding a message. * Includes signature check and decryption. * - * @param {string} signedMsg - * @param {string} hashedPassword - * @param {string} salt - * @param {int} backwardCompatibleAttempt - * @param {string} originalPassword + * We take an encrypted reader function instead of a message buffer so we + * can release its storage when it is no longer needed (caller isn't stuck + * holding a reference). + * + * @param {Uint8Array} iv + * @param {function(): Uint8Array|ArrayBuffer} encryptedReader + * @param {Uint8Array} hmac + * @param {Uint8Array} hashedPassword + * @param {Uint8Array} salt * - * @returns {Object} {success: true, decoded: string} | {success: false, message: string} + * @returns { Promise<{success: true, decoded: Uint8Array} | {success: false, message: string}> } */ - async function decode(signedMsg, hashedPassword, salt, backwardCompatibleAttempt = 0, originalPassword = "") { - const encryptedHMAC = signedMsg.substring(0, 64); - const encryptedMsg = signedMsg.substring(64); - const decryptedHMAC = await cryptoEngine.signMessage(hashedPassword, encryptedMsg); + async function decode(iv, encryptedReader, hmac, hashedPassword, salt) { + let encrypted = await encryptedReader(); - if (decryptedHMAC !== encryptedHMAC) { + const encryptedDataHash = await cryptoEngine.digestMessage(encrypted); + + let calculatedHMAC = await signDigest(iv, null, hashedPassword, encryptedDataHash); + + if (!cryptoEngine.isArrayEqual(calculatedHMAC, hmac)) { // we have been raising the number of iterations in the hashing algorithm multiple times, so to support the old // remember-me/autodecrypt links we need to try bringing the old hashes up to speed. - originalPassword = originalPassword || hashedPassword; - if (backwardCompatibleAttempt === 0) { - const updatedHashedPassword = await cryptoEngine.hashThirdRound(originalPassword, salt); - return decode(signedMsg, updatedHashedPassword, salt, backwardCompatibleAttempt + 1, originalPassword); - } - if (backwardCompatibleAttempt === 1) { - let updatedHashedPassword = await cryptoEngine.hashSecondRound(originalPassword, salt); - updatedHashedPassword = await cryptoEngine.hashThirdRound(updatedHashedPassword, salt); + const hashedPassword2 = await cryptoEngine.hashThirdRound(hashedPassword, salt); + calculatedHMAC = await signDigest(iv, null, hashedPassword2, encryptedDataHash); - return decode(signedMsg, updatedHashedPassword, salt, backwardCompatibleAttempt + 1, originalPassword); - } + if (!cryptoEngine.isArrayEqual(calculatedHMAC, hmac)) { + let hashedPassword3 = await cryptoEngine.hashSecondRound(hashedPassword, salt); + hashedPassword3 = await cryptoEngine.hashThirdRound(hashedPassword3, salt); - return { success: false, message: "Signature mismatch" }; + calculatedHMAC = await signDigest(iv, null, hashedPassword3, encryptedDataHash); + + if (!cryptoEngine.isArrayEqual(calculatedHMAC, hmac)) { + return { success: false, message: "Signature mismatch" }; + } + } } + const decrypted = await cryptoEngine.decrypt(iv, encrypted, hashedPassword); + encrypted = null; + return { success: true, - decoded: await cryptoEngine.decrypt(encryptedMsg, hashedPassword), + decoded: new Uint8Array(await cryptoEngine.Compressor.decompress(decrypted)), }; } exports.decode = decode; diff --git a/lib/cryptoEngine.js b/lib/cryptoEngine.js index db81afd..6c63e62 100644 --- a/lib/cryptoEngine.js +++ b/lib/cryptoEngine.js @@ -1,75 +1,303 @@ -const crypto = typeof window === "undefined" ? require("node:crypto").webcrypto : window.crypto; +const isNode = typeof window === "undefined"; +const crypto = isNode ? require("node:crypto").webcrypto : window.crypto; +const zlib = isNode ? require("zlib") : null; const { subtle } = crypto; const IV_BITS = 16 * 8; -const HEX_BITS = 4; const ENCRYPTION_ALGO = "AES-CBC"; +/** + * Compare 2 arrays and return true if they are equal. + * + * @param {Uint8Array} a + * @param {Uint8Array} b + * @returns {boolean} + */ +function isArrayEqual(a, b) { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} +exports.isArrayEqual = isArrayEqual; + /** * Translates between utf8 encoded hexadecimal strings - * and Uint8Array bytes. + * and Uint8Array. */ -const HexEncoder = { +const HexEncoder = isNode + ? { + // Node version + + /** + * hex string -> Uint8Array + * @param {string|null} hexString + * @returns {Uint8Array|null} + */ + parse: function (hexString) { + const bytes = Buffer.from(hexString, "hex"); + return bytes; + }, + + /** + * Uint8Array -> hex string + * @param {Uint8Array|null} bytes + * @returns {string|null} + */ + stringify: function (bytes) { + const buffer = Buffer.from(bytes); + const hexString = buffer.toString("hex"); + + return hexString; + }, + } + : { + // Browser version + + /** + * hex string -> Uint8Array + * @param {string} hexString + * @returns {Uint8Array} + */ + parse: function (hexString) { + if (!hexString) { + return null; + } + + if (hexString.length % 2 !== 0) { + throw new Error("Invalid hex string length"); + } + + const bytes = new Uint8Array(hexString.length / 2); + + for (let i = 0; i < hexString.length; i += 2) { + const byte = parseInt(hexString.substring(i, i + 2), 16); + if (isNaN(byte)) { + throw new Error("Invalid character in hex string."); + } + bytes[i / 2] = byte; + } + + return bytes; + }, + + /** + * Uint8Array -> hex string + * @param {Uint8Array} bytes + * @returns {string} + */ + stringify: function (bytes) { + if (!bytes) { + return null; + } + + return Array.from(bytes) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + }, + }; +exports.HexEncoder = HexEncoder; + +/** + * Translates between Base64 encoded strings + * and Uint8Array. + */ +const Base64Encoder = isNode + ? { + // Node version + + /** + * Base64 encoded string -> Uint8Array + * @param {string|null} b64String + * @returns {Uint8Array|null} + */ + parse: function (b64String) { + const bytes = Buffer.from(b64String, "base64"); + return bytes; + }, + + /** + * Uint8Array -> Base64 encoded string + * @param {Uint8Array|null} bytes + * @returns {string|null} + */ + stringify: function (bytes) { + const buffer = Buffer.from(bytes); + const b64String = buffer.toString("base64"); + + return b64String; + }, + } + : { + // Browser version + + /** + * Base64 encoded string -> Uint8Array + * @param {string} b64String + * @returns {Uint8Array} + */ + parse: async function (b64String) { + if (!b64String) { + return null; + } + + const dataUrl = `data:application/octet-stream;base64,${b64String}`; + const response = await fetch(dataUrl); + const arrayBuffer = await response.arrayBuffer(); + + return new Uint8Array(arrayBuffer); + }, + + /** + * Uint8Array -> Base64 encoded string + * @param {Uint8Array} bytes + * @returns {string} + */ + stringify: function (bytes) { + if (!bytes) { + return null; + } + + const blob = new Blob([bytes]); + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = () => { + // The result includes "data:;base64," prefix, so we remove it + const base64String = reader.result.split(",")[1]; + resolve(base64String); + }; + + reader.onerror = (error) => { + reject(error); + }; + + reader.readAsDataURL(blob); + }); + }, + }; +exports.Base64Encoder = Base64Encoder; + +/** + * Translates between utf8 string and Uint8Array. + */ +const UTF8Encoder = { /** - * hex string -> bytes - * @param {string} hexString - * @returns {Uint8Array} + * string -> Uint8Array + * @param {string|null} str + * @returns {Uint8Array|null} */ - parse: function (hexString) { - if (hexString.length % 2 !== 0) throw "Invalid hexString"; - const arrayBuffer = new Uint8Array(hexString.length / 2); - - for (let i = 0; i < hexString.length; i += 2) { - const byteValue = parseInt(hexString.substring(i, i + 2), 16); - if (isNaN(byteValue)) { - throw "Invalid hexString"; - } - arrayBuffer[i / 2] = byteValue; + parse: function (str) { + if (!str) { + return null; } - return arrayBuffer; + return new TextEncoder().encode(str); }, /** - * bytes -> hex string - * @param {Uint8Array} bytes - * @returns {string} + * Uint8Array -> string + * @param {Uint8Array|null} bytes + * @returns {string|null} */ stringify: function (bytes) { - const hexBytes = []; - - for (let i = 0; i < bytes.length; ++i) { - let byteString = bytes[i].toString(16); - if (byteString.length < 2) { - byteString = "0" + byteString; - } - hexBytes.push(byteString); + if (!bytes) { + return null; } - return hexBytes.join(""); + return new TextDecoder().decode(bytes); }, }; +exports.UTF8Encoder = UTF8Encoder; /** - * Translates between utf8 strings and Uint8Array bytes. + * Implements compression / decompression of UintArray/ArrayBuffer */ -const UTF8Encoder = { - parse: function (str) { - return new TextEncoder().encode(str); - }, - - stringify: function (bytes) { - return new TextDecoder().decode(bytes); - }, -}; +const Compressor = isNode + ? { + // Node version + + /** + * Uint8Array -> Compressed Uint8Array + * @param {Uint8Array|ArrayBuffer} bytes + * @returns {Uint8Array} + */ + compress: async function (bytes) { + const compressed = zlib.gzipSync(bytes); + + return compressed; + }, + + /** + * Compressed Uint8Array -> Uint8Array + * @param {Uint8Array|ArrayBuffer} compressed + * @returns {Uint8Array} + */ + decompress: async function (compressed) { + const bytes = new Uint8Array(zlib.gunzipSync(compressed)); + + return bytes; + }, + } + : { + // Browser version + + /** + * Uint8Array -> Compressed Uint8Array + * @param {Uint8Array|ArrayBuffer} bytes + * @returns {Uint8Array} + */ + compress: async function (bytes) { + const stream = new Blob([data]).stream(); + + const compressionStream = new CompressionStream("gzip"); + const compressedStream = stream.pipeThrough(compressionStream); + + const compressedResponse = new Response(compressedStream); + const compressedArrayBuffer = await compressedResponse.arrayBuffer(); + + return new Uint8Array(compressedArrayBuffer); + }, + + /** + * Compressed Uint8Array -> Uint8Array + * @param {Uint8Array|ArrayBuffer} compressed + * @returns {Uint8Array} + */ + decompress: async function (compressed) { + const compressedStream = new Blob([compressed]).stream(); + + const decompressionStream = new DecompressionStream("gzip"); + const decompressedStream = compressedStream.pipeThrough(decompressionStream); + + const decompressedResponse = new Response(decompressedStream); + const decompressedArrayBuffer = await decompressedResponse.arrayBuffer(); + + return new Uint8Array(decompressedArrayBuffer); + }, + }; +exports.Compressor = Compressor; /** * Salt and encrypt a msg with a password. + * + * @param {Uint8Array|ArrayBuffer} msg + * @param {Uint8Array} hashedPassword + * + * @returns { Promise<{iv: Uint8Array, encrypted: Uint8Array}> } */ async function encrypt(msg, hashedPassword) { // Must be 16 bytes, unpredictable, and preferably cryptographically random. However, it need not be secret. // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/encrypt#parameters + const iv = crypto.getRandomValues(new Uint8Array(IV_BITS / 8)); - const key = await subtle.importKey("raw", HexEncoder.parse(hashedPassword), ENCRYPTION_ALGO, false, ["encrypt"]); + const key = await subtle.importKey("raw", hashedPassword, ENCRYPTION_ALGO, false, ["encrypt"]); const encrypted = await subtle.encrypt( { @@ -77,47 +305,47 @@ async function encrypt(msg, hashedPassword) { iv: iv, }, key, - UTF8Encoder.parse(msg) + msg ); - // iv will be 32 hex characters, we prepend it to the ciphertext for use in decryption - return HexEncoder.stringify(iv) + HexEncoder.stringify(new Uint8Array(encrypted)); + // return iv with the ciphertext for use in decryption + return { + iv: iv, + encrypted: new Uint8Array(encrypted), + }; } exports.encrypt = encrypt; /** * Decrypt a salted msg using a password. * - * @param {string} encryptedMsg - * @param {string} hashedPassword - * @returns {Promise} + * @param {Uint8Array} iv + * @param {Uint8Array} encrypted + * @param {Uint8Array} hashedPassword + * @returns { Promise } */ -async function decrypt(encryptedMsg, hashedPassword) { - const ivLength = IV_BITS / HEX_BITS; - const iv = HexEncoder.parse(encryptedMsg.substring(0, ivLength)); - const encrypted = encryptedMsg.substring(ivLength); - - const key = await subtle.importKey("raw", HexEncoder.parse(hashedPassword), ENCRYPTION_ALGO, false, ["decrypt"]); +async function decrypt(iv, encrypted, hashedPassword) { + const key = await subtle.importKey("raw", hashedPassword, ENCRYPTION_ALGO, false, ["decrypt"]); - const outBuffer = await subtle.decrypt( + const decryptedBuffer = await subtle.decrypt( { name: ENCRYPTION_ALGO, iv: iv, }, key, - HexEncoder.parse(encrypted) + encrypted ); - return UTF8Encoder.stringify(new Uint8Array(outBuffer)); + return new Uint8Array(decryptedBuffer); } exports.decrypt = decrypt; /** * Salt and hash the password so it can be stored in localStorage without opening a password reuse vulnerability. * - * @param {string} password - * @param {string} salt - * @returns {Promise} + * @param {Uint8Array} password + * @param {Uint8Array} salt + * @returns { Promise } */ async function hashPassword(password, salt) { // we hash the password in multiple steps, each adding more iterations. This is because we used to allow less @@ -134,12 +362,12 @@ exports.hashPassword = hashPassword; * This hashes the password with 1k iterations. This is a low number, we need this function to support backwards * compatibility. * - * @param {string} password - * @param {string} salt - * @returns {Promise} + * @param {Uint8Array} password + * @param {Uint8Array} salt + * @returns { Promise } */ -function hashLegacyRound(password, salt) { - return pbkdf2(password, salt, 1000, "SHA-1"); +async function hashLegacyRound(password, salt) { + return await pbkdf2(password, salt, 1000, "SHA-1"); } exports.hashLegacyRound = hashLegacyRound; @@ -147,12 +375,12 @@ exports.hashLegacyRound = hashLegacyRound; * Add a second round of iterations. This is because we used to use 1k, so for backwards compatibility with * remember-me/autodecrypt links, we need to support going from that to more iterations. * - * @param hashedPassword - * @param salt - * @returns {Promise} + * @param {Uint8Array} hashedPassword + * @param {Uint8Array} salt + * @returns { Promise } */ -function hashSecondRound(hashedPassword, salt) { - return pbkdf2(hashedPassword, salt, 14000, "SHA-256"); +async function hashSecondRound(hashedPassword, salt) { + return await pbkdf2(hashedPassword, salt, 14000, "SHA-256"); } exports.hashSecondRound = hashSecondRound; @@ -160,52 +388,52 @@ exports.hashSecondRound = hashSecondRound; * Add a third round of iterations to bring total number to 600k. This is because we used to use 1k, then 15k, so for * backwards compatibility with remember-me/autodecrypt links, we need to support going from that to more iterations. * - * @param hashedPassword - * @param salt - * @returns {Promise} + * @param {Uint8Array} hashedPassword + * @param {Uint8Array} salt + * @returns { Promise } */ -function hashThirdRound(hashedPassword, salt) { - return pbkdf2(hashedPassword, salt, 585000, "SHA-256"); +async function hashThirdRound(hashedPassword, salt) { + return await pbkdf2(hashedPassword, salt, 585000, "SHA-256"); } exports.hashThirdRound = hashThirdRound; /** * Salt and hash the password so it can be stored in localStorage without opening a password reuse vulnerability. * - * @param {string} password - * @param {string} salt + * @param {Uint8Array} password + * @param {Uint8Array} salt * @param {int} iterations * @param {string} hashAlgorithm - * @returns {Promise} + * @returns { Promise } */ async function pbkdf2(password, salt, iterations, hashAlgorithm) { - const key = await subtle.importKey("raw", UTF8Encoder.parse(password), "PBKDF2", false, ["deriveBits"]); + const key = await subtle.importKey("raw", password, "PBKDF2", false, ["deriveBits"]); - const keyBytes = await subtle.deriveBits( + const derivedKey = await subtle.deriveBits( { name: "PBKDF2", hash: hashAlgorithm, iterations, - salt: UTF8Encoder.parse(salt), + salt: salt, }, key, 256 ); - return HexEncoder.stringify(new Uint8Array(keyBytes)); + return new Uint8Array(derivedKey); } -function generateRandomSalt() { +function generateRandomSaltString() { const bytes = crypto.getRandomValues(new Uint8Array(128 / 8)); return HexEncoder.stringify(new Uint8Array(bytes)); } -exports.generateRandomSalt = generateRandomSalt; +exports.generateRandomSaltString = generateRandomSaltString; async function signMessage(hashedPassword, message) { const key = await subtle.importKey( "raw", - HexEncoder.parse(hashedPassword), + hashedPassword, { name: "HMAC", hash: "SHA-256", @@ -213,28 +441,33 @@ async function signMessage(hashedPassword, message) { false, ["sign"] ); - const signature = await subtle.sign("HMAC", key, UTF8Encoder.parse(message)); + const signature = await subtle.sign("HMAC", key, message); - return HexEncoder.stringify(new Uint8Array(signature)); + return new Uint8Array(signature); } exports.signMessage = signMessage; +async function digestMessage(message) { + const digest = await subtle.digest("SHA-256", message); + const digestBytes = new Uint8Array(digest); + return digestBytes; +} +exports.digestMessage = digestMessage; + function getRandomAlphanum() { const possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let byteArray; let parsedInt; // Keep generating new random bytes until we get a value that falls // within a range that can be evenly divided by possibleCharacters.length + // to ensure each character is selected without bias do { - byteArray = crypto.getRandomValues(new Uint8Array(1)); - // extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte) - parsedInt = byteArray[0] & 0xff; - } while (parsedInt >= 256 - (256 % possibleCharacters.length)); + randByte = crypto.getRandomValues(new Uint8Array(1))[0]; + } while (randByte >= 256 - (256 % possibleCharacters.length)); // Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1 - const randomIndex = parsedInt % possibleCharacters.length; + const randomIndex = randByte % possibleCharacters.length; return possibleCharacters[randomIndex]; } diff --git a/lib/formater.js b/lib/formater.js index 92c48b5..3945ec8 100644 --- a/lib/formater.js +++ b/lib/formater.js @@ -12,15 +12,17 @@ */ function renderTemplate(templateString, data) { return templateString.replace(/\/\*\[\|\s*(\w+)\s*\|]\*\/\s*0/g, function (_, key) { - if (!data || data[key] === undefined) { - return key; - } + let replacementString; - if (typeof data[key] === "object") { - return JSON.stringify(data[key]); + if (!data || data[key] === undefined) { + replacementString = key; + } else if (typeof data[key] === "object") { + replacementString = JSON.stringify(data[key]); + } else { + replacementString = data[key]; } - return data[key]; + return replacementString; }); } exports.renderTemplate = renderTemplate; diff --git a/lib/password_template.html b/lib/password_template.html index 680f931..628b2ef 100644 --- a/lib/password_template.html +++ b/lib/password_template.html @@ -217,7 +217,11 @@ - +