From 92691a1994b16f1e83dfbfb1f6b1e31ead3806d3 Mon Sep 17 00:00:00 2001 From: robinmoisson Date: Wed, 29 Mar 2023 16:12:44 +0200 Subject: [PATCH 01/11] make WebCrypto the only engine available (closes #168) --- cli/helpers.js | 47 +----- cli/index.js | 124 ++++------------ example/example_encrypted.html | 4 +- index.html | 11 +- lib/codec-sync.js | 95 ------------ .../webcryptoEngine.js => cryptoEngine.js} | 2 - lib/cryptoEngine/cryptojsEngine.js | 138 ------------------ lib/kryptojs-3.1.9-1.min.js | 7 - package.json | 2 +- scripts/buildIndex.js | 2 +- scripts/index_template.html | 9 +- 11 files changed, 34 insertions(+), 407 deletions(-) delete mode 100644 lib/codec-sync.js rename lib/{cryptoEngine/webcryptoEngine.js => cryptoEngine.js} (99%) delete mode 100644 lib/cryptoEngine/cryptojsEngine.js delete mode 100644 lib/kryptojs-3.1.9-1.min.js diff --git a/cli/helpers.js b/cli/helpers.js index 89b1662..2b10a41 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -1,6 +1,6 @@ const fs = require("fs"); -const { generateRandomSalt } = require("../lib/cryptoEngine/webcryptoEngine.js"); +const { generateRandomSalt } = require("../lib/cryptoEngine.js"); const path = require("path"); const {renderTemplate} = require("../lib/formater.js"); const Yargs = require("yargs"); @@ -168,39 +168,6 @@ function genFile(data, outputFilePath, templateFilePath) { } exports.genFile = genFile; -/** - * TODO: remove in next major version - * - * This method checks whether the password template support the security fix increasing PBKDF2 iterations. Users using - * an old custom password_template might have logic that doesn't benefit from the fix. - * - * @param {string} templatePathParameter - * @returns {boolean} - */ -function isCustomPasswordTemplateLegacy(templatePathParameter) { - const customTemplateContent = readFile(templatePathParameter, "template"); - - // if the template injects the crypto engine, it's up to date - return !customTemplateContent.includes("js_crypto_engine"); -} -exports.isCustomPasswordTemplateLegacy = isCustomPasswordTemplateLegacy; - -/** - * TODO: remove in next major version - * - * This method checks whether the password template support the async logic. - * - * @param {string} templatePathParameter - * @returns {boolean} - */ -function isPasswordTemplateUsingAsync(templatePathParameter) { - const customTemplateContent = readFile(templatePathParameter, "template"); - - // if the template includes this comment, it's up to date - return customTemplateContent.includes("// STATICRYPT_VERSION: async"); -} -exports.isPasswordTemplateUsingAsync = isPasswordTemplateUsingAsync; - /** * @param {string} templatePathParameter * @returns {boolean} @@ -224,18 +191,6 @@ function parseCommandLineArguments() { describe: 'Label to use for the decrypt button. Default: "DECRYPT".', default: "DECRYPT", }) - .option("e", { - alias: "embed", - type: "boolean", - describe: "Whether or not to embed crypto-js in the page (or use an external CDN).", - default: true, - }) - .option("engine", { - type: "string", - describe: "The crypto engine to use. WebCrypto uses 600k iterations and is more secure, CryptoJS 15k.\n" + - "Possible values: 'cryptojs', 'webcrypto'.", - default: "cryptojs", - }) .option("f", { alias: "file-template", type: "string", diff --git a/cli/index.js b/cli/index.js index 5f35bd0..78244f8 100755 --- a/cli/index.js +++ b/cli/index.js @@ -3,31 +3,21 @@ "use strict"; const fs = require("fs"); -const path = require("path"); // parse .env file into process.env require('dotenv').config(); -const cryptojsEngine = require("../lib/cryptoEngine/cryptojsEngine"); -const webcryptoEngine = require("../lib/cryptoEngine/webcryptoEngine"); +const cryptoEngine = require("../lib/cryptoEngine.js"); const codec = require("../lib/codec.js"); +const { generateRandomSalt, generateRandomString } = cryptoEngine; +const { encode } = codec.init(cryptoEngine); const { convertCommonJSToBrowserJS, exitWithError, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers"); -const { isCustomPasswordTemplateLegacy, parseCommandLineArguments, isPasswordTemplateUsingAsync} = require("./helpers.js"); - -const CRYPTOJS_SCRIPT_TAG = - ''; +const { parseCommandLineArguments} = require("./helpers.js"); // parse arguments const yargs = parseCommandLineArguments(); const namedArgs = yargs.argv; -// set the crypto engine -const isWebcrypto = namedArgs.engine === "webcrypto"; -const cryptoEngine = isWebcrypto ? webcryptoEngine : cryptojsEngine; -const { generateRandomSalt, generateRandomString } = cryptoEngine; -const { encode } = codec.init(cryptoEngine); - async function runStatiCrypt() { // if the 's' flag is passed without parameter, generate a salt, display & exit if (isOptionSetByUser("s", yargs) && !namedArgs.salt) { @@ -91,94 +81,32 @@ async function runStatiCrypt() { console.log(url + "#staticrypt_pwd=" + hashedPassword); } - // TODO: remove in the next major version bump. This is to allow a security update to some versions without breaking - // older ones. If the password template is custom AND created before 2.2.0 we need to use the old hashing algorithm. - const isLegacy = isCustomPasswordTemplateLegacy(namedArgs.f); - - if (isLegacy) { - console.log( - "#################################\n\n" + - "SECURITY WARNING [StatiCrypt]: You are using an old version of the password template, which has been found to " + - "be less secure. Please update your custom password_template logic to match the latest version." + - "\nYou can find instructions here: https://github.com/robinmoisson/staticrypt/issues/161" + - "\n\n#################################" - ); - } - - if (!isWebcrypto) { - console.log( - "WARNING: If you are viewing the file over HTTPS or locally, we recommend " + - (isPasswordTemplateUsingAsync(namedArgs.f) ? "" : "updating your password template to the latest version and ") + - "using the '--engine webcrypto' more secure engine. It will become the default in StatiCrypt next major version." - ); - } else if (!isPasswordTemplateUsingAsync(namedArgs.f) && isWebcrypto) { - exitWithError( - "The '--engine webcrypto' engine is only available for password templates that use async/await. Please " + - "update your password template to the latest version or use the '--engine cryptojs' engine." - ) - } - - // create crypto-js tag (embedded or not) - let cryptoTag = CRYPTOJS_SCRIPT_TAG; - if (isWebcrypto) { - cryptoTag = ""; - } else if (namedArgs.embed) { - try { - const embedContents = fs.readFileSync( - path.join(__dirname, "..", "lib", "kryptojs-3.1.9-1.min.js"), - "utf8" - ); - - cryptoTag = ""; - } catch (e) { - exitWithError("Embed file does not exist."); - } - } - - const cryptoEngineString = isWebcrypto - ? convertCommonJSToBrowserJS("lib/cryptoEngine/webcryptoEngine") - : convertCommonJSToBrowserJS("lib/cryptoEngine/cryptojsEngine"); - // get the file content const contents = getFileContent(inputFilepath); // encrypt input - encode(contents, password, salt, isLegacy).then((encryptedMessage) => { - let codecString; - if (isWebcrypto) { - codecString = convertCommonJSToBrowserJS("lib/codec"); - } else { - // TODO: remove on next major version bump. The replace is a hack to pass the salt to the injected js_codec in - // a backward compatible way (not requiring to update the password_template). Same for using a "sync" version - // of the codec. - codecString = convertCommonJSToBrowserJS("lib/codec-sync").replace('##SALT##', salt); - } - - const data = { - crypto_tag: cryptoTag, - decrypt_button: namedArgs.decryptButton, - // TODO: deprecated option here for backward compat, remove on next major version bump - embed: isWebcrypto ? false : namedArgs.embed, - encrypted: encryptedMessage, - instructions: namedArgs.instructions, - is_remember_enabled: namedArgs.noremember ? "false" : "true", - js_codec: codecString, - js_crypto_engine: cryptoEngineString, - label_error: namedArgs.labelError, - passphrase_placeholder: namedArgs.passphrasePlaceholder, - remember_duration_in_days: namedArgs.remember, - remember_me: namedArgs.rememberLabel, - salt: salt, - title: namedArgs.title, - }; - - const outputFilepath = namedArgs.output !== null - ? namedArgs.output - : inputFilepath.replace(/\.html$/, "") + "_encrypted.html"; - - genFile(data, outputFilepath, namedArgs.f); - - }); + const encryptedMessage = await encode(contents, password, salt); + + const data = { + decrypt_button: namedArgs.decryptButton, + encrypted: encryptedMessage, + instructions: namedArgs.instructions, + is_remember_enabled: namedArgs.noremember ? "false" : "true", + js_codec: convertCommonJSToBrowserJS("lib/codec"), + js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"), + label_error: namedArgs.labelError, + passphrase_placeholder: namedArgs.passphrasePlaceholder, + remember_duration_in_days: namedArgs.remember, + remember_me: namedArgs.rememberLabel, + salt: salt, + title: namedArgs.title, + }; + + const outputFilepath = namedArgs.output !== null + ? namedArgs.output + : inputFilepath.replace(/\.html$/, "") + "_encrypted.html"; + + genFile(data, outputFilepath, namedArgs.f); } runStatiCrypt(); diff --git a/example/example_encrypted.html b/example/example_encrypted.html index 6033bfb..e500d97 100644 --- a/example/example_encrypted.html +++ b/example/example_encrypted.html @@ -201,8 +201,6 @@ /** * Translates between utf8 encoded hexadecimal strings * and Uint8Array bytes. - * - * Mirrors the API of CryptoJS.enc.Hex */ const HexEncoder = { /** @@ -564,7 +562,7 @@ const decode = codec.init(cryptoEngine).decode; // variables to be filled when generating the file - const encryptedMsg = 'df7428ed075decd3c4ff9d8ab6d2bea4410854c863d705789fb22e14b7da7ee20e94c593047abb9ba34d6519eebc879d2097bb918c0af0d4e248959849fb9c6bbb93aba054806c8773d1e4b63ec317185ad5462a9919dda986716c67bb57a89a044de3e25707cded482657c4a0208e9916aaa9d839f090eaaeb95603e05db11fe4bc37c4d98b9170124ce1c7ca18fe39c2f179e23eee61ba7d79cb3145e8833936c62adeffce1f5e129745c89541faa8100bfde4733bfa9c0ecf04768b3d1889', + const encryptedMsg = '2c0a13159934226fa022225a06c64af6dfffe80dd6cb908f0f6ad93311563d78150cfc5465e3c7d70d682194a6d4a0c6082e37aaca8dbd83036d9e9cf629a112132b8fb6004a028b31ea4fb5a9f82d505096fe59e109970261733b4c8f21110b6f365d8b087d0ec15866917341e3cd105c65c9c7542626bae08903cd10675ed7fbd71062a1e87e35d30341c8251ab452352eaeecd6e44ed0a256979b30287032bccefaecf1685e948bf0fc3a3b5ad1f7b70092d9e32ceb60818ee49e821f8933', salt = 'b93bbaf35459951c47721d1f3eaeb5b9', labelError = 'Bad password!', isRememberEnabled = true, diff --git a/index.html b/index.html index 6fedba7..23104b5 100644 --- a/index.html +++ b/index.html @@ -184,11 +184,6 @@

Encrypted HTML

- - - - diff --git a/index.html b/index.html index 23104b5..851343d 100644 --- a/index.html +++ b/index.html @@ -46,7 +46,7 @@

Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see example). + target="_blank" href="example/encrypted/example.html">example).

The tool is also available as a CLI on NPM and is

@@ -565,7 +565,7 @@

Encrypted HTML

window.formater = ((function(){ const exports = {}; /** - * Replace the placeholder tags (between '{tag}') in the template string with provided data. + * Replace the placeholder tags (between '/*[|tag|]* /0') in the template string with provided data. * * @param {string} templateString * @param {Object} data @@ -573,12 +573,16 @@

Encrypted HTML

* @returns string */ function renderTemplate(templateString, data) { - return templateString.replace(/{\s*(\w+)\s*}/g, function (_, key) { - if (data && data[key] !== undefined) { - return data[key]; + return templateString.replace(/\/\*\[\|\s*(\w+)\s*\|]\*\/0/g, function (_, key) { + if (!data || data[key] === undefined) { + return key; } - return ""; + if (typeof data[key] === 'object') { + return JSON.stringify(data[key]); + } + + return data[key]; }); } exports.renderTemplate = renderTemplate; @@ -588,19 +592,608 @@

Encrypted HTML

})()) + + diff --git a/lib/staticryptJs.js b/lib/staticryptJs.js new file mode 100644 index 0000000..55d5d4c --- /dev/null +++ b/lib/staticryptJs.js @@ -0,0 +1,215 @@ +const cryptoEngine = /*[|js_crypto_engine|]*/0 +const codec = /*[|js_codec|]*/0 +const decode = codec.init(cryptoEngine).decode; + + +/** + * Initialize the staticrypt module, that exposes functions callbable by the password_template. + * + * @param {{ + * encryptedMsg: string, + * isRememberEnabled: boolean, + * rememberDurationInDays: number, + * salt: string, + * }} staticryptConfig - object of data that is stored on the password_template at encryption time. + * + * @param {{ + * rememberExpirationKey: string, + * rememberPassphraseKey: string, + * replaceHtmlCallback: function, + * clearLocalStorageCallback: function, + * }} templateConfig - object of data that can be configured by a custom password_template. + */ +function init(staticryptConfig, templateConfig) { + const exports = {}; + + /** + * Decrypt our encrypted page, replace the whole HTML. + * + * @param {string} hashedPassphrase + * @returns {Promise} + */ + async function decryptAndReplaceHtml(hashedPassphrase) { + const { encryptedMsg, salt } = staticryptConfig; + const { replaceHtmlCallback } = templateConfig; + + const result = await decode(encryptedMsg, hashedPassphrase, salt); + if (!result.success) { + return false; + } + const plainHTML = result.decoded; + + // if the user configured a callback call it, otherwise just replace the whole HTML + if (typeof replaceHtmlCallback === 'function') { + replaceHtmlCallback(plainHTML); + } else { + document.write(plainHTML); + document.close(); + } + + return true; + } + + /** + * Attempt to decrypt the page and replace the whole HTML. + * + * @param {string} password + * @param {boolean} isRememberChecked + * + * @returns {Promise<{isSuccessful: boolean, hashedPassword?: string}>} - 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 { isRememberEnabled, rememberDurationInDays, salt } = staticryptConfig; + const { rememberExpirationKey, rememberPassphraseKey } = templateConfig; + + // decrypt and replace the whole page + const hashedPassword = await cryptoEngine.hashPassphrase(password, salt); + + const isDecryptionSuccessful = await decryptAndReplaceHtml(hashedPassword); + + if (!isDecryptionSuccessful) { + return { + isSuccessful: false, + hashedPassword, + }; + } + + // remember the hashedPassword and set its expiration if necessary + if (isRememberEnabled && isRememberChecked) { + window.localStorage.setItem(rememberPassphraseKey, hashedPassword); + + // set the expiration if the duration isn't 0 (meaning no expiration) + if (rememberDurationInDays > 0) { + window.localStorage.setItem( + rememberExpirationKey, + (new Date().getTime() + rememberDurationInDays * 24 * 60 * 60 * 1000).toString() + ); + } + } + + return { + isSuccessful: true, + hashedPassword, + }; + } + exports.handleDecryptionOfPage = handleDecryptionOfPage; + + /** + * Clear localstorage from staticrypt related values + */ + function clearLocalStorage() { + const { clearLocalStorageCallback, rememberExpirationKey, rememberPassphraseKey } = templateConfig; + + if (typeof clearLocalStorageCallback === 'function') { + clearLocalStorageCallback(); + } else { + localStorage.removeItem(rememberPassphraseKey); + localStorage.removeItem(rememberExpirationKey); + } + } + + async function handleDecryptOnLoad() { + let isSuccessful = await decryptOnLoadFromUrl(); + + if (!isSuccessful) { + isSuccessful = await decryptOnLoadFromRememberMe(); + } + + return { isSuccessful }; + } + exports.handleDecryptOnLoad = handleDecryptOnLoad; + + /** + * Clear storage if we are logging out + * + * @returns {boolean} - whether we logged out + */ + function logoutIfNeeded() { + const logoutKey = "staticrypt_logout"; + + // handle logout through query param + const queryParams = new URLSearchParams(window.location.search); + if (queryParams.has(logoutKey)) { + clearLocalStorage(); + return true; + } + + // handle logout through URL fragment + const hash = window.location.hash.substring(1); + if (hash.includes(logoutKey)) { + clearLocalStorage(); + return true; + } + + return false; + } + + /** + * To be called on load: check if we want to try to decrypt and replace the HTML with the decrypted content, and + * try to do it if needed. + * + * @returns {Promise} true if we derypted and replaced the whole page, false otherwise + */ + async function decryptOnLoadFromRememberMe() { + const { rememberDurationInDays } = staticryptConfig; + const { rememberExpirationKey, rememberPassphraseKey } = templateConfig; + + // if we are login out, terminate + if (logoutIfNeeded()) { + return false; + } + + // if there is expiration configured, check if we're not beyond the expiration + if (rememberDurationInDays && rememberDurationInDays > 0) { + const expiration = localStorage.getItem(rememberExpirationKey), + isExpired = expiration && new Date().getTime() > parseInt(expiration); + + if (isExpired) { + clearLocalStorage(); + return false; + } + } + + const hashedPassphrase = localStorage.getItem(rememberPassphraseKey); + + if (hashedPassphrase) { + // try to decrypt + const isDecryptionSuccessful = await decryptAndReplaceHtml(hashedPassphrase); + + // if the decryption is unsuccessful the password might be wrong - silently clear the saved data and let + // the user fill the password form again + if (!isDecryptionSuccessful) { + clearLocalStorage(); + return false; + } + + return true; + } + + return false; + } + + function decryptOnLoadFromUrl() { + const passwordKey = "staticrypt_pwd"; + + // get the password from the query param + const queryParams = new URLSearchParams(window.location.search); + const hashedPassphraseQuery = queryParams.get(passwordKey); + + // get the password from the url fragment + const hashRegexMatch = window.location.hash.substring(1).match(new RegExp(passwordKey + "=(.*)")); + const hashedPassphraseFragment = hashRegexMatch ? hashRegexMatch[1] : null; + + const hashedPassphrase = hashedPassphraseFragment || hashedPassphraseQuery; + + if (hashedPassphrase) { + return decryptAndReplaceHtml(hashedPassphrase); + } + + return false; + } + + return exports; +} +exports.init = init; \ No newline at end of file diff --git a/scripts/build.sh b/scripts/build.sh index 1b27e27..16c7f33 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,12 +1,15 @@ # Build the website files # Should be run with "npm run build" - npm handles the pathing better (so no "#!/usr/bin/env" bash on top) +# build the index.html file +node ./scripts/buildIndex.js + # encrypt the example file -node cli/index.js example/example.html test \ - --engine webcrypto \ +cd example +node ../cli/index.js example.html \ + -p test \ --short \ --salt b93bbaf35459951c47721d1f3eaeb5b9 \ - --instructions "Enter \"test\" to unlock the page" + --config false \ + --template-instructions "Enter \"test\" to unlock the page" -# build the index.html file -node ./scripts/buildIndex.js diff --git a/scripts/buildIndex.js b/scripts/buildIndex.js index 02dd000..3309f02 100644 --- a/scripts/buildIndex.js +++ b/scripts/buildIndex.js @@ -1,9 +1,10 @@ -const { convertCommonJSToBrowserJS, genFile } = require("../cli/helpers.js"); +const { convertCommonJSToBrowserJS, genFile, buildStaticryptJS} = require("../cli/helpers.js"); const data = { js_codec: convertCommonJSToBrowserJS("lib/codec"), js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"), js_formater: convertCommonJSToBrowserJS("lib/formater"), + js_staticrypt: buildStaticryptJS(), }; genFile(data, "./index.html", "./scripts/index_template.html"); diff --git a/scripts/index_template.html b/scripts/index_template.html index 63fc232..3af59e0 100644 --- a/scripts/index_template.html +++ b/scripts/index_template.html @@ -46,7 +46,7 @@

Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see example). + target="_blank" href="example/encrypted/example.html">example).

The tool is also available as a CLI on NPM and is

@@ -187,22 +187,26 @@

Encrypted HTML

+ +