raise PBKDF2 iterations in backward compatible way

pull/160/head
robinmoisson 2023-02-26 11:47:04 +01:00
rodzic e85077f503
commit a90d62ca0b
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 9419716500078583
4 zmienionych plików z 227 dodań i 113 usunięć

Wyświetl plik

@ -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,123 @@ function genFile(data, outputFilePath, inputFilePath) {
exitEarly("Failure: could not generate output file!");
}
}
exports.genFile = genFile;
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 <filename> [<passphrase>] [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: 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 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=<hashed_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",
});
}
exports.parseCommandLineArguments = parseCommandLineArguments;

Wyświetl plik

@ -12,6 +12,7 @@ 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 { isCustomPasswordTemplateLegacy, parseCommandLineArguments} = require("./helpers.js");
const { generateRandomSalt } = cryptoEngine;
const { encode } = codec.init(cryptoEngine);
@ -22,95 +23,8 @@ const SCRIPT_TAG =
SCRIPT_URL +
'" integrity="sha384-lp4k1VRKPU9eBnPePjnJ9M2RF3i7PC30gXs70+elCVfgwLwx1tv5+ctxdtwxqZa7" crossorigin="anonymous"></script>';
const yargs = Yargs.usage("Usage: staticrypt <filename> [<passphrase>] [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=<hashed_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);
}
@ -166,8 +80,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" +
"[StatiCrypt] SECURITY WARNING: 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 the template here: https://github.com/robinmoisson/staticrypt/blob/main/lib/password_template.html" +
"\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 +119,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 +131,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);

Wyświetl plik

@ -4,7 +4,12 @@
* @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.
@ -12,11 +17,15 @@ function init(cryptoEngine) {
* @param {string} msg
* @param {string} passphrase
* @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, passphrase, 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(passphrase, salt)
: cryptoEngine.hashPassphrase(passphrase, 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)
@ -28,19 +37,31 @@ 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 {boolean} shouldTryBackwardCompatible
*
* @returns {Object} {success: true, decoded: string} | {success: false, message: string}
*/
function decode(signedMsg, hashedPassphrase) {
function decode(signedMsg, hashedPassphrase, shouldTryBackwardCompatible = true) {
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 (shouldTryBackwardCompatible) {
return decode(
signedMsg,
cryptoEngine.hashSecondRound(hashedPassphrase, backwardCompatibleSalt),
false
);
}
return { success: false, message: "Signature mismatch" };
}
return {

Wyświetl plik

@ -47,15 +47,48 @@ exports.decrypt = decrypt;
* @returns string
*/
function hashPassphrase(passphrase, salt) {
var hashedPassphrase = CryptoJS.PBKDF2(passphrase, salt, {
keySize: 256 / 32,
iterations: 1000,
});
// 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 hashedPassphrase.toString();
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;
/**
* 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: 50000,
hasher: CryptoJS.algo.SHA256,
}).toString();
}
exports.hashSecondRound = hashSecondRound;
function generateRandomSalt() {
return CryptoJS.lib.WordArray.random(128 / 8).toString();
}