From 8dcefcc46cd7cb177294f27573361760be1ce1ba Mon Sep 17 00:00:00 2001 From: Jim Harkins Date: Mon, 7 Jul 2025 09:02:53 -0700 Subject: [PATCH 1/2] On Node, HexEncoder.stringify now uses Buffer.toString('hex') --- lib/cryptoEngine.js | 110 +++++++++++++++++++++++++++++--------------- 1 file changed, 74 insertions(+), 36 deletions(-) diff --git a/lib/cryptoEngine.js b/lib/cryptoEngine.js index db81afd..8e2997a 100644 --- a/lib/cryptoEngine.js +++ b/lib/cryptoEngine.js @@ -1,4 +1,5 @@ -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 { subtle } = crypto; const IV_BITS = 16 * 8; @@ -9,44 +10,81 @@ const ENCRYPTION_ALGO = "AES-CBC"; * Translates between utf8 encoded hexadecimal strings * and Uint8Array bytes. */ -const HexEncoder = { - /** - * hex string -> bytes - * @param {string} hexString - * @returns {Uint8Array} - */ - parse: function (hexString) { - if (hexString.length % 2 !== 0) throw "Invalid hexString"; - const arrayBuffer = new Uint8Array(hexString.length / 2); +const HexEncoder = isNode + ? { + // Node version - 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; - } - return arrayBuffer; - }, + /** + * hex string -> bytes + * @param {string} hexString + * @returns {Uint8Array} + */ + parse: function (hexString) { + if (hexString.length % 2 !== 0) throw "Invalid hexString"; + const arrayBuffer = new Uint8Array(hexString.length / 2); - /** - * bytes -> hex string - * @param {Uint8Array} bytes - * @returns {string} - */ - stringify: function (bytes) { - const hexBytes = []; + 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; + } + return arrayBuffer; + }, - for (let i = 0; i < bytes.length; ++i) { - let byteString = bytes[i].toString(16); - if (byteString.length < 2) { - byteString = "0" + byteString; - } - hexBytes.push(byteString); - } - return hexBytes.join(""); - }, -}; + /** + * bytes -> hex string + * @param {Uint8Array} bytes + * @returns {string} + */ + stringify: function (bytes) { + const buffer = Buffer.from(bytes); + const hexString = buffer.toString("hex"); + + return hexString; + }, + } + : { + // Browser version + + /** + * hex string -> bytes + * @param {string} hexString + * @returns {Uint8Array} + */ + 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; + } + return arrayBuffer; + }, + + /** + * bytes -> hex string + * @param {Uint8Array} bytes + * @returns {string} + */ + 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); + } + return hexBytes.join(""); + }, + }; /** * Translates between utf8 strings and Uint8Array bytes. From f2ef582f3ad69770e7b6085419a85abc42d1ad6b Mon Sep 17 00:00:00 2001 From: jimh Date: Mon, 14 Jul 2025 13:16:14 -0700 Subject: [PATCH 2/2] Refactor: prefer Uint8Array as internal representation of binary data This is part of a push to support larger files. The focus is the switch to using Uint8Array to store binary data. But also includes: - When running on Node, use Buffer.from() for hex string conversions. - To avoid large buffer copy, signedMsg as been replaced by an object containing iv, encrypted, and hmac. - hmac calculation has changed so it avoids copying (possibly very large) encrypted data. See signDigest() in lib/codec.js. - Minor cleanup Handling hex encode/decode at the input/output boundaries and using Uint8Array internally for representing binary data has these benefits: - More memory efficient, allows processing of 2x larger files. - Aligns with cryptographic best practices: hashing is now performed on raw binary data (Uint8Array) instead of hex strings. - Behavior is (mostly) unchanged - scripts/index_template.html textContent is not implemented and needs to be redesigned. --- cli/helpers.js | 41 +++++--- cli/index.js | 67 ++++++++----- lib/codec.js | 117 +++++++++++++++++------ lib/cryptoEngine.js | 186 +++++++++++++++++++++++------------- lib/staticryptJs.js | 51 ++++++---- scripts/index_template.html | 22 ++++- 6 files changed, 324 insertions(+), 160 deletions(-) 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..bb6f4f3 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 @@ -174,15 +180,24 @@ async function decodeAndGenerateFile(path, fullRootDirectory, hashedPassword, ou const encryptedFileContent = getFileContent(path); // extract the cipher text from the encrypted file - const cipherTextMatch = encryptedFileContent.match(/"staticryptEncryptedMsgUniqueVariableName":\s*"([^"]+)"/); + let encryptedMatch = encryptedFileContent.match(/"staticryptEncryptedUniqueVariableName":\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 (!encryptedMatch || !ivMatch || !hmacMatch || !saltMatch) { + return console.log(`ERROR: could not extract cipher text, iv, hmac, or salt from ${path}`); } + const encrypted = cryptoEngine.HexEncoder.parse(encryptedMatch[1]); + encryptedMatch = null; + + 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, encrypted, hmac, hashedPassword, salt); if (!success) { return console.log(`ERROR: could not decrypt ${path}`); @@ -197,25 +212,25 @@ 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), + staticryptEncryptedUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.encrypted), + staticryptHmacUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.hmac), + isRememberEnabled, rememberDurationInDays, - staticryptSaltUniqueVariableName: salt, + staticryptSaltUniqueVariableName: saltString, }; const templateData = { ...baseTemplateData, diff --git a/lib/codec.js b/lib/codec.js index 1772181..6df2f57 100644 --- a/lib/codec.js +++ b/lib/codec.js @@ -6,26 +6,52 @@ 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 {Uint8Array} msg + * @param {Uint8Array} password + * @param {Uint8Array} salt * - * @returns {string} The encoded text + * @returns {Promise} The encoded text */ async function encode(msg, password, salt) { const hashedPassword = await cryptoEngine.hashPassword(password, salt); + const authEncryptionData = await encodeWithHashedPassword(msg, 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; @@ -33,19 +59,23 @@ function init(cryptoEngine) { * 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. * - * @param {string} msg - * @param {string} hashedPassword + * @param {Uint8Array} msg + * @param {Uint8Array} hashedPassword * - * @returns {string} The encoded text + * @returns { Promise<{iv: Uint8Array, encrypted: Uint8Array, hmac: Uint8Array}> } */ async function encodeWithHashedPassword(msg, hashedPassword) { - const encrypted = await cryptoEngine.encrypt(msg, hashedPassword); + const encryptionData = 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; + const hmac = await signDigest(encryptionData.iv, encryptionData.encrypted, hashedPassword); + + return { + ...encryptionData, + hmac: hmac, + }; } exports.encodeWithHashedPassword = encodeWithHashedPassword; @@ -53,33 +83,60 @@ 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 {Uint8Array} iv + * @param {Uint8Array} encrypted + * @param {Uint8Array} hmac + * @param {Uint8Array} hashedPassword + * @param {Uint8Array} salt * @param {int} backwardCompatibleAttempt - * @param {string} originalPassword + * @param {Uint8Array} originalPassword * - * @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, + encrypted, + hmac, + hashedPassword, + salt, + backwardCompatibleAttempt = 0, + originalPassword = null + ) { + const encryptedDataHash = await cryptoEngine.digestMessage(encrypted); - if (decryptedHMAC !== encryptedHMAC) { + const 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); + return decode( + iv, + encrypted, + hmac, + updatedHashedPassword, + salt, + backwardCompatibleAttempt + 1, + originalPassword + ); } if (backwardCompatibleAttempt === 1) { let updatedHashedPassword = await cryptoEngine.hashSecondRound(originalPassword, salt); updatedHashedPassword = await cryptoEngine.hashThirdRound(updatedHashedPassword, salt); - return decode(signedMsg, updatedHashedPassword, salt, backwardCompatibleAttempt + 1, originalPassword); + return decode( + iv, + encrypted, + hmac, + updatedHashedPassword, + salt, + backwardCompatibleAttempt + 1, + originalPassword + ); } return { success: false, message: "Signature mismatch" }; @@ -87,7 +144,7 @@ function init(cryptoEngine) { return { success: true, - decoded: await cryptoEngine.decrypt(encryptedMsg, hashedPassword), + decoded: await cryptoEngine.decrypt(iv, encrypted, hashedPassword), }; } exports.decode = decode; diff --git a/lib/cryptoEngine.js b/lib/cryptoEngine.js index 8e2997a..c28e88e 100644 --- a/lib/cryptoEngine.js +++ b/lib/cryptoEngine.js @@ -3,40 +3,52 @@ const crypto = isNode ? require("node:crypto").webcrypto : window.crypto; 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 = isNode ? { // Node version /** - * hex string -> bytes - * @param {string} hexString - * @returns {Uint8Array} + * hex string -> Uint8Array + * @param {string|null} hexString + * @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; - } - return arrayBuffer; + const bytes = Buffer.from(hexString, "hex"); + return bytes; }, /** - * bytes -> hex string - * @param {Uint8Array} bytes - * @returns {string} + * Uint8Array -> hex string + * @param {Uint8Array|null} bytes + * @returns {string|null} */ stringify: function (bytes) { const buffer = Buffer.from(bytes); @@ -49,11 +61,14 @@ const HexEncoder = isNode // Browser version /** - * hex string -> bytes + * hex string -> Uint8Array * @param {string} hexString * @returns {Uint8Array} */ parse: function (hexString) { + if (!hexString) { + return null; + } if (hexString.length % 2 !== 0) throw "Invalid hexString"; const arrayBuffer = new Uint8Array(hexString.length / 2); @@ -68,11 +83,14 @@ const HexEncoder = isNode }, /** - * bytes -> hex string + * Uint8Array -> hex string * @param {Uint8Array} bytes * @returns {string} */ stringify: function (bytes) { + if (!bytes) { + return null; + } const hexBytes = []; for (let i = 0; i < bytes.length; ++i) { @@ -85,29 +103,53 @@ const HexEncoder = isNode return hexBytes.join(""); }, }; +exports.HexEncoder = HexEncoder; /** - * Translates between utf8 strings and Uint8Array bytes. + * Translates between utf8 string and Uint8Array. */ const UTF8Encoder = { + /** + * string -> Uint8Array + * @param {string|null} str + * @returns {Uint8Array|null} + */ parse: function (str) { + if (!str) { + return null; + } return new TextEncoder().encode(str); }, + /** + * Uint8Array -> string + * @param {Uint8Array|null} bytes + * @returns {string|null} + */ stringify: function (bytes) { + if (!bytes) { + return null; + } return new TextDecoder().decode(bytes); }, }; +exports.UTF8Encoder = UTF8Encoder; /** * Salt and encrypt a msg with a password. + * + * @param {Uint8Array} 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( { @@ -115,47 +157,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); +async function decrypt(iv, encrypted, hashedPassword) { + const key = await subtle.importKey("raw", hashedPassword, ENCRYPTION_ALGO, false, ["decrypt"]); - const key = await subtle.importKey("raw", HexEncoder.parse(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 @@ -172,12 +214,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; @@ -185,12 +227,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; @@ -198,52 +240,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", @@ -251,12 +293,19 @@ 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"; @@ -265,6 +314,7 @@ function getRandomAlphanum() { // 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) diff --git a/lib/staticryptJs.js b/lib/staticryptJs.js index 3e04532..6522ff0 100644 --- a/lib/staticryptJs.js +++ b/lib/staticryptJs.js @@ -4,9 +4,10 @@ const decode = codec.init(cryptoEngine).decode; /** * Initialize the staticrypt module, that exposes functions callbable by the password_template. - * * @param {{ - * staticryptEncryptedMsgUniqueVariableName: string, + * staticryptIvUniqueVariableName: string, + * staticryptEncryptedUniqueVariableName: string, + * staticryptHmacUniqueVariableName: string, * isRememberEnabled: boolean, * rememberDurationInDays: number, * staticryptSaltUniqueVariableName: string, @@ -25,22 +26,33 @@ function init(staticryptConfig, templateConfig) { /** * Decrypt our encrypted page, replace the whole HTML. * - * @param {string} hashedPassword + * @param {Uint8Array} hashedPassword * @returns {Promise} */ async function decryptAndReplaceHtml(hashedPassword) { - const { staticryptEncryptedMsgUniqueVariableName, staticryptSaltUniqueVariableName } = staticryptConfig; + const iv = cryptoEngine.HexEncoder.parse(staticryptConfig.staticryptIvUniqueVariableName); + const hmac = cryptoEngine.HexEncoder.parse(staticryptConfig.staticryptHmacUniqueVariableName); + const salt = cryptoEngine.HexEncoder.parse(staticryptConfig.staticryptSaltUniqueVariableName); + const { replaceHtmlCallback } = templateConfig; - const result = await decode( - staticryptEncryptedMsgUniqueVariableName, + // Encrypted may be very large, we avoid creating a named variable + // for the Uint8Array so it will immediately be eligible for garbage + // collection. + let result = await decode( + iv, + cryptoEngine.HexEncoder.parse(staticryptConfig.staticryptEncryptedUniqueVariableName), + hmac, hashedPassword, - staticryptSaltUniqueVariableName + salt ); if (!result.success) { return false; } - const plainHTML = result.decoded; + const plainHTML = cryptoEngine.UTF8Encoder.stringify(result.decoded); + + // no further use for result and result.decoded can be vary large + result = null; // if the user configured a callback call it, otherwise just replace the whole HTML if (typeof replaceHtmlCallback === "function") { @@ -59,14 +71,15 @@ function init(staticryptConfig, templateConfig) { * @param {string} password * @param {boolean} isRememberChecked * - * @returns {Promise<{isSuccessful: boolean, hashedPassword?: string}>} - we return an object, so that if we want to + * @returns {Promise<{isSuccessful: boolean, hashedPassword?: Uint8Array}>} - we return an object, so that if we want to * expose more information in the future we can do it without breaking the password_template */ - async function handleDecryptionOfPage(password, isRememberChecked) { - const { staticryptSaltUniqueVariableName } = staticryptConfig; + async function handleDecryptionOfPage(passwordString, isRememberChecked) { + const password = cryptoEngine.UTF8Encoder.parse(passwordString); + const salt = cryptoEngine.HexEncoder.parse(staticryptConfig.staticryptSaltUniqueVariableName); // decrypt and replace the whole page - const hashedPassword = await cryptoEngine.hashPassword(password, staticryptSaltUniqueVariableName); + const hashedPassword = await cryptoEngine.hashPassword(password, salt); return handleDecryptionOfPageFromHash(hashedPassword, isRememberChecked); } exports.handleDecryptionOfPage = handleDecryptionOfPage; @@ -86,7 +99,8 @@ function init(staticryptConfig, templateConfig) { // remember the hashedPassword and set its expiration if necessary if (isRememberEnabled && isRememberChecked) { - window.localStorage.setItem(rememberPassphraseKey, hashedPassword); + const hashedPasswordString = cryptoEngine.HexEncoder.stringify(hashedPassword); + window.localStorage.setItem(rememberPassphraseKey, hashedPasswordString); // set the expiration if the duration isn't 0 (meaning no expiration) if (rememberDurationInDays > 0) { @@ -164,7 +178,7 @@ function init(staticryptConfig, templateConfig) { const { rememberDurationInDays } = staticryptConfig; const { rememberExpirationKey, rememberPassphraseKey } = templateConfig; - // if we are login out, terminate + // if we are loging out, terminate if (logoutIfNeeded()) { return false; } @@ -180,9 +194,11 @@ function init(staticryptConfig, templateConfig) { } } - const hashedPassword = localStorage.getItem(rememberPassphraseKey); + const hashedPasswordString = localStorage.getItem(rememberPassphraseKey); + + if (hashedPasswordString) { + const hashedPassword = cryptoEngine.HexEncoder.parse(hashedPasswordString); - if (hashedPassword) { // try to decrypt const isDecryptionSuccessful = await decryptAndReplaceHtml(hashedPassword); @@ -215,7 +231,8 @@ function init(staticryptConfig, templateConfig) { const hashedPasswordFragment = hashedPasswordRegexMatch ? hashedPasswordRegexMatch[1] : null; const rememberMeFragment = urlFragment.includes(rememberMeKey); - const hashedPassword = hashedPasswordFragment || hashedPasswordQuery; + const hashedPasswordString = hashedPasswordFragment || hashedPasswordQuery; + const hashedPassword = cryptoEngine.HexEncoder.parse(hashedPasswordString); const rememberMe = rememberMeFragment || rememberMeQuery; if (hashedPassword) { diff --git a/scripts/index_template.html b/scripts/index_template.html index 1d88748..6f2cf00 100644 --- a/scripts/index_template.html +++ b/scripts/index_template.html @@ -381,10 +381,14 @@ trackEvent("generate_encrypted"); - const unencrypted = document.getElementById("unencrypted_html").value, - password = document.getElementById("password").value; + const unencrypted = cryptoEngine.UTF8Encoder.parse(document.getElementById("unencrypted_html").value); + + const passwordString = document.getElementById("password").value; + const password = cryptoEngine.HexEncoder.parse(passwordString); + + const saltString = cryptoEngine.generateRandomSaltString(); + const salt = cryptoEngine.HexEncoder.parse(saltString); - const salt = cryptoEngine.generateRandomSalt(); const encryptedMsg = await encode(unencrypted, password, salt); const templateButton = document.getElementById("template_button").value, @@ -399,7 +403,11 @@ const data = { staticrypt_config: { - staticryptEncryptedMsgUniqueVariableName: encryptedMsg, + staticryptIvUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.iv), + staticryptEncryptedUniqueVariableName: cryptoEngine.HexEncoder.stringify( + encryptedMsg.encrypted + ), + staticryptHmacUniqueVariableName: cryptoEngine.HexEncoder.stringify(encryptedMsg.hmac), isRememberEnabled, rememberDurationInDays, staticryptSaltUniqueVariableName: salt, @@ -415,7 +423,11 @@ template_title: templateTitle || "Protected Page", }; - document.getElementById("encrypted_html_display").textContent = encryptedMsg; + // TODO: Displaying the old encryptedMsg hex string is problematic for large + // TODO: pages. Maybe implement a way to allow user to request that (button?) + // TODO: maybe we need to create a new format to support copy/past, + // TODO: (possibly JSON)? + document.getElementById("encrypted_html_display").textContent = "HTML is encrypted"; setFileToDownload(data); });