diff --git a/.gitignore b/.gitignore index a10bedf..126023e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ node_modules .env encrypted/ !example/encrypted/ +decrypted/ \ No newline at end of file diff --git a/README.md b/README.md index 19d0c52..be9910c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ You can then run it with `npx staticrypt ...`. You can also install globally wit **Encrypt a file:** encrypt `test.html` and create a `encrypted/test.html` file (use `-d my_directory` to change the output directory): ```bash -staticrypt test.html -p MY_LONG_PASSWORD +staticrypt test.html -p # or do not include the password if you want to be prompted for it: staticrypt test.html @@ -45,22 +45,34 @@ staticrypt test.html **Encrypt multiple files at once** and put them in a `encrypted/` directory: ```bash -# this will encrypt test_A.html, test_B.html and all files in the test/ directory -staticrypt test_A.html test_B.html test/* -p MY_LONG_PASSWORD -# => encrypted files are in encrypted/test_A.html, encrypted/test_B.html, encrypted/test/... +# this will encrypt test_A.html and test_B.html +staticrypt test_A.html test_B.html -p +# => encrypted files are in encrypted/test_A.html and encrypted/test_B.html # you can also use the -r flag to recursively encrypt all files in a directory -staticrypt dir_to_encrypt -p MY_LONG_PASSWORD -r +staticrypt dir_to_encrypt -p -r +# => encrypted files are in encrypted/dir_to_encrypt/... + +# if you don't want to include the directory name in the output path, you can use dir_to_encrypt/* instead +staticrypt dir_to_encrypt/* -p -r +# => encrypted files are in encrypted/... ``` **Encrypt a file and get a shareable link containing the hashed password** - you can include your file URL or leave blank: ```bash # you can also pass '--share' without specifying the URL to get the `#staticrypt_pwd=...` -staticrypt test.html -p MY_LONG_PASSWORD --share https://example.com/encrypted.html +staticrypt test.html -p --share https://example.com/encrypted.html # => https://example.com/encrypted.html#staticrypt_pwd=5bfbf1343c7257cd7be23ecd74bb37fa2c76d041042654f358b6255baeab898f ``` +**Decrypt files you encrypted earlier** straight from the CLI by including the `--decrypt` flag. The `-r|--recursive` flag and output `-d|--directory` option work the same way as when encrypting (default name for the output directory is `decrypted`): + +```bash +staticrypt encrypted/test.html -p --decrypt +# => decrypted file is in decrypted/test.html +``` + **Pin the salt to use staticrypt in your CI in a build step** - if you want want the "Remember-me" or share features to work accross multiple pages or multiple successive deployment, the salt needs to stay the same ([see why](https://github.com/robinmoisson/staticrypt#why-does-staticrypt-create-a-config-file)). If you run StatiCrypt in a CI step, you can pin the salt in two ways: ```bash @@ -69,7 +81,7 @@ staticrypt test.html -p MY_LONG_PASSWORD --share https://example.com/encrypted.h staticrypt --salt # or hardcode the salt in the CI script command: -staticrypt test.html -p MY_LONG_PASSWORD --salt 12345678901234567890123456789012 +staticrypt test.html -p --salt 12345678901234567890123456789012 ``` ### CLI Reference @@ -83,9 +95,12 @@ The password argument is optional if `STATICRYPT_PASSWORD` is set in the environ --version Show version number [boolean] -c, --config Path to the config file. Set to "false" to disable.[string] [default: ".staticrypt.json"] - -d, --directory Name of the directory where the encrypted - files will be saved. - [string] [default: "encrypted/"] + -d, --directory Name of the directory where the generated + files will be saved. If the '--decrypt' flag + is set, default will be 'decrypted'. + [string] [default: "encrypted"] + --decrypt Include this flag to decrypt files instead of + encrypt. [boolean] [default: false] -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 diff --git a/cli/helpers.js b/cli/helpers.js index 27f3397..22148d9 100644 --- a/cli/helpers.js +++ b/cli/helpers.js @@ -1,4 +1,4 @@ -const path = require("path"); +const pathModule = require("path"); const fs = require("fs"); const readline = require("readline"); @@ -6,7 +6,9 @@ const { generateRandomSalt, generateRandomString } = require("../lib/cryptoEngin const { renderTemplate } = require("../lib/formater.js"); const Yargs = require("yargs"); -const PASSWORD_TEMPLATE_DEFAULT_PATH = path.join(__dirname, "..", "lib", "password_template.html"); +const PASSWORD_TEMPLATE_DEFAULT_PATH = pathModule.join(__dirname, "..", "lib", "password_template.html"); +const OUTPUT_DIRECTORY_DEFAULT_PATH = "encrypted"; +exports.OUTPUT_DIRECTORY_DEFAULT_PATH = OUTPUT_DIRECTORY_DEFAULT_PATH; /** * @param {string} message @@ -71,16 +73,19 @@ function prompt(question) { }); } -async function getValidatedPassword(passwordArgument, isShortAllowed) { - const password = await getPassword(passwordArgument); - +/** + * @param {string} password + * @param {boolean} isShortAllowed + * @returns {Promise} + */ +async function validatePassword(password, isShortAllowed) { if (password.length < 14 && !isShortAllowed) { const shouldUseShort = await prompt( `WARNING: Your password is less than 14 characters (length: ${password.length})` + - " and it's easy to try brute-forcing on public files. For better security we recommend using a longer one, for example: " + + " and it's easy to try brute-forcing on public files, so we recommend using a longer one. Here's a generated one: " + generateRandomString(21) + "\nYou can hide this warning by increasing your password length or adding the '--short' flag." + - " Do you want to use the short password? [y/N] " + "\nDo you want to still want to use the shorter password? [y/N] " ); if (!shouldUseShort.match(/^\s*(y|yes)\s*$/i)) { @@ -88,10 +93,8 @@ async function getValidatedPassword(passwordArgument, isShortAllowed) { process.exit(0); } } - - return password; } -exports.getValidatedPassword = getValidatedPassword; +exports.validatePassword = validatePassword; /** * Get the config from the config file. @@ -137,6 +140,7 @@ async function getPassword(passwordArgument) { // prompt the user for their password return prompt("Enter your long, unusual password: "); } +exports.getPassword = getPassword; /** * @param {string} filepath @@ -200,8 +204,8 @@ function getSalt(namedArgs, config) { * @param {string} modulePath - path from staticrypt root directory */ function convertCommonJSToBrowserJS(modulePath) { - const rootDirectory = path.join(__dirname, ".."); - const resolvedPath = path.join(rootDirectory, ...modulePath.split("/")) + ".js"; + const rootDirectory = pathModule.join(__dirname, ".."); + const resolvedPath = pathModule.join(rootDirectory, ...modulePath.split("/")) + ".js"; if (!fs.existsSync(resolvedPath)) { exitWithError(`could not find module to convert at path "${resolvedPath}"`); @@ -262,20 +266,29 @@ function genFile(data, outputFilePath, templateFilePath) { const renderedTemplate = renderTemplate(templateContents, data); + writeFile(outputFilePath, renderedTemplate); +} +exports.genFile = genFile; + +/** + * @param {string} filePath + * @param {string} contents + */ +function writeFile(filePath, contents) { // create output directory if it does not exist - const dirname = path.dirname(outputFilePath); + const dirname = pathModule.dirname(filePath); if (!fs.existsSync(dirname)) { fs.mkdirSync(dirname, { recursive: true }); } try { - fs.writeFileSync(outputFilePath, renderedTemplate); + fs.writeFileSync(filePath, contents); } catch (e) { console.error(e); - exitWithError("could not generate output file"); + exitWithError(`could not write file at path "${filePath}"`); } } -exports.genFile = genFile; +exports.writeFile = writeFile; /** * @param {string} templatePathParameter @@ -287,6 +300,28 @@ function isCustomPasswordTemplateDefault(templatePathParameter) { } exports.isCustomPasswordTemplateDefault = isCustomPasswordTemplateDefault; +/** + * @param {string} path + * @param {string} rootDirectory + * @param {(fullPath: string, rootDirectoryFromArgument: string) => void} callback + */ +function recursivelyApplyCallbackToFiles(callback, path, rootDirectory = "") { + const fullPath = pathModule.resolve(path); + const fullRootDirectory = rootDirectory || pathModule.dirname(fullPath); + + if (fs.statSync(fullPath).isDirectory()) { + fs.readdirSync(fullPath).forEach((filePath) => { + const fullFilePath = `${fullPath}/${filePath}`; + + recursivelyApplyCallbackToFiles(callback, fullFilePath, fullRootDirectory); + }); + return; + } + + callback(fullPath, fullRootDirectory); +} +exports.recursivelyApplyCallbackToFiles = recursivelyApplyCallbackToFiles; + function parseCommandLineArguments() { return ( Yargs.usage("Usage: staticrypt [ ...] [options]") @@ -299,8 +334,15 @@ function parseCommandLineArguments() { .option("d", { alias: "directory", type: "string", - describe: "Name of the directory where the encrypted files will be saved.", - default: "encrypted/", + describe: + "Name of the directory where the generated files will be saved. If the '--decrypt' flag is " + + "set, default will be 'decrypted'.", + default: OUTPUT_DIRECTORY_DEFAULT_PATH, + }) + .option("decrypt", { + type: "boolean", + describe: "Include this flag to decrypt files instead of encrypt.", + default: false, }) .option("p", { alias: "password", diff --git a/cli/index.js b/cli/index.js index 0de6622..08f07fa 100755 --- a/cli/index.js +++ b/cli/index.js @@ -12,22 +12,28 @@ if (nodeVersion[0] < 16) { // parse .env file into process.env require("dotenv").config(); +const pathModule = require("path"); const fs = require("fs"); const cryptoEngine = require("../lib/cryptoEngine.js"); const codec = require("../lib/codec.js"); const { generateRandomSalt } = cryptoEngine; -const { encodeWithHashedPassword } = codec.init(cryptoEngine); +const { decode, encodeWithHashedPassword } = codec.init(cryptoEngine); const { - parseCommandLineArguments, + OUTPUT_DIRECTORY_DEFAULT_PATH, buildStaticryptJS, - isOptionSetByUser, + exitWithError, genFile, - getFileContent, - getValidatedSalt, - getValidatedPassword, getConfig, + getFileContent, + getPassword, + getValidatedSalt, + isOptionSetByUser, + parseCommandLineArguments, + recursivelyApplyCallbackToFiles, + validatePassword, writeConfig, + writeFile, } = require("./helpers.js"); // parse arguments @@ -67,23 +73,49 @@ async function runStatiCrypt() { writeConfig(configPath, config); } - process.exit(0); + return; } // get the salt & password const salt = getValidatedSalt(namedArgs, config); - const password = await getValidatedPassword(namedArgs.password, namedArgs.short); + const password = await getPassword(namedArgs.password); + const hashedPassword = await cryptoEngine.hashPassword(password, salt); // display the share link with the hashed password if the --share flag is set if (hasShareFlag) { + await validatePassword(password, namedArgs.short); + const url = namedArgs.share || ""; - const hashedPassword = await cryptoEngine.hashPassword(password, salt); - console.log(url + "#staticrypt_pwd=" + hashedPassword); - process.exit(0); + return; } + // only process a directory if the --recursive flag is set + const directoriesInArguments = positionalArguments.filter((path) => fs.statSync(path).isDirectory()); + if (directoriesInArguments.length > 0 && !namedArgs.recursive) { + exitWithError( + `'${directoriesInArguments[0].toString()}' is a directory. Use the -r|--recursive flag to process directories.` + ); + } + + // if asking for decryption, decrypt all the files + if (namedArgs.decrypt) { + const isOutputDirectoryDefault = + namedArgs.directory === OUTPUT_DIRECTORY_DEFAULT_PATH && !isOptionSetByUser("d", yargs); + const outputDirectory = isOutputDirectoryDefault ? "decrypted" : namedArgs.directory; + + positionalArguments.forEach((path) => { + recursivelyApplyCallbackToFiles((fullPath, fullRootDirectory) => { + decodeAndGenerateFile(fullPath, fullRootDirectory, hashedPassword, salt, outputDirectory); + }, path); + }); + + return; + } + + await validatePassword(password, namedArgs.short); + // write salt to config file if (config.salt !== salt) { config.salt = salt; @@ -105,35 +137,55 @@ async function runStatiCrypt() { template_color_secondary: namedArgs.templateColorSecondary, }; - const hashedPassword = await cryptoEngine.hashPassword(password, salt); - - positionalArguments.forEach((path) => - encodeAndGenerateFile(path.toString(), hashedPassword, salt, baseTemplateData, isRememberEnabled, namedArgs) - ); + // encode all the files + positionalArguments.forEach((path) => { + recursivelyApplyCallbackToFiles((fullPath, fullRootDirectory) => { + encodeAndGenerateFile( + fullPath, + fullRootDirectory, + hashedPassword, + salt, + baseTemplateData, + isRememberEnabled, + namedArgs + ); + }, path); + }); } -async function encodeAndGenerateFile(path, hashedPassword, salt, baseTemplateData, isRememberEnabled, namedArgs) { - // if the path is a directory, get into it and process all files - if (fs.statSync(path).isDirectory()) { - if (!namedArgs.recursive) { - console.log( - "ERROR: The path '" + - path + - "' is a directory. Use the -r|--recursive flag to process all files in the directory." - ); +async function decodeAndGenerateFile(path, rootDirectoryFromArguments, hashedPassword, salt, outputDirectory) { + // get the file content + const encryptedFileContent = getFileContent(path); - // just return instead of exiting the process, that way all other files can be processed - return; - } + // extract the cipher text from the encrypted file + const cipherTextMatch = encryptedFileContent.match(/"staticryptEncryptedMsgUniqueVariableName":\s*"([^"]+)"/); - fs.readdirSync(path).forEach((filePath) => { - const fullPath = `${path}/${filePath}`; - - encodeAndGenerateFile(fullPath, hashedPassword, salt, baseTemplateData, isRememberEnabled, namedArgs); - }); - return; + if (!cipherTextMatch) { + return console.log(`ERROR: could not extract cipher text from ${path}`); } + // decrypt input + const { success, decoded } = await decode(cipherTextMatch[1], hashedPassword, salt); + + if (!success) { + return console.log(`ERROR: could not decrypt ${path}`); + } + + const relativePath = pathModule.relative(rootDirectoryFromArguments, path); + const outputFilepath = outputDirectory + "/" + relativePath; + + writeFile(outputFilepath, decoded); +} + +async function encodeAndGenerateFile( + path, + rootDirectoryFromArguments, + hashedPassword, + salt, + baseTemplateData, + isRememberEnabled, + namedArgs +) { // get the file content const contents = getFileContent(path); @@ -141,7 +193,7 @@ async function encodeAndGenerateFile(path, hashedPassword, salt, baseTemplateDat const encryptedMsg = await encodeWithHashedPassword(contents, hashedPassword); const staticryptConfig = { - encryptedMsg, + staticryptEncryptedMsgUniqueVariableName: encryptedMsg, isRememberEnabled, rememberDurationInDays: namedArgs.remember, salt, @@ -151,7 +203,9 @@ async function encodeAndGenerateFile(path, hashedPassword, salt, baseTemplateDat staticrypt_config: staticryptConfig, }; - const outputFilepath = namedArgs.directory.replace(/\/+$/, "") + "/" + path; + // remove the base path so that the actual output path is relative to the base path + const relativePath = pathModule.relative(rootDirectoryFromArguments, path); + const outputFilepath = namedArgs.directory + "/" + relativePath; genFile(templateData, outputFilepath, namedArgs.template); } diff --git a/lib/staticryptJs.js b/lib/staticryptJs.js index 0a1115f..61e40cd 100644 --- a/lib/staticryptJs.js +++ b/lib/staticryptJs.js @@ -6,7 +6,7 @@ const decode = codec.init(cryptoEngine).decode; * Initialize the staticrypt module, that exposes functions callbable by the password_template. * * @param {{ - * encryptedMsg: string, + * staticryptEncryptedMsgUniqueVariableName: string, * isRememberEnabled: boolean, * rememberDurationInDays: number, * salt: string, @@ -29,10 +29,10 @@ function init(staticryptConfig, templateConfig) { * @returns {Promise} */ async function decryptAndReplaceHtml(hashedPassword) { - const { encryptedMsg, salt } = staticryptConfig; + const { staticryptEncryptedMsgUniqueVariableName, salt } = staticryptConfig; const { replaceHtmlCallback } = templateConfig; - const result = await decode(encryptedMsg, hashedPassword, salt); + const result = await decode(staticryptEncryptedMsgUniqueVariableName, hashedPassword, salt); if (!result.success) { return false; } diff --git a/package-lock.json b/package-lock.json index 79412c5..6e51a5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "staticrypt", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "staticrypt", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "dependencies": { "dotenv": "^16.0.3", @@ -15,6 +15,9 @@ "bin": { "staticrypt": "cli/index.js" }, + "devDependencies": { + "prettier": "^2.8.7" + }, "engines": { "node": ">=16.0.0" } @@ -104,6 +107,21 @@ "node": ">=8" } }, + "node_modules/prettier": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -248,6 +266,12 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" }, + "prettier": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.7.tgz", + "integrity": "sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==", + "dev": true + }, "require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", diff --git a/package.json b/package.json index aa362c0..33dc478 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "staticrypt", - "version": "3.2.0", + "version": "3.3.0", "description": "Based on the [crypto-js](https://github.com/brix/crypto-js) library, StatiCrypt uses AES-256 to encrypt your input with your long password and put it in a HTML file with a password prompt that can decrypted in-browser (client side).", "main": "index.js", "files": [ @@ -43,5 +43,8 @@ "bugs": { "url": "https://github.com/robinmoisson/staticrypt/issues" }, - "homepage": "https://github.com/robinmoisson/staticrypt" + "homepage": "https://github.com/robinmoisson/staticrypt", + "devDependencies": { + "prettier": "^2.8.7" + } }