plug webcrypto everywhere, update doc

pull/164/head
robinmoisson 2023-03-26 13:37:47 +02:00
rodzic 8116020815
commit 6c92f0d5cd
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 9419716500078583
11 zmienionych plików z 766 dodań i 388 usunięć

Wyświetl plik

@ -22,7 +22,9 @@ You can then run it with `npx staticrypt ...`. You can also install globally wit
### Examples
> These will create a `.staticrypt.json` file in the current directory, see the FAQ as to why. You can prevent it by setting the `--config` flag to "false".
> If you're viewing your file over HTTPS or localhost, you can add the `--engine webcrypto` flag to use the WebCrypto engine, which is more secure here. Otherwise the CryptoJS engine will be used.
>
> These examples will create a `.staticrypt.json` file in the current directory, see the FAQ as to why. You can prevent it by setting the `--config` flag to "false".
**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):
@ -73,6 +75,10 @@ The password argument is optional if `STATICRYPT_PASSWORD` is set in the environ
-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"]
@ -113,7 +119,7 @@ The password argument is optional if `STATICRYPT_PASSWORD` is set in the environ
So, how can you password protect html without a back-end?
StatiCrypt uses the [crypto-js](https://github.com/brix/crypto-js) library to generate a static, password protected page that can be decrypted in-browser. You can then just send or upload the generated page to a place serving static content (github pages, for example) and you're done: the page will prompt users for a password, and the javascript will decrypt and load your HTML, all done in the browser.
StatiCrypt uses the [crypto-js](https://github.com/brix/crypto-js) library or WebCrypto to generate a static, password protected page that can be decrypted in-browser. You can then just send or upload the generated page to a place serving static content (github pages, for example) and you're done: the page will prompt users for a password, and the javascript will decrypt and load your HTML, all done in the browser.
So it basically encrypts your page and puts everything in a user-friendly way to use a password in the new file.
@ -137,7 +143,17 @@ Yes! Just copy `lib/password_template.html`, modify it to suit your style and po
If you don't want the checkbox to be included, you can add the `--noremember` flag to disable it.
### Why do we embed the whole crypto-js library in each encrypted file by default?
### Should I use the WebCrypto or CryptoJS engine?
CryptoJS is the JS library that StatiCrypt used at first to do its crypto operations. WebCrypto is a browser API which exposes crypto methods, without having to rely on an external library.
WebCrypto is faster, which allows us to do more hashing rounds and make StatiCrypt more robust against brute-force attacks - if you can, **you should use WebCrypto**. The only limitation is it's only available in HTTPS context (which [is annoying people](https://github.com/w3c/webcrypto/issues/28)) or on localhost and on non-ancient browsers, so if you need that you can use `--engine cryptojs` which works everywhere. WebCrypto will be the only available option in our next major version.
> **Will it break share links/remember-me?** If you encrypted a file with the CryptoJS engine and shared auto-decrypt links, or activated the remember-me flag, then switch to WebCrypto, the change is backward compatible and the file should still autodecrypt. The reverse isn't true - don't create an auto-decrypt link with WebCrypto then encrypt your file with CryptoJS.
>
> This is because we use more hashing rounds with the faster WebCrypto, making it more secure, but we can't remove hashing rounds to convert back (which is the whole point of a hash).
### Why do we embed the whole crypto-js library in each encrypted file when using the CryptoJS engine by default?
Some adblockers used to see the `crypto-js.min.js` served by CDN, think that's a crypto miner and block it. If you don't want to include it and serve from a CDN instead, you can add `--embed false`.
@ -218,6 +234,4 @@ Here are some other projects and community resources you might find interesting
### Based on StatiCrypt
**WebCrypto:** https://github.com/tarpdalton/staticrypt/tree/webcrypto is a fork that uses the WebCrypto browser api to encrypt and decrypt the page, which removes the need for `crypto-js`. There's a PR open towards here which I haven't checked in detail yet. WebCrypto is only available in HTTPS context (which [is annoying people](https://github.com/w3c/webcrypto/issues/28)) so it won't work if you're on HTTP.
**Template to host an encrypted single page website with Github Pages:** [a-nau/password-protected-website-template](https://github.com/a-nau/password-protected-website-template) is a demonstration of how to build a protected page on Github Pages, integrating with Github Actions

Wyświetl plik

@ -1,10 +1,9 @@
const fs = require("fs");
const cryptoEngine = require("../lib/cryptoEngine/cryptojsEngine");
const { generateRandomSalt } = require("../lib/cryptoEngine/webcryptoEngine.js");
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");
@ -12,11 +11,11 @@ const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "passwo
/**
* @param {string} message
*/
function exitEarly(message) {
console.log(message);
function exitWithError(message) {
console.log("ERROR: " + message);
process.exit(1);
}
exports.exitEarly = exitEarly;
exports.exitWithError = exitWithError;
/**
* Check if a particular option has been set by the user. Useful for distinguishing default value with flag without
@ -66,7 +65,7 @@ function getPassword(positionalArguments) {
}
if (positionalArguments.length < 2) {
exitEarly("Missing password: please provide an argument or set the STATICRYPT_PASSWORD environment variable in the environment or .env file");
exitWithError("missing password, please provide an argument or set the STATICRYPT_PASSWORD environment variable in the environment or .env file");
}
return positionalArguments[1].toString();
@ -81,7 +80,7 @@ function getFileContent(filepath) {
try {
return fs.readFileSync(filepath, "utf8");
} catch (e) {
exitEarly("Failure: input file does not exist!");
exitWithError("input file does not exist!");
}
}
exports.getFileContent = getFileContent;
@ -119,7 +118,7 @@ function convertCommonJSToBrowserJS(modulePath) {
const resolvedPath = path.join(rootDirectory, ...modulePath.split("/")) + ".js";
if (!fs.existsSync(resolvedPath)) {
exitEarly(`Failure: could not find module to convert at path "${resolvedPath}"`);
exitWithError(`could not find module to convert at path "${resolvedPath}"`);
}
const moduleText = fs
@ -145,7 +144,7 @@ function readFile(filePath, errorName = file) {
try {
return fs.readFileSync(filePath, "utf8");
} catch (e) {
exitEarly(`Failure: could not read ${errorName}!`);
exitWithError(`could not read ${errorName}!`);
}
}
@ -164,7 +163,7 @@ function genFile(data, outputFilePath, templateFilePath) {
try {
fs.writeFileSync(outputFilePath, renderedTemplate);
} catch (e) {
exitEarly("Failure: could not generate output file!");
exitWithError("could not generate output file!");
}
}
exports.genFile = genFile;
@ -179,22 +178,39 @@ exports.genFile = genFile;
* @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;
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}
*/
function isCustomPasswordTemplateDefault(templatePathParameter) {
// if the user uses the default template, it's up to date
return templatePathParameter === PASSWORD_TEMPLATE_DEFAULT_PATH;
}
exports.isCustomPasswordTemplateDefault = isCustomPasswordTemplateDefault;
function parseCommandLineArguments() {
return Yargs.usage("Usage: staticrypt <filename> [<password>] [options]")
.option("c", {
@ -216,7 +232,8 @@ function parseCommandLineArguments() {
})
.option("engine", {
type: "string",
describe: "The crypto engine to use. Possible values: 'cryptojs', 'webcrypto'.",
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", {

Wyświetl plik

@ -10,12 +10,12 @@ require('dotenv').config();
const cryptojsEngine = require("../lib/cryptoEngine/cryptojsEngine");
const webcryptoEngine = require("../lib/cryptoEngine/webcryptoEngine");
const codec = require("../lib/codec");
const { convertCommonJSToBrowserJS, exitEarly, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers");
const { isCustomPasswordTemplateLegacy, parseCommandLineArguments} = require("./helpers.js");
const codec = require("../lib/codec.js");
const { convertCommonJSToBrowserJS, exitWithError, isOptionSetByUser, genFile, getPassword, getFileContent, getSalt} = require("./helpers");
const { isCustomPasswordTemplateLegacy, parseCommandLineArguments, isPasswordTemplateUsingAsync} = require("./helpers.js");
const CRYPTOJS_SCRIPT_TAG =
'<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js" ' +
'<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js" ' +
'integrity="sha384-lp4k1VRKPU9eBnPePjnJ9M2RF3i7PC30gXs70+elCVfgwLwx1tv5+ctxdtwxqZa7" crossorigin="anonymous"></script>';
// parse arguments
@ -28,132 +28,157 @@ const cryptoEngine = isWebcrypto ? webcryptoEngine : cryptojsEngine;
const { generateRandomSalt, generateRandomString } = cryptoEngine;
const { encode } = codec.init(cryptoEngine);
// if the 's' flag is passed without parameter, generate a salt, display & exit
if (isOptionSetByUser("s", yargs) && !namedArgs.salt) {
console.log(generateRandomSalt());
process.exit(0);
async function runStatiCrypt() {
// if the 's' flag is passed without parameter, generate a salt, display & exit
if (isOptionSetByUser("s", yargs) && !namedArgs.salt) {
console.log(generateRandomSalt());
process.exit(0);
}
// validate the number of arguments
const positionalArguments = namedArgs._;
if (positionalArguments.length > 2 || positionalArguments.length === 0) {
yargs.showHelp();
process.exit(1);
}
// parse input
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;
let config = {};
if (isUsingconfigFile && fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
}
// get the salt
const salt = getSalt(namedArgs, config);
// validate the salt
if (salt.length !== 32 || /[^a-f0-9]/.test(salt)) {
exitWithError(
"the salt should be a 32 character long hexadecimal string (only [0-9a-f] characters allowed)"
+ "\nDetected salt: " + salt
);
}
// write salt to config file
if (isUsingconfigFile && config.salt !== salt) {
config.salt = salt;
fs.writeFileSync(configPath, JSON.stringify(config, null, 4));
}
// display the share link with the hashed password if the --share flag is set
if (isOptionSetByUser("share", yargs)) {
const url = namedArgs.share || "";
const hashedPassword = await cryptoEngine.hashPassphrase(password, salt);
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 = "<script>" + embedContents + "</script>";
} 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);
});
}
// validate the number of arguments
const positionalArguments = namedArgs._;
if (positionalArguments.length > 2 || positionalArguments.length === 0) {
yargs.showHelp();
process.exit(1);
}
// parse input
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;
let config = {};
if (isUsingconfigFile && fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, "utf8"));
}
// get the salt
const salt = getSalt(namedArgs, config);
// validate the salt
if (salt.length !== 32 || /[^a-f0-9]/.test(salt)) {
exitEarly(
"The salt should be a 32 character long hexadecimal string (only [0-9a-f] characters allowed)"
+ "\nDetected salt: " + salt
);
}
// write salt to config file
if (isUsingconfigFile && config.salt !== salt) {
config.salt = salt;
fs.writeFileSync(configPath, JSON.stringify(config, null, 4));
}
// display the share link with the hashed password if the --share flag is set
if (isOptionSetByUser("share", yargs)) {
const url = namedArgs.share || "";
const hashedPassphrase = cryptoEngine.hashPassphrase(password, salt);
console.log(url + "?staticrypt_pwd=" + hashedPassphrase);
}
// 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#################################"
);
}
// 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 = "<script>" + embedContents + "</script>";
} catch (e) {
exitEarly("Failure: embed file does not exist!");
}
}
const cryptoEngineString = isWebcrypto
? convertCommonJSToBrowserJS("lib/cryptoEngine/webcryptoEngine")
: convertCommonJSToBrowserJS("lib/cryptoEngine/cryptojsEngine");
// encrypt input
encode(contents, password, salt, isLegacy).then((encryptedMessage) => {
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",
// 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: 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);
});
runStatiCrypt();

Wyświetl plik

@ -187,6 +187,9 @@
<script>
// do not remove this comment, it is used to detect the version of the script
// STATICRYPT_VERSION: async
const cryptoEngine = ((function(){
const exports = {};
const { subtle } = crypto;
@ -324,12 +327,13 @@ exports.decrypt = decrypt;
* @returns {Promise<string>}
*/
async function hashPassphrase(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
const hashedPassphrase = await hashLegacyRound(passphrase, salt);
// we hash the passphrase in multiple steps, each adding more iterations. This is because we used to allow less
// iterations, so for backward compatibility reasons, we need to support going from that to more iterations.
let hashedPassphrase = await hashLegacyRound(passphrase, salt);
return hashSecondRound(hashedPassphrase, salt);
hashedPassphrase = await hashSecondRound(hashedPassphrase, salt);
return hashThirdRound(hashedPassphrase, salt);
}
exports.hashPassphrase = hashPassphrase;
@ -359,6 +363,19 @@ function hashSecondRound(hashedPassphrase, salt) {
}
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 hashedPassphrase
* @param salt
* @returns {Promise<string>}
*/
function hashThirdRound(hashedPassphrase, salt) {
return pbkdf2(hashedPassphrase, salt, 585000, "SHA-256");
}
exports.hashThirdRound = hashThirdRound;
/**
* Salt and hash the passphrase so it can be stored in localStorage without opening a password reuse vulnerability.
*
@ -390,7 +407,6 @@ async function pbkdf2(passphrase, salt, iterations, hashAlgorithm) {
return HexEncoder.stringify(new Uint8Array(keyBytes));
}
exports.hashPassphrase = hashPassphrase;
function generateRandomSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(128 / 8));
@ -465,10 +481,6 @@ exports.generateRandomString = generateRandomString;
* @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 = {};
/**
@ -478,15 +490,11 @@ function init(cryptoEngine) {
* @param {string} msg
* @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
*/
async 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
? await cryptoEngine.hashLegacyRound(password, salt)
: await cryptoEngine.hashPassphrase(password, salt);
async function encode(msg, password, salt) {
const hashedPassphrase = await cryptoEngine.hashPassphrase(password, salt);
const encrypted = await cryptoEngine.encrypt(msg, hashedPassphrase);
@ -504,45 +512,42 @@ function init(cryptoEngine) {
*
* @param {string} signedMsg
* @param {string} hashedPassphrase
* @param {string} backwardCompatibleHashedPassword
* @param {string} salt
* @param {int} backwardCompatibleAttempt
* @param {string} originalPassphrase
*
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
*/
async function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') {
async function decode(
signedMsg,
hashedPassphrase,
salt,
backwardCompatibleAttempt = 0,
originalPassphrase = ''
) {
const encryptedHMAC = signedMsg.substring(0, 64);
const encryptedMsg = signedMsg.substring(64);
const decryptedHMAC = await 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,
await cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
hashedPassphrase
);
// 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.
originalPassphrase = originalPassphrase || hashedPassphrase;
if (backwardCompatibleAttempt === 0) {
const updatedHashedPassphrase = await cryptoEngine.hashThirdRound(originalPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
if (backwardCompatibleAttempt === 1) {
let updatedHashedPassphrase = await cryptoEngine.hashSecondRound(originalPassphrase, salt);
updatedHashedPassphrase = await cryptoEngine.hashThirdRound(updatedHashedPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
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: await cryptoEngine.decrypt(encryptedMsg, hashedPassphrase),
@ -559,7 +564,7 @@ exports.init = init;
const decode = codec.init(cryptoEngine).decode;
// variables to be filled when generating the file
const encryptedMsg = '69480424a5fcc19909363e39d8434a0f706b32183e0771f2be1e746cdadfef879e9866b997f75e85a60ddfb7483e571eeba89134dea2f77a4b1123fb2825e8d32132045ea5a98b491fb29b20142643c98a2bc24e90d936f58dea6c67a2a692c9066e4d0cce5f01da8b433a5338224327edda47dadf43a6ffc45185f26f4c2438a68af464f3a69cb586fbb71cf4ef353a66d474fafa848bf736c683c986bb86b8e23dfcf6f3dbef170cacaae3591f9004f6f44b4dcc597d5696adfbb0781a4c32',
const encryptedMsg = '1c9cbdfdb99a165b6459b329f37250ec99c30c53c5cca71d28e19314268896ec613dc0443a36e38dc5a983145dda064fc7716024e87c32ae5dbe03a888731e53e0869a0e739134d91fb343c0b11c759a2a9f9ac755544274af0fad8959919ad91e285405f9255b9ad8ce83f3f8e13e367c0b061b1bd84df78440a63196469743d34a887819d31111750f0de9d915e85020ca3b4cd209e5ad82678c572fc677642a8884fff4943b6366d4172d515519db96b6476f23066d36ae40b1b3bdd3c69a',
salt = 'b93bbaf35459951c47721d1f3eaeb5b9',
labelError = 'Bad password!',
isRememberEnabled = true,
@ -576,7 +581,7 @@ exports.init = init;
* @returns {Promise<boolean>}
*/
async function decryptAndReplaceHtml(hashedPassphrase) {
const result = await decode(encryptedMsg, hashedPassphrase);
const result = await decode(encryptedMsg, hashedPassphrase, salt);
if (!result.success) {
return false;
}

Wyświetl plik

@ -42,8 +42,7 @@
<small>Password protect a static HTML page</small>
</h1>
<p>
Based on the <a href="https://github.com/brix/crypto-js">crypto-js library</a>, StatiCrypt uses AES-256
to encrypt your string with your long password in your browser (client side).
StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in your browser (client side).
</p>
<p>
Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see <a
@ -70,6 +69,14 @@
just send or upload the generated page to a place serving static content (github pages, for example)
and you're done: the javascript will prompt users for password, decrypt the page and load your HTML.
</p>
<p>
The page is encrypted with AES-256 in CBC mode (see why this mode is appropriate for StatiCrypt in
<a href="https://github.com/robinmoisson/staticrypt/issues/19">#19</a>). The password is hashed with
PBKDF2 (599k iterations with SHA-256, plus 1k with SHA-1 for legacy reasons (see
<a href="https://github.com/robinmoisson/staticrypt/issues/159">#159</a>), for the added
<a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2">recommended
total</a> of 600k) and used to encrypt the page.
</p>
<p>
It basically encrypts your page and puts everything with a user-friendly way to use a password
in the new file. AES-256 is state of the art but <b>brute-force/dictionary attacks would be easy to
@ -157,19 +164,6 @@
<label for="decrypt_button">Decrypt button label</label>
<input type="text" class="form-control" id="decrypt_button" placeholder="Default: 'DECRYPT'">
</div>
<div class="form-group">
<label class="no-style">
<input type="checkbox" id="embed-crypto" checked>
Embed crypto-js into your file
<small>
<abbr class="text-muted"
title="Leave checked to include crypto-js into your file so you can decrypt it offline. Uncheck to load crypto-js from a CDN.">
(?)
</abbr>
</small>
</label>
</div>
</div>
<button class="btn btn-primary pull-right" type="submit">Generate passphrase protected HTML</button>
@ -335,12 +329,13 @@ exports.decrypt = decrypt;
* @returns {Promise<string>}
*/
async function hashPassphrase(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
const hashedPassphrase = await hashLegacyRound(passphrase, salt);
// we hash the passphrase in multiple steps, each adding more iterations. This is because we used to allow less
// iterations, so for backward compatibility reasons, we need to support going from that to more iterations.
let hashedPassphrase = await hashLegacyRound(passphrase, salt);
return hashSecondRound(hashedPassphrase, salt);
hashedPassphrase = await hashSecondRound(hashedPassphrase, salt);
return hashThirdRound(hashedPassphrase, salt);
}
exports.hashPassphrase = hashPassphrase;
@ -370,6 +365,19 @@ function hashSecondRound(hashedPassphrase, salt) {
}
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 hashedPassphrase
* @param salt
* @returns {Promise<string>}
*/
function hashThirdRound(hashedPassphrase, salt) {
return pbkdf2(hashedPassphrase, salt, 585000, "SHA-256");
}
exports.hashThirdRound = hashThirdRound;
/**
* Salt and hash the passphrase so it can be stored in localStorage without opening a password reuse vulnerability.
*
@ -401,7 +409,6 @@ async function pbkdf2(passphrase, salt, iterations, hashAlgorithm) {
return HexEncoder.stringify(new Uint8Array(keyBytes));
}
exports.hashPassphrase = hashPassphrase;
function generateRandomSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(128 / 8));
@ -479,10 +486,6 @@ exports.generateRandomString = generateRandomString;
* @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 = {};
/**
@ -492,15 +495,11 @@ function init(cryptoEngine) {
* @param {string} msg
* @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
*/
async 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
? await cryptoEngine.hashLegacyRound(password, salt)
: await cryptoEngine.hashPassphrase(password, salt);
async function encode(msg, password, salt) {
const hashedPassphrase = await cryptoEngine.hashPassphrase(password, salt);
const encrypted = await cryptoEngine.encrypt(msg, hashedPassphrase);
@ -518,45 +517,42 @@ function init(cryptoEngine) {
*
* @param {string} signedMsg
* @param {string} hashedPassphrase
* @param {string} backwardCompatibleHashedPassword
* @param {string} salt
* @param {int} backwardCompatibleAttempt
* @param {string} originalPassphrase
*
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
*/
async function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') {
async function decode(
signedMsg,
hashedPassphrase,
salt,
backwardCompatibleAttempt = 0,
originalPassphrase = ''
) {
const encryptedHMAC = signedMsg.substring(0, 64);
const encryptedMsg = signedMsg.substring(64);
const decryptedHMAC = await 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,
await cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
hashedPassphrase
);
// 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.
originalPassphrase = originalPassphrase || hashedPassphrase;
if (backwardCompatibleAttempt === 0) {
const updatedHashedPassphrase = await cryptoEngine.hashThirdRound(originalPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
if (backwardCompatibleAttempt === 1) {
let updatedHashedPassphrase = await cryptoEngine.hashSecondRound(originalPassphrase, salt);
updatedHashedPassphrase = await cryptoEngine.hashThirdRound(updatedHashedPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
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: await cryptoEngine.decrypt(encryptedMsg, hashedPassphrase),
@ -600,12 +596,12 @@ exports.renderTemplate = renderTemplate;
</script>
<script>
var encode = codec.init(cryptoEngine).encode;
const encode = codec.init(cryptoEngine).encode;
// enable CKEDIRTOR
CKEDITOR.replace('instructions');
var htmlToDownload;
let htmlToDownload;
/**
* Extract js code from <script> tag and return it as a string
@ -613,19 +609,19 @@ exports.renderTemplate = renderTemplate;
* @param id
* @returns
*/
var getScriptAsString = function (id) {
function getScriptAsString(id) {
return document.getElementById(id)
.innerText.replace(/window\.\w+ = /, '');
}
/**
* Register something happened - this uses a simple Supabase function to implement a counter, and allows use to drop
* google analytics.
* google analytics. We don't store the IP or any personal information.
*
* @param action
*/
function trackEvent(action) {
var xhr = new XMLHttpRequest();
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://zlgpaemmniviswibzuwt.supabase.co/rest/v1/rpc/increment_analytics', true);
xhr.setRequestHeader('Content-type', 'application/json; charset=UTF-8')
xhr.setRequestHeader('apikey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpsZ3BhZW1tbml2aXN3aWJ6dXd0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjkxMjM0OTcsImV4cCI6MTk4NDY5OTQ5N30.wNoVDHG7F6INx-IPotMs3fL1nudfaF2qvQDgG-1PhNI')
@ -641,34 +637,20 @@ exports.renderTemplate = renderTemplate;
* Fill the password prompt template with data provided.
* @param data
*/
var setFileToDownload = function (data) {
var request = new XMLHttpRequest();
function setFileToDownload (data) {
const request = new XMLHttpRequest();
request.open('GET', 'lib/password_template.html', true);
request.onload = function () {
var renderedTmpl = formater.renderTemplate(request.responseText, data);
const renderedTmpl = formater.renderTemplate(request.responseText, data);
var downloadLink = document.querySelector('a.download');
const downloadLink = document.querySelector('a.download');
downloadLink.href = 'data:text/html,' + encodeURIComponent(renderedTmpl);
downloadLink.removeAttribute('disabled');
htmlToDownload = renderedTmpl;
};
request.send();
};
/**
* Download crypto-js lib to embed it in the generated file, update the file when done.
* @param data
*/
var setFileToDownloadWithEmbeddedCrypto = function (data) {
var request = new XMLHttpRequest();
request.open('GET', 'lib/kryptojs-3.1.9-1.min.js', true);
request.onload = function () {
data['crypto_tag'] = '<script>' + request.responseText + '</scr' + 'ipt>';
setFileToDownload(data);
};
request.send();
};
}
// register page load
window.onload = function () {
@ -678,7 +660,7 @@ exports.renderTemplate = renderTemplate;
/**
* Handle form submission.
*/
document.getElementById('encrypt_form').addEventListener('submit', function (e) {
document.getElementById('encrypt_form').addEventListener('submit', async function (e) {
e.preventDefault();
trackEvent('generate_encrypted');
@ -687,13 +669,13 @@ exports.renderTemplate = renderTemplate;
// (see https://stackoverflow.com/questions/3147670/ckeditor-update-textarea)
CKEDITOR.instances['instructions'].updateElement();
var unencrypted = document.getElementById('unencrypted_html').value,
const unencrypted = document.getElementById('unencrypted_html').value,
passphrase = document.getElementById('passphrase').value;
var salt = cryptoEngine.generateRandomSalt();
var encryptedMsg = encode(unencrypted, passphrase, salt);
const salt = cryptoEngine.generateRandomSalt();
const encryptedMsg = await encode(unencrypted, passphrase, salt);
var decryptButton = document.getElementById('decrypt_button').value,
const decryptButton = document.getElementById('decrypt_button').value,
instructions = document.getElementById('instructions').value,
isRememberEnabled = document.getElementById('remember').checked,
pageTitle = document.getElementById('title').value.trim(),
@ -701,7 +683,7 @@ exports.renderTemplate = renderTemplate;
rememberDurationInDays = document.getElementById('remember_in_days').value || 0,
rememberMe = document.getElementById('remember_me').value;
var data = {
const data = {
crypto_tag: '<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js" integrity="sha384-lp4k1VRKPU9eBnPePjnJ9M2RF3i7PC30gXs70+elCVfgwLwx1tv5+ctxdtwxqZa7" crossorigin="anonymous"></scr' + 'ipt>',
decrypt_button: decryptButton ? decryptButton : 'DECRYPT',
encrypted: encryptedMsg,
@ -718,11 +700,7 @@ exports.renderTemplate = renderTemplate;
document.getElementById('encrypted_html_display').textContent = encryptedMsg;
if (document.getElementById("embed-crypto").checked) {
setFileToDownloadWithEmbeddedCrypto(data);
} else {
setFileToDownload(data);
}
setFileToDownload(data);
});
document.getElementById('toggle-extra-option')
@ -731,7 +709,7 @@ exports.renderTemplate = renderTemplate;
document.getElementById('extra-options').classList.toggle('hidden');
});
var isConceptShown = false;
let isConceptShown = false;
document.getElementById('toggle-concept')
.addEventListener('click', function (e) {
e.preventDefault();
@ -754,13 +732,13 @@ exports.renderTemplate = renderTemplate;
trackEvent('download_encrypted');
}
var isIE = (navigator.userAgent.indexOf("MSIE") !== -1) || (!!document.documentMode === true); // >= 10
var isEdge = navigator.userAgent.indexOf("Edge") !== -1;
const isIE = (navigator.userAgent.indexOf("MSIE") !== -1) || (!!document.documentMode === true); // >= 10
const isEdge = navigator.userAgent.indexOf("Edge") !== -1;
// download with MS specific feature
if (htmlToDownload && (isIE || isEdge)) {
e.preventDefault();
var blobObject = new Blob([htmlToDownload]);
const blobObject = new Blob([htmlToDownload]);
window.navigator.msSaveOrOpenBlob(blobObject, 'encrypted.html');
}

92
lib/codec-sync.js 100644
Wyświetl plik

@ -0,0 +1,92 @@
/**
* Initialize the codec with the provided cryptoEngine - this return functions to encode and decode messages.
*
* @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 password hashing, encryption, and signing.
*
* @param {string} msg
* @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, 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 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;
}
exports.encode = encode;
/**
* Top-level function for decoding a message.
* 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, 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),
};
}
exports.decode = decode;
return exports;
}
exports.init = init;

Wyświetl plik

@ -4,10 +4,6 @@
* @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 = {};
/**
@ -17,15 +13,11 @@ function init(cryptoEngine) {
* @param {string} msg
* @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
*/
async 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
? await cryptoEngine.hashLegacyRound(password, salt)
: await cryptoEngine.hashPassphrase(password, salt);
async function encode(msg, password, salt) {
const hashedPassphrase = await cryptoEngine.hashPassphrase(password, salt);
const encrypted = await cryptoEngine.encrypt(msg, hashedPassphrase);
@ -43,45 +35,42 @@ function init(cryptoEngine) {
*
* @param {string} signedMsg
* @param {string} hashedPassphrase
* @param {string} backwardCompatibleHashedPassword
* @param {string} salt
* @param {int} backwardCompatibleAttempt
* @param {string} originalPassphrase
*
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
*/
async function decode(signedMsg, hashedPassphrase, backwardCompatibleHashedPassword = '') {
async function decode(
signedMsg,
hashedPassphrase,
salt,
backwardCompatibleAttempt = 0,
originalPassphrase = ''
) {
const encryptedHMAC = signedMsg.substring(0, 64);
const encryptedMsg = signedMsg.substring(64);
const decryptedHMAC = await 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,
await cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
hashedPassphrase
);
// 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.
originalPassphrase = originalPassphrase || hashedPassphrase;
if (backwardCompatibleAttempt === 0) {
const updatedHashedPassphrase = await cryptoEngine.hashThirdRound(originalPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
if (backwardCompatibleAttempt === 1) {
let updatedHashedPassphrase = await cryptoEngine.hashSecondRound(originalPassphrase, salt);
updatedHashedPassphrase = await cryptoEngine.hashThirdRound(updatedHashedPassphrase, salt);
return decode(signedMsg, updatedHashedPassphrase, salt, backwardCompatibleAttempt + 1, originalPassphrase);
}
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: await cryptoEngine.decrypt(encryptedMsg, hashedPassphrase),

Wyświetl plik

@ -0,0 +1,279 @@
const crypto = typeof window === "undefined" ? require("node:crypto").webcrypto : window.crypto;
const { subtle } = crypto;
const IV_BITS = 16 * 8;
const HEX_BITS = 4;
const ENCRYPTION_ALGO = "AES-CBC";
/**
* Translates between utf8 encoded hexadecimal strings
* and Uint8Array bytes.
*
* Mirrors the API of CryptoJS.enc.Hex
*/
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);
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.
*/
const UTF8Encoder = {
parse: function (str) {
return new TextEncoder().encode(str);
},
stringify: function (bytes) {
return new TextDecoder().decode(bytes);
},
};
/**
* Salt and encrypt a msg with a password.
*/
async function encrypt(msg, hashedPassphrase) {
// 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(hashedPassphrase),
ENCRYPTION_ALGO,
false,
["encrypt"]
);
const encrypted = await subtle.encrypt(
{
name: ENCRYPTION_ALGO,
iv: iv,
},
key,
UTF8Encoder.parse(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));
}
exports.encrypt = encrypt;
/**
* Decrypt a salted msg using a password.
*
* @param {string} encryptedMsg
* @param {string} hashedPassphrase
* @returns {Promise<string>}
*/
async function decrypt(encryptedMsg, hashedPassphrase) {
const ivLength = IV_BITS / HEX_BITS;
const iv = HexEncoder.parse(encryptedMsg.substring(0, ivLength));
const encrypted = encryptedMsg.substring(ivLength);
const key = await subtle.importKey(
"raw",
HexEncoder.parse(hashedPassphrase),
ENCRYPTION_ALGO,
false,
["decrypt"]
);
const outBuffer = await subtle.decrypt(
{
name: ENCRYPTION_ALGO,
iv: iv,
},
key,
HexEncoder.parse(encrypted)
);
return UTF8Encoder.stringify(new Uint8Array(outBuffer));
}
exports.decrypt = decrypt;
/**
* Salt and hash the passphrase so it can be stored in localStorage without opening a password reuse vulnerability.
*
* @param {string} passphrase
* @param {string} salt
* @returns {Promise<string>}
*/
async function hashPassphrase(passphrase, salt) {
// we hash the passphrase in multiple steps, each adding more iterations. This is because we used to allow less
// iterations, so for backward compatibility reasons, we need to support going from that to more iterations.
let hashedPassphrase = await hashLegacyRound(passphrase, salt);
hashedPassphrase = await hashSecondRound(hashedPassphrase, salt);
return hashThirdRound(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 {Promise<string>}
*/
function hashLegacyRound(passphrase, salt) {
return pbkdf2(passphrase, salt, 1000, "SHA-1");
}
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 hashedPassphrase
* @param salt
* @returns {Promise<string>}
*/
function hashSecondRound(hashedPassphrase, salt) {
return pbkdf2(hashedPassphrase, salt, 14000, "SHA-256");
}
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 hashedPassphrase
* @param salt
* @returns {Promise<string>}
*/
function hashThirdRound(hashedPassphrase, salt) {
return pbkdf2(hashedPassphrase, salt, 585000, "SHA-256");
}
exports.hashThirdRound = hashThirdRound;
/**
* Salt and hash the passphrase so it can be stored in localStorage without opening a password reuse vulnerability.
*
* @param {string} passphrase
* @param {string} salt
* @param {int} iterations
* @param {string} hashAlgorithm
* @returns {Promise<string>}
*/
async function pbkdf2(passphrase, salt, iterations, hashAlgorithm) {
const key = await subtle.importKey(
"raw",
UTF8Encoder.parse(passphrase),
"PBKDF2",
false,
["deriveBits"]
);
const keyBytes = await subtle.deriveBits(
{
name: "PBKDF2",
hash: hashAlgorithm,
iterations,
salt: UTF8Encoder.parse(salt),
},
key,
256
);
return HexEncoder.stringify(new Uint8Array(keyBytes));
}
function generateRandomSalt() {
const bytes = crypto.getRandomValues(new Uint8Array(128 / 8));
return HexEncoder.stringify(new Uint8Array(bytes));
}
exports.generateRandomSalt = generateRandomSalt;
async function signMessage(hashedPassphrase, message) {
const key = await subtle.importKey(
"raw",
HexEncoder.parse(hashedPassphrase),
{
name: "HMAC",
hash: "SHA-256",
},
false,
["sign"]
);
const signature = await subtle.sign("HMAC", key, UTF8Encoder.parse(message));
return HexEncoder.stringify(new Uint8Array(signature));
}
exports.signMessage = signMessage;
function getRandomAlphanum() {
const possibleCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let byteArray;
let parsedInt;
// Keep generating new random bytes until we get a value that falls
// within a range that can be evenly divided by possibleCharacters.length
do {
byteArray = crypto.getRandomValues(new Uint8Array(1));
// extract the lowest byte to get an int from 0 to 255 (probably unnecessary, since we're only generating 1 byte)
parsedInt = byteArray[0] & 0xff;
} while (parsedInt >= 256 - (256 % possibleCharacters.length));
// Take the modulo of the parsed integer to get a random number between 0 and totalLength - 1
const randomIndex = parsedInt % possibleCharacters.length;
return possibleCharacters[randomIndex];
}
/**
* Generate a random string of a given length.
*
* @param {int} length
* @returns {string}
*/
function generateRandomString(length) {
let randomString = '';
for (let i = 0; i < length; i++) {
randomString += getRandomAlphanum();
}
return randomString;
}
exports.generateRandomString = generateRandomString;

Wyświetl plik

@ -187,6 +187,9 @@
{crypto_tag}
<script>
// do not remove this comment, it is used to detect the version of the script
// STATICRYPT_VERSION: async
const cryptoEngine = {js_crypto_engine}
const codec = {js_codec}
const decode = codec.init(cryptoEngine).decode;
@ -209,7 +212,7 @@
* @returns {Promise<boolean>}
*/
async function decryptAndReplaceHtml(hashedPassphrase) {
const result = await decode(encryptedMsg, hashedPassphrase);
const result = await decode(encryptedMsg, hashedPassphrase, salt);
if (!result.success) {
return false;
}

Wyświetl plik

@ -2,7 +2,7 @@
# Should be run with "npm run build" - npm handles the pathing better (so no "#!/usr/bin/env" bash on top)
# encrypt the example file
npx . example/example.html test \
node cli/index.js example/example.html test \
--engine webcrypto \
--short \
--salt b93bbaf35459951c47721d1f3eaeb5b9 \

Wyświetl plik

@ -42,8 +42,7 @@
<small>Password protect a static HTML page</small>
</h1>
<p>
Based on the <a href="https://github.com/brix/crypto-js">crypto-js library</a>, StatiCrypt uses AES-256
to encrypt your string with your long password in your browser (client side).
StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in your browser (client side).
</p>
<p>
Download your encrypted string in a HTML page with a password prompt you can upload anywhere (see <a
@ -70,6 +69,14 @@
just send or upload the generated page to a place serving static content (github pages, for example)
and you're done: the javascript will prompt users for password, decrypt the page and load your HTML.
</p>
<p>
The page is encrypted with AES-256 in CBC mode (see why this mode is appropriate for StatiCrypt in
<a href="https://github.com/robinmoisson/staticrypt/issues/19">#19</a>). The password is hashed with
PBKDF2 (599k iterations with SHA-256, plus 1k with SHA-1 for legacy reasons (see
<a href="https://github.com/robinmoisson/staticrypt/issues/159">#159</a>), for the added
<a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2">recommended
total</a> of 600k) and used to encrypt the page.
</p>
<p>
It basically encrypts your page and puts everything with a user-friendly way to use a password
in the new file. AES-256 is state of the art but <b>brute-force/dictionary attacks would be easy to
@ -157,19 +164,6 @@
<label for="decrypt_button">Decrypt button label</label>
<input type="text" class="form-control" id="decrypt_button" placeholder="Default: 'DECRYPT'">
</div>
<div class="form-group">
<label class="no-style">
<input type="checkbox" id="embed-crypto" checked>
Embed crypto-js into your file
<small>
<abbr class="text-muted"
title="Leave checked to include crypto-js into your file so you can decrypt it offline. Uncheck to load crypto-js from a CDN.">
(?)
</abbr>
</small>
</label>
</div>
</div>
<button class="btn btn-primary pull-right" type="submit">Generate passphrase protected HTML</button>
@ -210,12 +204,12 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
</script>
<script>
var encode = codec.init(cryptoEngine).encode;
const encode = codec.init(cryptoEngine).encode;
// enable CKEDIRTOR
CKEDITOR.replace('instructions');
var htmlToDownload;
let htmlToDownload;
/**
* Extract js code from <script> tag and return it as a string
@ -223,7 +217,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
* @param {string} id
* @returns {string}
*/
var getScriptAsString = function (id) {
function getScriptAsString(id) {
return document.getElementById(id)
.innerText.replace(/window\.\w+ = /, '');
}
@ -235,7 +229,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
* @param {string} action
*/
function trackEvent(action) {
var xhr = new XMLHttpRequest();
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://zlgpaemmniviswibzuwt.supabase.co/rest/v1/rpc/increment_analytics', true);
xhr.setRequestHeader('Content-type', 'application/json; charset=UTF-8')
xhr.setRequestHeader('apikey', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpsZ3BhZW1tbml2aXN3aWJ6dXd0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjkxMjM0OTcsImV4cCI6MTk4NDY5OTQ5N30.wNoVDHG7F6INx-IPotMs3fL1nudfaF2qvQDgG-1PhNI')
@ -251,34 +245,20 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
* Fill the password prompt template with data provided.
* @param data
*/
var setFileToDownload = function (data) {
var request = new XMLHttpRequest();
function setFileToDownload (data) {
const request = new XMLHttpRequest();
request.open('GET', 'lib/password_template.html', true);
request.onload = function () {
var renderedTmpl = formater.renderTemplate(request.responseText, data);
const renderedTmpl = formater.renderTemplate(request.responseText, data);
var downloadLink = document.querySelector('a.download');
const downloadLink = document.querySelector('a.download');
downloadLink.href = 'data:text/html,' + encodeURIComponent(renderedTmpl);
downloadLink.removeAttribute('disabled');
htmlToDownload = renderedTmpl;
};
request.send();
};
/**
* Download crypto-js lib to embed it in the generated file, update the file when done.
* @param data
*/
var setFileToDownloadWithEmbeddedCrypto = function (data) {
var request = new XMLHttpRequest();
request.open('GET', 'lib/kryptojs-3.1.9-1.min.js', true);
request.onload = function () {
data['crypto_tag'] = '<script>' + request.responseText + '</scr' + 'ipt>';
setFileToDownload(data);
};
request.send();
};
}
// register page load
window.onload = function () {
@ -288,7 +268,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
/**
* Handle form submission.
*/
document.getElementById('encrypt_form').addEventListener('submit', function (e) {
document.getElementById('encrypt_form').addEventListener('submit', async function (e) {
e.preventDefault();
trackEvent('generate_encrypted');
@ -297,13 +277,13 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
// (see https://stackoverflow.com/questions/3147670/ckeditor-update-textarea)
CKEDITOR.instances['instructions'].updateElement();
var unencrypted = document.getElementById('unencrypted_html').value,
const unencrypted = document.getElementById('unencrypted_html').value,
passphrase = document.getElementById('passphrase').value;
var salt = cryptoEngine.generateRandomSalt();
var encryptedMsg = encode(unencrypted, passphrase, salt);
const salt = cryptoEngine.generateRandomSalt();
const encryptedMsg = await encode(unencrypted, passphrase, salt);
var decryptButton = document.getElementById('decrypt_button').value,
const decryptButton = document.getElementById('decrypt_button').value,
instructions = document.getElementById('instructions').value,
isRememberEnabled = document.getElementById('remember').checked,
pageTitle = document.getElementById('title').value.trim(),
@ -311,7 +291,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
rememberDurationInDays = document.getElementById('remember_in_days').value || 0,
rememberMe = document.getElementById('remember_me').value;
var data = {
const data = {
crypto_tag: '<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.9-1/crypto-js.min.js" integrity="sha384-lp4k1VRKPU9eBnPePjnJ9M2RF3i7PC30gXs70+elCVfgwLwx1tv5+ctxdtwxqZa7" crossorigin="anonymous"></scr' + 'ipt>',
decrypt_button: decryptButton ? decryptButton : 'DECRYPT',
encrypted: encryptedMsg,
@ -328,11 +308,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
document.getElementById('encrypted_html_display').textContent = encryptedMsg;
if (document.getElementById("embed-crypto").checked) {
setFileToDownloadWithEmbeddedCrypto(data);
} else {
setFileToDownload(data);
}
setFileToDownload(data);
});
document.getElementById('toggle-extra-option')
@ -341,7 +317,7 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
document.getElementById('extra-options').classList.toggle('hidden');
});
var isConceptShown = false;
let isConceptShown = false;
document.getElementById('toggle-concept')
.addEventListener('click', function (e) {
e.preventDefault();
@ -364,13 +340,13 @@ Filename changed to circumvent adblockers that mistake it for a crypto miner (se
trackEvent('download_encrypted');
}
var isIE = (navigator.userAgent.indexOf("MSIE") !== -1) || (!!document.documentMode === true); // >= 10
var isEdge = navigator.userAgent.indexOf("Edge") !== -1;
const isIE = (navigator.userAgent.indexOf("MSIE") !== -1) || (!!document.documentMode === true); // >= 10
const isEdge = navigator.userAgent.indexOf("Edge") !== -1;
// download with MS specific feature
if (htmlToDownload && (isIE || isEdge)) {
e.preventDefault();
var blobObject = new Blob([htmlToDownload]);
const blobObject = new Blob([htmlToDownload]);
window.navigator.msSaveOrOpenBlob(blobObject, 'encrypted.html');
}