From 059701ce891958433b82b6934442094c13618841 Mon Sep 17 00:00:00 2001 From: robinmoisson Date: Thu, 30 Mar 2023 12:03:18 +0200 Subject: [PATCH] refactor password template to make it simpler (closes #167) --- .gitignore | 2 + README.md | 94 ++- cli/helpers.js | 22 +- cli/index.js | 33 +- .../example.html} | 195 +++-- index.html | 670 +++++++++++++++++- lib/formater.js | 18 +- lib/password_template.html | 205 +----- lib/staticryptJs.js | 215 ++++++ scripts/build.sh | 17 +- scripts/buildIndex.js | 3 +- scripts/index_template.html | 71 +- 12 files changed, 1168 insertions(+), 377 deletions(-) rename example/{example_encrypted.html => encrypted/example.html} (78%) create mode 100644 lib/staticryptJs.js diff --git a/.gitignore b/.gitignore index 5100467..a10bedf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules .staticrypt.json .env +encrypted/ +!example/encrypted/ diff --git a/README.md b/README.md index 20140d0..1e3bd10 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # StatiCrypt -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)). +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/encrypted/example.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)). @@ -63,55 +63,51 @@ find . -type f -name "*.html" -not -name "*_encrypted.html" -exec staticrypt {} 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] - --version Show version number [boolean] - -c, --config Path to the config file. Set to "false" to - disable. [string] [default: ".staticrypt.json"] - --decrypt-button Label to use for the decrypt button. Default: - "DECRYPT". [string] [default: "DECRYPT"] - -e, --embed Whether or not to embed crypto-js in the page - (or use an external CDN). - [boolean] [default: true] - --engine The crypto engine to use. WebCrypto uses 600k - iterations and is more secure, CryptoJS 15k. - Possible values: 'cryptojs', 'webcrypto'. - [string] [default: "cryptojs"] - -f, --file-template Path to custom HTML template with password - prompt. - [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 - 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 password input. - [string] [default: "Password"] - -r, --remember 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. [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 - want to use "Remember me" through multiple - pages. It needs to be a 32-character-long - hexadecimal string. - Include the empty flag to generate a random salt - you can use: "statycrypt -s". [string] - --share 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. + --help Show help [boolean] + --version Show version number [boolean] + -c, --config Path to the config file. Set to "false" to + disable. [string] [default: ".staticrypt.json"] + -o, --output Name of the directory where the encrypted files + will be saved. [string] [default: "encrypted/"] + -p, --password The password to encrypt your file with. Leave + empty to be prompted for it. If + STATICRYPT_PASSWORD is set in the env, we'll use + that instead. [string] [default: null] + --remember Expiration in days of the "Remember me" checkbox + that will save the (salted + hashed) password in + localStorage when entered by the user. Set to + "false" to hide the box. Default: "0", no + expiration. [number] [default: 0] + -s, --salt 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. + Include the empty flag to generate a random salt + you can use: "statycrypt -s". [string] + --share 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. [string] - --short Hide the "short password" warning. + --short Hide the "short password" warning. [boolean] [default: false] - -t, --title Title for the output HTML page. + -t, --template Path to custom HTML template with password + prompt. + [string] [default: "/code/staticrypt/lib/password_template.html"] + --template-button Label to use for the decrypt button. Default: + "DECRYPT". [string] [default: "DECRYPT"] + --template-instructions Special instructions to display to the user. + [string] [default: ""] + --template-error Error message to display on entering wrong + password. [string] [default: "Bad password!"] + --template-placeholder Placeholder to use for the password input. + [string] [default: "Password"] + --template-remember Label to use for the "Remember me" checkbox. + [string] [default: "Remember me"] + --template-title Title for the output HTML page. [string] [default: "Protected Page"] @@ -137,7 +133,9 @@ On the technical aspects: we use AES in CBC mode (see a discussion on why it's a ### Can I customize the password prompt? -Yes! Just copy `lib/password_template.html`, modify it to suit your style and point to your template file with the `-f path/to/my/file.html` flag. Be careful to not break the encrypting javascript part, the variables replaced by StatiCrypt are between curly brackets: `{salt}`. +Yes! Just copy `lib/password_template.html`, modify it to suit your style and point to your template file with the `-t path/to/my/file.html` flag. + +Be careful to not break the encrypting javascript part, the variables replaced by StatiCrypt are in this format: `/*[|variable|]*/0`. Don't leave out the `0` at the end, this weird syntax is to avoid conflict with other templating engines while still being read as valid JS to parsers so we can use auto-formatting on the template files. ### Can I remove the "Remember me" checkbox? @@ -223,7 +221,7 @@ npm run build #### Test -The testing is done manually for now - run [build](#build), then open `example/example_encypted.html` and check everything works correctly. +The testing is done manually for now - run [build](#build), then open `example/encrypted/example.html` and check everything works correctly. ## Community and alternatives diff --git a/cli/helpers.js b/cli/helpers.js index e3341b5..b26a6c8 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -3,7 +3,7 @@ const fs = require("fs"); const readline = require('readline'); const { generateRandomSalt } = require("../lib/cryptoEngine.js"); -const {renderTemplate} = require("../lib/formater.js"); +const { renderTemplate } = require("../lib/formater.js"); const Yargs = require("yargs"); const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "password_template.html"); @@ -158,6 +158,23 @@ function convertCommonJSToBrowserJS(modulePath) { } exports.convertCommonJSToBrowserJS = convertCommonJSToBrowserJS; +/** + * Build the staticrypt script string to inject in our template. + * + * @returns {string} + */ +function buildStaticryptJS() { + let staticryptJS = convertCommonJSToBrowserJS("lib/staticryptJs"); + + const scriptsToInject = { + js_codec: convertCommonJSToBrowserJS("lib/codec"), + js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"), + }; + + return renderTemplate(staticryptJS, scriptsToInject); +} +exports.buildStaticryptJS = buildStaticryptJS; + /** * @param {string} filePath * @param {string} errorName @@ -226,7 +243,8 @@ function parseCommandLineArguments() { .option("p", { alias: "password", type: "string", - describe: "The password to encrypt your file with.", + describe: "The password to encrypt your file with. Leave empty to be prompted for it. If STATICRYPT_PASSWORD" + + " is set in the env, we'll use that instead.", default: null, }) .option("remember", { diff --git a/cli/index.js b/cli/index.js index ec73e9e..72b8803 100755 --- a/cli/index.js +++ b/cli/index.js @@ -11,8 +11,7 @@ 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 { parseCommandLineArguments} = require("./helpers.js"); +const { parseCommandLineArguments, buildStaticryptJS, exitWithError, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt } = require("./helpers.js"); // parse arguments const yargs = parseCommandLineArguments(); @@ -85,21 +84,25 @@ async function runStatiCrypt() { const contents = getFileContent(inputFilepath); // encrypt input - const encryptedMessage = await encode(contents, password, salt); + const encryptedMsg = await encode(contents, password, salt); + + const isRememberEnabled = namedArgs.remember !== "false"; const data = { - decrypt_button: namedArgs.templateButton, - encrypted: encryptedMessage, - instructions: namedArgs.templateInstructions, - is_remember_enabled: namedArgs.remember === "false" ? "false" : "true", - js_codec: convertCommonJSToBrowserJS("lib/codec"), - js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"), - label_error: namedArgs.templateError, - passphrase_placeholder: namedArgs.templatePlaceholder, - remember_duration_in_days: namedArgs.remember, - remember_me: namedArgs.templateRemember, - salt: salt, - title: namedArgs.templateTitle, + is_remember_enabled: JSON.stringify(isRememberEnabled), + js_staticrypt: buildStaticryptJS(), + staticrypt_config: { + encryptedMsg, + isRememberEnabled, + rememberDurationInDays: namedArgs.remember, + salt, + }, + template_button: namedArgs.templateButton, + template_error: namedArgs.templateError, + template_instructions: namedArgs.templateInstructions, + template_placeholder: namedArgs.templatePlaceholder, + template_remember: namedArgs.templateRemember, + template_title: namedArgs.templateTitle, }; const outputFilepath = namedArgs.output.replace(/\/+$/, '') + "/" + inputFilepath; diff --git a/example/example_encrypted.html b/example/encrypted/example.html similarity index 78% rename from example/example_encrypted.html rename to example/encrypted/example.html index e500d97..7be759a 100644 --- a/example/example_encrypted.html +++ b/example/encrypted/example.html @@ -183,14 +183,11 @@ - - - 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 @@ exports.init = init; 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 @@ exports.init = init; * @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 @@ exports.renderTemplate = renderTemplate; })()) + + 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) -# encrypt the example file -node cli/index.js example/example.html test \ - --engine webcrypto \ - --short \ - --salt b93bbaf35459951c47721d1f3eaeb5b9 \ - --instructions "Enter \"test\" to unlock the page" - # build the index.html file node ./scripts/buildIndex.js + +# encrypt the example file +cd example +node ../cli/index.js example.html \ + -p test \ + --short \ + --salt b93bbaf35459951c47721d1f3eaeb5b9 \ + --config false \ + --template-instructions "Enter \"test\" to unlock the page" + 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 @@ Your encrypted string + +