From 57ea09076820a56842752e31b27bd704fee21608 Mon Sep 17 00:00:00 2001 From: Robin Moisson Date: Wed, 1 Mar 2023 18:46:48 +0100 Subject: [PATCH] raise PBKDF2 iterations in backward compatible way (#160) This addresses the concerns and plan detailed in #159 --- README.md | 50 ++++----- cli/helpers.js | 155 ++++++++++++++++++++++++++-- cli/index.js | 131 +++++++----------------- example/example_encrypted.html | 158 ++++++++++++++++++++++------- index.html | 150 +++++++++++++++++++++++---- lib/codec.js | 53 ++++++++-- lib/cryptoEngine/cryptojsEngine.js | 78 +++++++++++++- lib/password_template.html | 25 ----- package-lock.json | 4 +- package.json | 2 +- scripts/index_template.html | 19 ++-- 11 files changed, 589 insertions(+), 236 deletions(-) diff --git a/README.md b/README.md index 8a2d762..6b1b2f4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # StatiCrypt -StatiCrypt uses AES-256 to encrypt your HTML file with your passphrase and return a static page including a password prompt and the javascript decryption logic that you can safely upload anywhere (see [what the page looks like](https://robinmoisson.github.io/staticrypt/example/example_encrypted.html)). +StatiCrypt uses AES-256 to encrypt your HTML file with your long password and return a static page including a password prompt and the javascript decryption logic that you can safely upload anywhere (see [what the page looks like](https://robinmoisson.github.io/staticrypt/example/example_encrypted.html)). This means you can **password protect the content of your _public_ static HTML file, without any back-end** - serving it over Netlify, GitHub pages, etc. (see the detail of [how it works](#how-staticrypt-works)). @@ -27,13 +27,13 @@ You can then run it with `npx staticrypt ...`. You can also install globally wit **Encrypt a file:** Encrypt `test.html` and create a `test_encrypted.html` file (add `-o my_encrypted_file.html` to change the name of the output file): ```bash -staticrypt test.html MY_PASSPHRASE +staticrypt test.html MY_LONG_PASSWORD ``` -**Encrypt a file with the passphrase in an environment variable:** set your passphrase in the `STATICRYPT_PASSWORD` environment variable ([`.env` files](https://www.npmjs.com/package/dotenv#usage) are supported): +**Encrypt a file with the password in an environment variable:** set your long password in the `STATICRYPT_PASSWORD` environment variable ([`.env` files](https://www.npmjs.com/package/dotenv#usage) are supported): ```bash -# the passphrase is in the STATICRYPT_PASSWORD env variable +# the password is in the STATICRYPT_PASSWORD env variable staticrypt test.html ``` @@ -41,27 +41,27 @@ staticrypt test.html ```bash # you can also pass '--share' without specifying the URL to get the `?staticrypt_pwd=...` -staticrypt test.html MY_PASSPHRASE --share https://example.com/test_encrypted.html +staticrypt test.html MY_LONG_PASSWORD --share https://example.com/test_encrypted.html # => https://example.com/test_encrypted.html?staticrypt_pwd=5bfbf1343c7257cd7be23ecd74bb37fa2c76d041042654f358b6255baeab898f ``` **Encrypt all html files in a directory** and replace them with encrypted versions (`{}` will be replaced with each file name by the `find` command - if you wanted to move the encrypted files to an `encrypted/` directory, you could use `-o encrypted/{}`): ```bash -find . -type f -name "*.html" -exec staticrypt {} MY_PASSPHRASE -o {} \; +find . -type f -name "*.html" -exec staticrypt {} MY_LONG_PASSWORD -o {} \; ``` **Encrypt all html files in a directory except** the ones ending in `_encrypted.html`: ```bash -find . -type f -name "*.html" -not -name "*_encrypted.html" -exec staticrypt {} MY_PASSPHRASE \; +find . -type f -name "*.html" -not -name "*_encrypted.html" -exec staticrypt {} MY_LONG_PASSWORD \; ``` ### CLI Reference -The passphrase argument is optional if `STATICRYPT_PASSWORD` is set in the environment or `.env` file. +The password argument is optional if `STATICRYPT_PASSWORD` is set in the environment or `.env` file. - Usage: staticrypt [] [options] + Usage: staticrypt [] [options] Options: --help Show help [boolean] @@ -73,24 +73,23 @@ The passphrase argument is optional if `STATICRYPT_PASSWORD` is set in the envir -e, --embed Whether or not to embed crypto-js in the page (or use an external CDN). [boolean] [default: true] - -f, --file-template Path to custom HTML template with passphrase + -f, --file-template Path to custom HTML template with password prompt. - [string] [default: "/lib/password_template.html"] + [string] [default: "/code/staticrypt/lib/password_template.html"] -i, --instructions Special instructions to display to the user. [string] [default: ""] --label-error Error message to display on entering wrong - passphrase. [string] [default: "Bad password!"] + password. [string] [default: "Bad password!"] --noremember Set this flag to remove the "Remember me" checkbox. [boolean] [default: false] -o, --output File name/path for the generated encrypted file. [string] [default: null] - --passphrase-placeholder Placeholder to use for the passphrase input. + --passphrase-placeholder Placeholder to use for the password input. [string] [default: "Password"] -r, --remember Expiration in days of the "Remember me" checkbox - that will save the (salted + hashed) passphrase - in localStorage when entered by the user. - Default: "0", no expiration. - [number] [default: 0] + that will save the (salted + hashed) password in + localStorage when entered by the user. Default: + "0", no expiration. [number] [default: 0] --remember-label Label to use for the "Remember me" checkbox. [string] [default: "Remember me"] -s, --salt Set the salt manually. It should be set if you @@ -104,7 +103,10 @@ The passphrase argument is optional if `STATICRYPT_PASSWORD` is set in the envir value to append "?staticrypt_pwd=", or leave empty to display the hash to append. [string] + --short Hide the "short password" warning. + [boolean] [default: false] -t, --title Title for the output HTML page. + [string] [default: "Protected Page"] ## HOW STATICRYPT WORKS @@ -119,9 +121,11 @@ So it basically encrypts your page and puts everything in a user-friendly way to ### Is it secure? -Simple answer: your file content has been encrypted with AES-256 (CBC), a popular and strong encryption algorithm, you can now upload it in any public place and no one will be able to read it without the password. So yes, if you used a good password it should be pretty secure. +Simple answer: your file content has been encrypted with AES-256 (CBC), a popular and strong encryption algorithm, you can now upload it in any public place and no one will be able to read it without the password. So if you used a long, strong password, then yes it should be pretty secure. -That being said, actual security always depends on a number of factors and on the threat model you want to protect against. Because your full encrypted file is accessible client side, brute-force/dictionary attacks would be trivial to do at a really fast pace: **use a long, unusual password**. You can read a discussion on CBC mode and how appropriate it is in the context of StatiCrypt in [#19](https://github.com/robinmoisson/staticrypt/issues/19). +That being said, actual security always depends on a number of factors and on the threat model you want to protect against. Because your full encrypted file is accessible client side, brute-force/dictionary attacks would be easy to do at a really fast pace: **use a long, unusual password**. We recommend 16+ alphanum characters, [Bitwarden](https://bitwarden.com/) is a great open-source password manager if you don't have one already. + +On the technical aspects: we use AES in CBC mode (see a discussion on why it's appropriate for StatiCrypt in [#19](https://github.com/robinmoisson/staticrypt/issues/19)) and 15k PBKDF2 iterations (it will be 600k when we'll switch to WebCrypto, read a detailed report on why these numbers in [#159](https://github.com/robinmoisson/staticrypt/issues/159)). **Also, disclaimer:** I am not a cryptographer - the concept is simple and I try my best to implement it correctly but please adjust accordingly: if you are an at-risk activist or have sensitive crypto data to protect, you might want to use something else. @@ -149,9 +153,9 @@ The salt isn't secret, so you don't need to worry about hiding the config file. ### How does the "Remember me" checkbox work? -The CLI will add a "Remember me" checkbox on the password prompt by default (`--noremember` to disable). If the user checks it, the (salted + hashed) passphrase will be stored in their browser's localStorage and the page will attempt to auto-decrypt when they come back. +The CLI will add a "Remember me" checkbox on the password prompt by default (`--noremember` to disable). If the user checks it, the (salted + hashed) password will be stored in their browser's localStorage and the page will attempt to auto-decrypt when they come back. -If no value is provided the stored passphrase doesn't expire, you can also give it a value in days for how long should the store value be kept with `-r NUMBER_OF_DAYS`. If the user reconnects to the page after the expiration date the stored value will be cleared. +If no value is provided the stored password doesn't expire, you can also give it a value in days for how long should the store value be kept with `-r NUMBER_OF_DAYS`. If the user reconnects to the page after the expiration date the stored value will be cleared. #### "Logging out" @@ -163,14 +167,14 @@ This allows encrypting multiple page on a single domain with the same password: #### Is the "Remember me" checkbox secure? -In case the value stored in the browser becomes compromised an attacker can decrypt the page, but because it's stored salted and hashed this should still protect against password reuse attacks if you've used the passphrase on other websites (of course, please use a unique passphrase nonetheless). +In case the value stored in the browser becomes compromised an attacker can decrypt the page, but because it's stored salted and hashed this should still protect against password reuse attacks if you've used the password on other websites (of course, please use a long, unique password nonetheless). ## Contributing ### 🙏 Thank you! - [@AaronCoplan](https://github.com/AaronCoplan) for bringing the CLI to life -- [@epicfaace](https://github.com/epicfaace) & [@thomasmarr](https://github.com/thomasmarr) for sparking the caching of the passphrase in localStorage (allowing the "Remember me" checkbox) +- [@epicfaace](https://github.com/epicfaace) & [@thomasmarr](https://github.com/thomasmarr) for sparking the caching of the password in localStorage (allowing the "Remember me" checkbox) - [@hurrymaplelad](https://github.com/hurrymaplelad) for refactoring a lot of the code and making the project much more pleasant to work with ### Opening PRs and issues diff --git a/cli/helpers.js b/cli/helpers.js index fa795ae..76e32aa 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -3,8 +3,12 @@ const fs = require("fs"); const cryptoEngine = require("../lib/cryptoEngine/cryptojsEngine"); const path = require("path"); const {renderTemplate} = require("../lib/formater.js"); +const Yargs = require("yargs"); const { generateRandomSalt } = cryptoEngine; +const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "password_template.html"); + + /** * @param {string} message */ @@ -132,21 +136,28 @@ function convertCommonJSToBrowserJS(modulePath) { } exports.convertCommonJSToBrowserJS = convertCommonJSToBrowserJS; +/** + * @param {string} filePath + * @param {string} errorName + * @returns {string} + */ +function readFile(filePath, errorName = file) { + try { + return fs.readFileSync(filePath, "utf8"); + } catch (e) { + exitEarly(`Failure: could not read ${errorName}!`); + } +} + /** * Fill the template with provided data and writes it to output file. * * @param {Object} data * @param {string} outputFilePath - * @param {string} inputFilePath + * @param {string} templateFilePath */ -function genFile(data, outputFilePath, inputFilePath) { - let templateContents; - - try { - templateContents = fs.readFileSync(inputFilePath, "utf8"); - } catch (e) { - exitEarly("Failure: could not read template!"); - } +function genFile(data, outputFilePath, templateFilePath) { + const templateContents = readFile(templateFilePath, "template"); const renderedTemplate = renderTemplate(templateContents, data); @@ -156,4 +167,128 @@ function genFile(data, outputFilePath, inputFilePath) { exitEarly("Failure: could not generate output file!"); } } -exports.genFile = genFile; \ No newline at end of file +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) { + // if the user uses the default template, it's up to date + if (templatePathParameter === PASSWORD_TEMPLATE_DEFAULT_PATH) { + return false; + } + + const customTemplateContent = readFile(templatePathParameter, "template"); + + // if the template injects the crypto engine, it's up to date + if (customTemplateContent.includes("js_crypto_engine")) { + return false; + } + + return true; +} +exports.isCustomPasswordTemplateLegacy = isCustomPasswordTemplateLegacy; + +function parseCommandLineArguments() { + return Yargs.usage("Usage: staticrypt [] [options]") + .option("c", { + alias: "config", + type: "string", + describe: 'Path to the config file. Set to "false" to disable.', + default: ".staticrypt.json", + }) + .option("decrypt-button", { + type: "string", + 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("f", { + alias: "file-template", + type: "string", + describe: "Path to custom HTML template with password prompt.", + default: PASSWORD_TEMPLATE_DEFAULT_PATH, + }) + .option("i", { + alias: "instructions", + type: "string", + describe: "Special instructions to display to the user.", + default: "", + }) + .option("label-error", { + type: "string", + describe: "Error message to display on entering wrong password.", + default: "Bad password!", + }) + .option("noremember", { + type: "boolean", + describe: 'Set this flag to remove the "Remember me" checkbox.', + default: false, + }) + .option("o", { + alias: "output", + type: "string", + describe: "File name/path for the generated encrypted file.", + default: null, + }) + .option("passphrase-placeholder", { + type: "string", + describe: "Placeholder to use for the password input.", + default: "Password", + }) + .option("r", { + alias: "remember", + type: "number", + describe: + 'Expiration in days of the "Remember me" checkbox that will save the (salted + hashed) password ' + + 'in localStorage when entered by the user. Default: "0", no expiration.', + default: 0, + }) + .option("remember-label", { + type: "string", + describe: 'Label to use for the "Remember me" checkbox.', + default: "Remember me", + }) + // do not give a default option to this parameter - we want to see when the flag is included with no + // value and when it's not included at all + .option("s", { + alias: "salt", + describe: + 'Set the salt manually. It should be set if you want to use "Remember me" through multiple pages. It ' + + "needs to be a 32-character-long hexadecimal string.\nInclude the empty flag to generate a random salt you " + + 'can use: "statycrypt -s".', + type: "string", + }) + // do not give a default option to this parameter - we want to see when the flag is included with no + // value and when it's not included at all + .option("share", { + describe: + 'Get a link containing your hashed password that will auto-decrypt the page. Pass your URL as a value to append ' + + '"?staticrypt_pwd=", or leave empty to display the hash to append.', + type: "string", + }) + .option("short", { + describe: 'Hide the "short password" warning.', + type: "boolean", + default: false, + }) + .option("t", { + alias: "title", + type: "string", + describe: "Title for the output HTML page.", + default: "Protected Page", + }); +} +exports.parseCommandLineArguments = parseCommandLineArguments; diff --git a/cli/index.js b/cli/index.js index 9f2f81d..485c789 100755 --- a/cli/index.js +++ b/cli/index.js @@ -12,7 +12,8 @@ require('dotenv').config(); const cryptoEngine = require("../lib/cryptoEngine/cryptojsEngine"); const codec = require("../lib/codec"); const { convertCommonJSToBrowserJS, exitEarly, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers"); -const { generateRandomSalt } = cryptoEngine; +const { isCustomPasswordTemplateLegacy, parseCommandLineArguments} = require("./helpers.js"); +const { generateRandomSalt, generateRandomString } = cryptoEngine; const { encode } = codec.init(cryptoEngine); const SCRIPT_URL = @@ -22,95 +23,8 @@ const SCRIPT_TAG = SCRIPT_URL + '" integrity="sha384-lp4k1VRKPU9eBnPePjnJ9M2RF3i7PC30gXs70+elCVfgwLwx1tv5+ctxdtwxqZa7" crossorigin="anonymous">'; -const yargs = Yargs.usage("Usage: staticrypt [] [options]") - .option("c", { - alias: "config", - type: "string", - describe: 'Path to the config file. Set to "false" to disable.', - default: ".staticrypt.json", - }) - .option("decrypt-button", { - type: "string", - 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("f", { - alias: "file-template", - type: "string", - describe: "Path to custom HTML template with passphrase prompt.", - default: path.join(__dirname, "..", "lib", "password_template.html"), - }) - .option("i", { - alias: "instructions", - type: "string", - describe: "Special instructions to display to the user.", - default: "", - }) - .option("label-error", { - type: "string", - describe: "Error message to display on entering wrong passphrase.", - default: "Bad password!", - }) - .option("noremember", { - type: "boolean", - describe: 'Set this flag to remove the "Remember me" checkbox.', - default: false, - }) - .option("o", { - alias: "output", - type: "string", - describe: "File name/path for the generated encrypted file.", - default: null, - }) - .option("passphrase-placeholder", { - type: "string", - describe: "Placeholder to use for the passphrase input.", - default: "Password", - }) - .option("r", { - alias: "remember", - type: "number", - describe: - 'Expiration in days of the "Remember me" checkbox that will save the (salted + hashed) passphrase ' + - 'in localStorage when entered by the user. Default: "0", no expiration.', - default: 0, - }) - .option("remember-label", { - type: "string", - describe: 'Label to use for the "Remember me" checkbox.', - default: "Remember me", - }) - // do not give a default option to this parameter - we want to see when the flag is included with no - // value and when it's not included at all - .option("s", { - alias: "salt", - describe: - 'Set the salt manually. It should be set if you want to use "Remember me" through multiple pages. It ' + - "needs to be a 32-character-long hexadecimal string.\nInclude the empty flag to generate a random salt you " + - 'can use: "statycrypt -s".', - type: "string", - }) - // do not give a default option to this parameter - we want to see when the flag is included with no - // value and when it's not included at all - .option("share", { - describe: - 'Get a link containing your hashed password that will auto-decrypt the page. Pass your URL as a value to append ' - + '"?staticrypt_pwd=", or leave empty to display the hash to append.', - type: "string", - }) - .option("t", { - alias: "title", - type: "string", - describe: "Title for the output HTML page.", - default: "Protected Page", - }); +// parse arguments +const yargs = parseCommandLineArguments(); const namedArgs = yargs.argv; // if the 's' flag is passed without parameter, generate a salt, display & exit @@ -122,7 +36,7 @@ if (isOptionSetByUser("s", yargs) && !namedArgs.salt) { // validate the number of arguments const positionalArguments = namedArgs._; if (positionalArguments.length > 2 || positionalArguments.length === 0) { - Yargs.showHelp(); + yargs.showHelp(); process.exit(1); } @@ -130,6 +44,17 @@ if (positionalArguments.length > 2 || positionalArguments.length === 0) { const inputFilepath = positionalArguments[0].toString(), password = getPassword(positionalArguments); +if (password.length < 16 && !namedArgs.short) { + console.log( + `WARNING: Your password is less than 16 characters (length: ${password.length}). Brute-force attacks are easy to ` + + `try on public files, and you are most safe when using a long password.\n\n` + + `👉️ Here's a strong generated password you could use: ` + + generateRandomString(21) + + "\n\nThe file was encrypted with your password. You can hide this warning by increasing your password length or" + + " adding the '--short' flag." + ) +} + // get config file const isUsingconfigFile = namedArgs.config.toLowerCase() !== "false"; const configPath = "./" + namedArgs.config; @@ -166,8 +91,22 @@ if (isOptionSetByUser("share", yargs)) { // get the file content const contents = getFileContent(inputFilepath); +// 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 version 2.2.0 or higher." + + "\nYou can find instructions here: https://github.com/robinmoisson/staticrypt/issues/161" + + "\n\n#################################" + ); +} + // encrypt input -const encryptedMessage = encode(contents, password, salt); +const encryptedMessage = encode(contents, password, salt, isLegacy); // create crypto-js tag (embedded or not) let cryptoTag = SCRIPT_TAG; @@ -191,7 +130,9 @@ const data = { encrypted: encryptedMessage, instructions: namedArgs.instructions, is_remember_enabled: namedArgs.noremember ? "false" : "true", - js_codec: convertCommonJSToBrowserJS("lib/codec"), + // TODO: remove on next major version bump. This is a hack to pass the salt to the injected js_codec in a backward + // compatible way (not requiring to update the password_template). + js_codec: convertCommonJSToBrowserJS("lib/codec").replace('##SALT##', salt), js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine/cryptojsEngine"), label_error: namedArgs.labelError, passphrase_placeholder: namedArgs.passphrasePlaceholder, @@ -201,8 +142,8 @@ const data = { title: namedArgs.title, }; -const outputFilePath = namedArgs.output !== null +const outputFilepath = namedArgs.output !== null ? namedArgs.output : inputFilepath.replace(/\.html$/, "") + "_encrypted.html"; -genFile(data, outputFilePath, namedArgs.f); +genFile(data, outputFilepath, namedArgs.f); diff --git a/example/example_encrypted.html b/example/example_encrypted.html index b9b8c39..cff47ee 100644 --- a/example/example_encrypted.html +++ b/example/example_encrypted.html @@ -96,27 +96,6 @@ font-size: 1.5em; } - .staticrypt-footer { - position: fixed; - height: 20px; - font-size: 16px; - padding: 2px; - bottom: 0; - left: 0; - right: 0; - margin-bottom: 0; - } - - .staticrypt-footer p { - margin: 2px; - text-align: center; - float: right; - } - - .staticrypt-footer a { - text-decoration: none; - } - label.staticrypt-remember { display: flex; align-items: center; @@ -202,10 +181,6 @@ - - @@ -262,20 +237,90 @@ * @returns string */ function hashPassphrase(passphrase, salt) { - var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, { + // we hash the passphrase in two steps: first 1k iterations, then we add 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 + var hashedPassphrase = hashLegacyRound(passphrase, salt); + + return hashSecondRound(hashedPassphrase, salt); +} +exports.hashPassphrase = hashPassphrase; + +/** + * This hashes the passphrase with 1k iterations. This is a low number, we need this function to support backwards + * compatibility. + * + * @param {string} passphrase + * @param {string} salt + * @returns {string} + */ +function hashLegacyRound(passphrase, salt) { + return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32, iterations: 1000, - }); + }).toString(); +} +exports.hashLegacyRound = hashLegacyRound; - return hashedPassphrase.toString(); +/** + * 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 hashedPassphrase + * @param salt + * @returns {string} + */ +function hashSecondRound(hashedPassphrase, salt) { + return CryptoJS.PBKDF2(hashedPassphrase, salt, { + keySize: 256 / 32, + iterations: 14000, + hasher: CryptoJS.algo.SHA256, + }).toString(); } -exports.hashPassphrase = hashPassphrase; +exports.hashSecondRound = hashSecondRound; function generateRandomSalt() { return CryptoJS.lib.WordArray.random(128 / 8).toString(); } exports.generateRandomSalt = generateRandomSalt; +function getRandomAlphanum() { + var possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + var byteArray; + var parsedInt; + + // Keep generating new random bytes until we get a value that falls + // within a range that can be evenly divided by possibleCharacters.length + do { + byteArray = CryptoJS.lib.WordArray.random(1); + // extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte) + parsedInt = byteArray.words[0] & 0xff; + } while (parsedInt >= 256 - (256 % possibleCharacters.length)); + + // Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1 + var randomIndex = parsedInt % possibleCharacters.length; + + return possibleCharacters[randomIndex]; +} + +/** + * Generate a random string of a given length. + * + * @param {int} length + * @returns {string} + */ +function generateRandomString(length) { + var randomString = ''; + + for (var i = 0; i < length; i++) { + randomString += getRandomAlphanum(); + } + + return randomString; +} +exports.generateRandomString = generateRandomString; + function signMessage(hashedPassphrase, message) { return CryptoJS.HmacSHA256( message, @@ -294,22 +339,31 @@ * @param cryptoEngine - the engine to use for encryption / decryption */ function init(cryptoEngine) { + // TODO: remove on next major version bump. This is a hack to make the salt available in all functions here in a + // backward compatible way (not requiring to change the password_template). + const backwardCompatibleSalt = 'b93bbaf35459951c47721d1f3eaeb5b9'; + const exports = {}; + /** * Top-level function for encoding a message. - * Includes passphrase hashing, encryption, and signing. + * Includes password hashing, encryption, and signing. * * @param {string} msg - * @param {string} passphrase + * @param {string} password * @param {string} salt + * @param {boolean} isLegacy - whether to use the legacy hashing algorithm (1k iterations) or not * * @returns {string} The encoded text */ - function encode(msg, passphrase, salt) { - const hashedPassphrase = cryptoEngine.hashPassphrase(passphrase, salt); + function encode(msg, password, salt, isLegacy = false) { + // TODO: remove in the next major version bump. This is to not break backwards compatibility with the old way of hashing + const hashedPassphrase = isLegacy + ? cryptoEngine.hashLegacyRound(password, salt) + : cryptoEngine.hashPassphrase(password, salt); const encrypted = cryptoEngine.encrypt(msg, hashedPassphrase); - // we use the hashed passphrase in the HMAC because this is effectively what will be used a passphrase (so we can store - // it in localStorage safely, we don't use the clear text passphrase) + // 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 = cryptoEngine.signMessage(hashedPassphrase, encrypted); return hmac + encrypted; @@ -318,21 +372,49 @@ /** * Top-level function for decoding a message. - * Includes signature check, an decryption. + * Includes signature check and decryption. * * @param {string} signedMsg * @param {string} hashedPassphrase + * @param {string} backwardCompatibleHashedPassword * * @returns {Object} {success: true, decoded: string} | {success: false, message: string} */ - function decode(signedMsg, hashedPassphrase) { + function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') { const encryptedHMAC = signedMsg.substring(0, 64); const encryptedMsg = signedMsg.substring(64); const decryptedHMAC = cryptoEngine.signMessage(hashedPassphrase, encryptedMsg); if (decryptedHMAC !== encryptedHMAC) { + // TODO: remove in next major version bump. This is to not break backwards compatibility with the old 1k + // iterations in PBKDF2 - if the key we try isn't working, it might be because it's a remember-me/autodecrypt + // link key, generated with 1k iterations. Try again with the updated iteration count. + if (!backwardCompatibleHashedPassword) { + return decode( + signedMsg, + cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt), + hashedPassphrase + ); + } + return { success: false, message: "Signature mismatch" }; } + + // TODO: remove in next major version bump. If we're trying to double hash for backward compatibility reasons, + // and the attempt is successful, we check if we should update the stored password in localStorage. This avoids + // having to compute the upgrade each time. + if (backwardCompatibleHashedPassword) { + if (window && window.localStorage) { + const storedPassword = window.localStorage.getItem('staticrypt_passphrase'); + + // check the stored password is actually the backward compatible one, so we don't save the new one and trigger + // the "remember-me" by mistake, leaking the password + if (storedPassword === backwardCompatibleHashedPassword) { + window.localStorage.setItem('staticrypt_passphrase', hashedPassphrase); + } + } + } + return { success: true, decoded: cryptoEngine.decrypt(encryptedMsg, hashedPassphrase), @@ -349,7 +431,7 @@ var decode = codec.init(cryptoEngine).decode; // variables to be filled when generating the file - var encryptedMsg = 'e2097d2f28456efc381ee3693ee4f3c83bbcabd66ac3a80a0a7f163fb009b932c0a25e135aa360fd56ec151d9cec7b02U2FsdGVkX1/BUsUWRYt2XNHAIpvZEDZd3NiW9ev5M8mJXqm1MXQ7pyjmZGM1o+77gjVfIiclQhWy3WM06RvYelbQUdDRBb9pan7fVLB2v1dFWnT6OjNWjqAwv0DgDS6IEsDyNJxRmMuNC2JYPAvcm4nnx2PUCjSbo097nKPRLz9NkzHt6Xv5u+FIlisD5dDImTRLoGZGm7o5uB57R8ZOHQ==', + var encryptedMsg = '060d759ada624841e032ea6cae0702adcbc1d451c01abe34997ed26a84ed4744d441fa6762ebd5a8c57cc5072f604a85U2FsdGVkX1+kxAZ7tF7tajh5NbDHIVUiC4CzLCa9H8BoTKgapvg9mXDGaIDkmRmk3nLDt1I+lMB8vgy/nr2E04CUTvaAJFFua9EZwMzTa6VNWELRJEOrbzESZ8P++2sZyVYUGinfB8ZbBdEZHPErPc6f7ZcksLTmCki+W4cpOEfYF9HBHjsMqu7BVvBCW5NXBajFzasDS327SrLay10VXA==', salt = 'b93bbaf35459951c47721d1f3eaeb5b9', labelError = 'Bad password!', isRememberEnabled = true, diff --git a/index.html b/index.html index 80f27f9..dcce9e5 100644 --- a/index.html +++ b/index.html @@ -43,7 +43,7 @@

Based on the crypto-js library, StatiCrypt uses AES-256 - to encrypt your string with your passphrase in your browser (client side). + to encrypt your string with your long password in your browser (client side).

Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see


@@ -88,9 +89,9 @@

- + + placeholder="Password (choose a long one!)">
@@ -247,20 +248,90 @@

Encrypted HTML

* @returns string */ function hashPassphrase(passphrase, salt) { - var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, { + // we hash the passphrase in two steps: first 1k iterations, then we add 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 + var hashedPassphrase = hashLegacyRound(passphrase, salt); + + return hashSecondRound(hashedPassphrase, salt); +} +exports.hashPassphrase = hashPassphrase; + +/** + * This hashes the passphrase with 1k iterations. This is a low number, we need this function to support backwards + * compatibility. + * + * @param {string} passphrase + * @param {string} salt + * @returns {string} + */ +function hashLegacyRound(passphrase, salt) { + return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32, iterations: 1000, - }); + }).toString(); +} +exports.hashLegacyRound = hashLegacyRound; - return hashedPassphrase.toString(); +/** + * 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 hashedPassphrase + * @param salt + * @returns {string} + */ +function hashSecondRound(hashedPassphrase, salt) { + return CryptoJS.PBKDF2(hashedPassphrase, salt, { + keySize: 256 / 32, + iterations: 14000, + hasher: CryptoJS.algo.SHA256, + }).toString(); } -exports.hashPassphrase = hashPassphrase; +exports.hashSecondRound = hashSecondRound; function generateRandomSalt() { return CryptoJS.lib.WordArray.random(128 / 8).toString(); } exports.generateRandomSalt = generateRandomSalt; +function getRandomAlphanum() { + var possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + var byteArray; + var parsedInt; + + // Keep generating new random bytes until we get a value that falls + // within a range that can be evenly divided by possibleCharacters.length + do { + byteArray = CryptoJS.lib.WordArray.random(1); + // extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte) + parsedInt = byteArray.words[0] & 0xff; + } while (parsedInt >= 256 - (256 % possibleCharacters.length)); + + // Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1 + var randomIndex = parsedInt % possibleCharacters.length; + + return possibleCharacters[randomIndex]; +} + +/** + * Generate a random string of a given length. + * + * @param {int} length + * @returns {string} + */ +function generateRandomString(length) { + var randomString = ''; + + for (var i = 0; i < length; i++) { + randomString += getRandomAlphanum(); + } + + return randomString; +} +exports.generateRandomString = generateRandomString; + function signMessage(hashedPassphrase, message) { return CryptoJS.HmacSHA256( message, @@ -282,22 +353,31 @@

Encrypted HTML

* @param cryptoEngine - the engine to use for encryption / decryption */ function init(cryptoEngine) { + // TODO: remove on next major version bump. This is a hack to make the salt available in all functions here in a + // backward compatible way (not requiring to change the password_template). + const backwardCompatibleSalt = '##SALT##'; + const exports = {}; + /** * Top-level function for encoding a message. - * Includes passphrase hashing, encryption, and signing. + * Includes password hashing, encryption, and signing. * * @param {string} msg - * @param {string} passphrase + * @param {string} password * @param {string} salt + * @param {boolean} isLegacy - whether to use the legacy hashing algorithm (1k iterations) or not * * @returns {string} The encoded text */ - function encode(msg, passphrase, salt) { - const hashedPassphrase = cryptoEngine.hashPassphrase(passphrase, salt); + function encode(msg, password, salt, isLegacy = false) { + // TODO: remove in the next major version bump. This is to not break backwards compatibility with the old way of hashing + const hashedPassphrase = isLegacy + ? cryptoEngine.hashLegacyRound(password, salt) + : cryptoEngine.hashPassphrase(password, salt); const encrypted = cryptoEngine.encrypt(msg, hashedPassphrase); - // we use the hashed passphrase in the HMAC because this is effectively what will be used a passphrase (so we can store - // it in localStorage safely, we don't use the clear text passphrase) + // 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 = cryptoEngine.signMessage(hashedPassphrase, encrypted); return hmac + encrypted; @@ -306,21 +386,49 @@

Encrypted HTML

/** * Top-level function for decoding a message. - * Includes signature check, an decryption. + * Includes signature check and decryption. * * @param {string} signedMsg * @param {string} hashedPassphrase + * @param {string} backwardCompatibleHashedPassword * * @returns {Object} {success: true, decoded: string} | {success: false, message: string} */ - function decode(signedMsg, hashedPassphrase) { + function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') { const encryptedHMAC = signedMsg.substring(0, 64); const encryptedMsg = signedMsg.substring(64); const decryptedHMAC = cryptoEngine.signMessage(hashedPassphrase, encryptedMsg); if (decryptedHMAC !== encryptedHMAC) { + // TODO: remove in next major version bump. This is to not break backwards compatibility with the old 1k + // iterations in PBKDF2 - if the key we try isn't working, it might be because it's a remember-me/autodecrypt + // link key, generated with 1k iterations. Try again with the updated iteration count. + if (!backwardCompatibleHashedPassword) { + return decode( + signedMsg, + cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt), + hashedPassphrase + ); + } + return { success: false, message: "Signature mismatch" }; } + + // TODO: remove in next major version bump. If we're trying to double hash for backward compatibility reasons, + // and the attempt is successful, we check if we should update the stored password in localStorage. This avoids + // having to compute the upgrade each time. + if (backwardCompatibleHashedPassword) { + if (window && window.localStorage) { + const storedPassword = window.localStorage.getItem('staticrypt_passphrase'); + + // check the stored password is actually the backward compatible one, so we don't save the new one and trigger + // the "remember-me" by mistake, leaking the password + if (storedPassword === backwardCompatibleHashedPassword) { + window.localStorage.setItem('staticrypt_passphrase', hashedPassphrase); + } + } + } + return { success: true, decoded: cryptoEngine.decrypt(encryptedMsg, hashedPassphrase), diff --git a/lib/codec.js b/lib/codec.js index dc3ea27..d61ea44 100644 --- a/lib/codec.js +++ b/lib/codec.js @@ -4,22 +4,31 @@ * @param cryptoEngine - the engine to use for encryption / decryption */ function init(cryptoEngine) { + // TODO: remove on next major version bump. This is a hack to make the salt available in all functions here in a + // backward compatible way (not requiring to change the password_template). + const backwardCompatibleSalt = '##SALT##'; + const exports = {}; + /** * Top-level function for encoding a message. - * Includes passphrase hashing, encryption, and signing. + * Includes password hashing, encryption, and signing. * * @param {string} msg - * @param {string} passphrase + * @param {string} password * @param {string} salt + * @param {boolean} isLegacy - whether to use the legacy hashing algorithm (1k iterations) or not * * @returns {string} The encoded text */ - function encode(msg, passphrase, salt) { - const hashedPassphrase = cryptoEngine.hashPassphrase(passphrase, salt); + function encode(msg, password, salt, isLegacy = false) { + // TODO: remove in the next major version bump. This is to not break backwards compatibility with the old way of hashing + const hashedPassphrase = isLegacy + ? cryptoEngine.hashLegacyRound(password, salt) + : cryptoEngine.hashPassphrase(password, salt); const encrypted = cryptoEngine.encrypt(msg, hashedPassphrase); - // we use the hashed passphrase in the HMAC because this is effectively what will be used a passphrase (so we can store - // it in localStorage safely, we don't use the clear text passphrase) + // 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 = cryptoEngine.signMessage(hashedPassphrase, encrypted); return hmac + encrypted; @@ -28,21 +37,49 @@ function init(cryptoEngine) { /** * Top-level function for decoding a message. - * Includes signature check, an decryption. + * Includes signature check and decryption. * * @param {string} signedMsg * @param {string} hashedPassphrase + * @param {string} backwardCompatibleHashedPassword * * @returns {Object} {success: true, decoded: string} | {success: false, message: string} */ - function decode(signedMsg, hashedPassphrase) { + function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') { const encryptedHMAC = signedMsg.substring(0, 64); const encryptedMsg = signedMsg.substring(64); const decryptedHMAC = cryptoEngine.signMessage(hashedPassphrase, encryptedMsg); if (decryptedHMAC !== encryptedHMAC) { + // TODO: remove in next major version bump. This is to not break backwards compatibility with the old 1k + // iterations in PBKDF2 - if the key we try isn't working, it might be because it's a remember-me/autodecrypt + // link key, generated with 1k iterations. Try again with the updated iteration count. + if (!backwardCompatibleHashedPassword) { + return decode( + signedMsg, + cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt), + hashedPassphrase + ); + } + return { success: false, message: "Signature mismatch" }; } + + // TODO: remove in next major version bump. If we're trying to double hash for backward compatibility reasons, + // and the attempt is successful, we check if we should update the stored password in localStorage. This avoids + // having to compute the upgrade each time. + if (backwardCompatibleHashedPassword) { + if (window && window.localStorage) { + const storedPassword = window.localStorage.getItem('staticrypt_passphrase'); + + // check the stored password is actually the backward compatible one, so we don't save the new one and trigger + // the "remember-me" by mistake, leaking the password + if (storedPassword === backwardCompatibleHashedPassword) { + window.localStorage.setItem('staticrypt_passphrase', hashedPassphrase); + } + } + } + return { success: true, decoded: cryptoEngine.decrypt(encryptedMsg, hashedPassphrase), diff --git a/lib/cryptoEngine/cryptojsEngine.js b/lib/cryptoEngine/cryptojsEngine.js index 6a7f830..b97e39f 100644 --- a/lib/cryptoEngine/cryptojsEngine.js +++ b/lib/cryptoEngine/cryptojsEngine.js @@ -47,20 +47,90 @@ exports.decrypt = decrypt; * @returns string */ function hashPassphrase(passphrase, salt) { - var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, { + // we hash the passphrase in two steps: first 1k iterations, then we add 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 + var hashedPassphrase = hashLegacyRound(passphrase, salt); + + return hashSecondRound(hashedPassphrase, salt); +} +exports.hashPassphrase = hashPassphrase; + +/** + * This hashes the passphrase with 1k iterations. This is a low number, we need this function to support backwards + * compatibility. + * + * @param {string} passphrase + * @param {string} salt + * @returns {string} + */ +function hashLegacyRound(passphrase, salt) { + return CryptoJS.PBKDF2(passphrase, salt, { keySize: 256 / 32, iterations: 1000, - }); + }).toString(); +} +exports.hashLegacyRound = hashLegacyRound; - return hashedPassphrase.toString(); +/** + * 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 hashedPassphrase + * @param salt + * @returns {string} + */ +function hashSecondRound(hashedPassphrase, salt) { + return CryptoJS.PBKDF2(hashedPassphrase, salt, { + keySize: 256 / 32, + iterations: 14000, + hasher: CryptoJS.algo.SHA256, + }).toString(); } -exports.hashPassphrase = hashPassphrase; +exports.hashSecondRound = hashSecondRound; function generateRandomSalt() { return CryptoJS.lib.WordArray.random(128 / 8).toString(); } exports.generateRandomSalt = generateRandomSalt; +function getRandomAlphanum() { + var possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + var byteArray; + var parsedInt; + + // Keep generating new random bytes until we get a value that falls + // within a range that can be evenly divided by possibleCharacters.length + do { + byteArray = CryptoJS.lib.WordArray.random(1); + // extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte) + parsedInt = byteArray.words[0] & 0xff; + } while (parsedInt >= 256 - (256 % possibleCharacters.length)); + + // Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1 + var randomIndex = parsedInt % possibleCharacters.length; + + return possibleCharacters[randomIndex]; +} + +/** + * Generate a random string of a given length. + * + * @param {int} length + * @returns {string} + */ +function generateRandomString(length) { + var randomString = ''; + + for (var i = 0; i < length; i++) { + randomString += getRandomAlphanum(); + } + + return randomString; +} +exports.generateRandomString = generateRandomString; + function signMessage(hashedPassphrase, message) { return CryptoJS.HmacSHA256( message, diff --git a/lib/password_template.html b/lib/password_template.html index 472b8d3..dfd30ef 100644 --- a/lib/password_template.html +++ b/lib/password_template.html @@ -96,27 +96,6 @@ font-size: 1.5em; } - .staticrypt-footer { - position: fixed; - height: 20px; - font-size: 16px; - padding: 2px; - bottom: 0; - left: 0; - right: 0; - margin-bottom: 0; - } - - .staticrypt-footer p { - margin: 2px; - text-align: center; - float: right; - } - - .staticrypt-footer a { - text-decoration: none; - } - label.staticrypt-remember { display: flex; align-items: center; @@ -202,10 +181,6 @@
- - diff --git a/package-lock.json b/package-lock.json index cb6cdab..9cbc566 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "staticrypt", - "version": "2.3.4", + "version": "2.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "staticrypt", - "version": "2.3.4", + "version": "2.4.0", "license": "MIT", "dependencies": { "crypto-js": "3.1.9-1", diff --git a/package.json b/package.json index d68f38f..dcf0669 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "staticrypt", - "version": "2.3.5", + "version": "2.4.0", "description": "Based on the [crypto-js](https://github.com/brix/crypto-js) library, StatiCrypt uses AES-256 to encrypt your input with your passphrase and put it in a HTML file with a password prompt that can decrypted in-browser (client side).", "main": "index.js", "files": [ diff --git a/scripts/index_template.html b/scripts/index_template.html index 5aee40d..cc72d30 100644 --- a/scripts/index_template.html +++ b/scripts/index_template.html @@ -43,7 +43,7 @@

Based on the crypto-js library, StatiCrypt uses AES-256 - to encrypt your string with your passphrase in your browser (client side). + to encrypt your string with your long password in your browser (client side).

Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see


@@ -88,9 +89,9 @@

- + + placeholder="Password (choose a long one!)">