Repository: robinmoisson/staticrypt Branch: main Commit: f7266b074054 Files: 26 Total size: 204.3 KB Directory structure: gitextract_fsb2jzk4/ ├── .github/ │ ├── FUNDING.yml │ └── ISSUE_TEMPLATE/ │ ├── bug_report.md │ └── new-issue.md ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MIGRATING.md ├── README.md ├── SECURITY.md ├── cli/ │ ├── helpers.js │ └── index.js ├── example/ │ ├── encrypted/ │ │ └── example.html │ └── example.html ├── index.html ├── lib/ │ ├── codec.js │ ├── cryptoEngine.js │ ├── formater.js │ ├── password_template.html │ └── staticryptJs.js ├── package.json └── scripts/ ├── build.sh ├── buildIndex.js └── index_template.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: robinmoisson patreon: # Replace with a single Patreon username open_collective: # Replace with a single Open Collective username ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Open a bug report title: '' labels: bug assignees: '' --- I'm trying to... ### What's happening When I do ... then this happens: ... ### What should be happening I expect this would happen instead: ... ### My setup StatiCrypt version: Node version: Any additional relevant context, if any: ================================================ FILE: .github/ISSUE_TEMPLATE/new-issue.md ================================================ --- name: New issue about: Any issue that isn't just a bug report title: '' labels: '' assignees: '' --- ================================================ FILE: .gitignore ================================================ .idea .vscode/ node_modules .staticrypt.json .env encrypted/ !example/encrypted/ decrypted/ test/ ================================================ FILE: .husky/pre-commit ================================================ npx lint-staged ================================================ FILE: .prettierignore ================================================ node_modules example/encrypted package-lock.json index.html ================================================ FILE: .prettierrc.json ================================================ { "printWidth": 120, "semi": true, "singleQuote": false, "tabWidth": 4, "trailingComma": "es5", "overrides": [ { "files": "*.{js,html}", "options": { "proseWrap": "preserve" } } ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ The code of conduct is here to set the tone of contributions to StatiCrypt, help turn difficult situations into growth, and make our little corner of the internet a nice and healthy one. 🌱 1. **Assume good intentions** English might not be everyone's first language and nuances might be lost. Don't use sarcasm or assume others are. Things that look trivial or inept to you might be genuinely important for someone else. 2. **Be constructive** Don't just use dismissive comments ("this sucks", "this is obviously wrong"). Try to stay as factual and concrete as possible ("this will cause problem X if Y happens"), make "I" statement ("my experience is...") and remember everything is a trade-off (though of course, some trade-offs are much better suited than others for this project). 3. **Be kind** We all have a limited time being alive, and we all die in the end. Let's not waste energy on things that don't matter. Let's be kind to each other. I wanna add "Be patient" as this is a side project for everyone and we all have busy lives on the side, but I feel I'm by far the slowest one to respond here so you guys would be the only ones needing to apply it... Let's say it's included in the "Be Kind" rule and thanks for your patience anyway - I really appreciate it! ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2017 Robin Moisson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MIGRATING.md ================================================ # Migration guide ## From 2.x to 3.x StatiCrypt 3.x brings a number of improvements: strong default security with WebCrypto, cleaner CLI options and a much simpler `password_template`. This has been done while preserving auto-decrypt "share" links and remember-me functionality: if you used those with StatiCrypt 2.x, your links will still work with 3.x and you'll still be logged in. There are a few breaking changes, but they should be easy to fix. If you have any trouble, feel free to open an issue. ### Breaking changes 3.x works with WebCrypto exclusively, which is only available on HTTPS and localhost. If you need access to the file over HTTP, you'll need to stay on 2.x. The minimum node version is now 16. If you need to stay on a lower number, you'll need to stay on 2.x and use the cryptoJS engine. #### The CLI When encrypting `secret.html`, the CLI will now create a folder with your encrypted file `encrypted/secret.html`. It will not create a `secret_encrypted.html` file anymore. Passwords shorter than 14 characters used to trigger a warning, now they trigger a blocking promp ("Do you want to use that password [yn]"). Add `--short` to hide that prompt. The options and parameters have been changed: - all template related options have been renamed to `--template-*`: pick your file with `--template`, set title with `--template-title`, etc. - the password is now an optional argument: set with `-p `, or leave blank to be prompted for it. - many other options have been renamed, refer to the help (`--help`) or documentation for the full reference. #### The password template If you don't use a custom password template, you don't need to do anything. If you do, you need to update your template. To do so: - get `lib/password_template.html` - replace the javascript part from this file in your custom template (the new template is logic is much simpler) - update the injected variables in your template (notice we use new template tags, they now are `/*[|variable|]*/0` instead of `{variable}`): - `{title}` => `/*[|template_title|]*/0` - `{instructions}` => `/*[|template_instructions|]*/0` - `{remember_me}` => `/*[|template_remember|]*/0` - `{passphrase_placeholder}` => `/*[|template_placeholder|]*/0` - `{decrypt_button}` => `/*[|template_button|]*/0` ================================================ FILE: README.md ================================================

password prompt preview
live example

# StatiCrypt Safely encrypt and password protect the content of your _public_ static HTML file, to be decrypted in-browser without any back-end - to serve it over static hosting like Netlify, GitHub pages, etc. (see [a live example](https://robinmoisson.github.io/staticrypt/example/encrypted/example.html)). StatiCrypt uses AES-256 and WebCrypto to encrypt your HTML file with your long password, and returns a static HTML page showing a password prompt that you can now safely upload anywhere, the page containing your encrypted content and decryption happening in javascript client side (see the details of [how it works](#how-staticrypt-works)). 👉️ You can encrypt a file online in your browser (client side) at [robinmoisson.github.io/staticrypt](https://robinmoisson.github.io/staticrypt), or use the CLI to do it in your terminal or build process. > 🌱 **Supporting:** If you want to support StatiCrypt development you can do so by clicking on the sponsor button (you can also [come study meditation](https://robinmoisson.com/en/) with me, or use my other tool to [translate books](https://translateabook.com)). See [how donations are used](https://github.com/sponsors/robinmoisson). Thank you for your support! > > Sponsor ## CLI **Migration:** v3 brings many improvements, a clearer CLI and simpler `password_template` over v2. See the [migration guide from v2 to v3](MIGRATING.md). v3 uses WebCrypto which is only available in HTTPS or localhost contexts, so if you need to use it in HTTP you'll need to use v2. ### Installation Staticrypt is available through npm as a CLI, install with ```bash npm install staticrypt ``` You can then run it with `npx staticrypt ...`. You can also install globally with `npm install -g staticrypt` and then just call `staticrypt ...` from anywhere. ### Examples > These examples will create a `.staticrypt.json` file in the current directory ([here's why](#why-does-staticrypt-create-a-config-file)). This file isn't secret and you don't need to protect it. You can prevent this by setting the `--config` flag to `false` (a string). #### Encrypt a file Encrypt `test.html` and create a `encrypted/test.html` file (use `-d my_directory` to change the output directory): ```bash # this will prompt you for the password, which won't stay in your terminal command history staticrypt test.html # you can also pass the password as an argument staticrypt test.html -p ``` #### Encrypt a file with the password in an environment variable Set your long password in the `STATICRYPT_PASSWORD` environment variable ([`.env` files](https://www.npmjs.com/package/dotenv#usage) are supported): ```bash # the password is in the STATICRYPT_PASSWORD env variable, you won't be prompted staticrypt test.html ``` #### Encrypt multiple HTML files at once This will put the HTML files in an `encrypted` directory, created where you run the `staticrypt` command. Non-HTML files will be copied as-is from the input directory, so you can easily overwrite it with the encrypted directory if you want. ```bash # this will encrypt test_A.html and test_B.html staticrypt test_A.html test_B.html # => 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 -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. `-r` will include potential subdirectories as well staticrypt dir_to_encrypt/* -r # => encrypted files are in encrypted/... ``` #### Replace all the files in a folder with encrypted ones ```bash # 'dir_to_encrypt/*' as argument will select all the files in the directory ('-r' recursively), # and the '-d dir_to_encrypt' will put them in the same directory, overwriting the files staticrypt dir_to_encrypt/* -r -d dir_to_encrypt ``` #### Get a shareable auto-decrypt link The link contains the hashed password, that will auto-decrypt the file - you can include your file URL or leave blank. (⚠️ you should keep your `.staticrypt.json` so the salt is the same each time you encrypt, or re-encrypting will [invalidate the link](#why-does-staticrypt-create-a-config-file)): ```bash # you can also pass '--share' without specifying the URL to get the `#staticrypt_pwd=...` staticrypt test.html --share https://example.com/encrypted.html # => https://example.com/encrypted.html#staticrypt_pwd=5bfbf1343c7257cd7be23ecd74bb37fa2c76d041042654f358b6255baeab898f # add --share-remember to auto-enable "Remember-me" - useful if you want send one link to # autodecrypt multiple pages (you can also just append '&remember_me') staticrypt test.html --share --share-remember # => #staticrypt_pwd=5bfbf1343c7257cd7be23ecd74bb37fa2c76d041042654f358b6255baeab898f&remember_me ``` #### Pin the salt to use staticrypt in your CI or 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: - either commit the `.staticrypt.json` config file - you can generate a random salt and config file on your local machine with: ```bash staticrypt --salt ``` - or hardcode the salt in the encryption command in the CI script: ```bash staticrypt test.html --salt 12345678901234567890123456789012 ``` > See an example of how to use StatiCrypt in a CI build step in this community project: [a-nau/password-protected-website-template](https://github.com/a-nau/password-protected-website-template) #### Customize the password prompt Customize the HTML to have the encrypted page match your style (see [the FAQ](#can-i-customize-the-password-prompt) for a full custom template): ```bash # use your own custom template staticrypt test.html -t my/own/password_template.html # or customize the default template staticrypt test.html \ --template-color-primary "#fd45a4" \ --template-title "My custom title" \ --template-instructions "To unlock this file, you should..." \ # ... ``` #### Decrypt files from the CLI Decrypt files you encrypted earlier with StatiCrypt straight from the CLI by including the `--decrypt` flag. (So if you want, you can keep only the encrypted files.) 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 --decrypt # => decrypted file is in decrypted/test.html ``` ### CLI Reference The password argument is optional if `STATICRYPT_PASSWORD` is set in the environment or `.env` file. Usage: staticrypt [ ...] [options] Options: --help Show help [boolean] --version Show version number [boolean] -c, --config Path to the config file. Set to "false" to disable.[string] [default: ".staticrypt.json"] -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 use that instead. [string] [default: null] -r, --recursive Whether to recursively encrypt the input directory. [boolean] [default: false] --remember Integer: expiration in days of the "Remember me" checkbox that will save the (salted + hashed) password in localStorage when entered by the user. Set to "false" to hide the box. Default: "0", no expiration. [default: 0] -s, --salt Generate a config file or set the salt manually. Pass a 32-character-long hexadecimal string to use as salt, or leave empty to generate, display and save to config a random salt. This won't overwrite an existing config file. [string] --share Get a link containing your hashed password that will auto-decrypt the page. Pass your URL as a value to append "#staticrypt_pwd=", or leave empty to display the hash to append. [string] --share-remember Whether the share link should auto-enable 'Remember-me'. [boolean] [default: false] --short Hide the "short password" warning. [boolean] [default: false] -t, --template Path to custom HTML template with password prompt. [string] [default: "/code/staticrypt/lib/password_template.html"] --template-button Label to use for the decrypt button. Default: "DECRYPT". [string] [default: "DECRYPT"] --template-color-primary Primary color (button...) [string] [default: "#4CAF50"] --template-color-secondary Secondary color (page background...) [string] [default: "#76B852"] --template-instructions Special instructions to display to the user. [string] [default: ""] --template-error Error message to display on entering wrong password. [string] [default: "Bad password!"] --template-placeholder Placeholder to use for the password input. [string] [default: "Password"] --template-remember Label to use for the "Remember me" checkbox. [string] [default: "Remember me"] --template-title Title for the output HTML page. [string] [default: "Protected Page"] --template-toggle-hide Alt text for toggling password visibility - "hide" action. [string] [default: "Hide password"] --template-toggle-show Alt text for toggling password visibility - "show" action. [string] [default: "Show password"] ## HOW STATICRYPT WORKS So, how can you password protect html without a back-end? StatiCrypt uses 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 enter the password in the new file. ## FAQ ### Is it secure? Simple answer: your file content has been encrypted with AES-256, a popular and strong encryption algorithm. You can now upload it to any public place and no one will be able to read it without the password. So if you used a long, strong password, then yes it should be secure. Longer answer: actual security depends on a number of factors and on the threat model you want to protect against. Because your full encrypted file is accessible client side, brute-force/dictionary attacks would be easy to do at a fast pace: **use a long, unusual password**. We recommend 16+ alphanum characters, [Bitwarden](https://bitwarden.com/) is a great open-source password manager if you don't have one already. On the technical aspects: we use AES in CBC mode (see a discussion on why this mode is appropriate for StatiCrypt in [#19](https://github.com/robinmoisson/staticrypt/issues/19)) and key stretching with 600k PBKDF2-SHA256 iterations to slow down brute-force attacks (which is the [recommended number](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2) by OWASP - read a detailed report on why this number and the security model of StatiCrypt in [#159](https://github.com/robinmoisson/staticrypt/issues/159)). **Transparency disclaimer:** I am not a cryptographer. I try my best to get the implementation right, listen to feedback and be transparent in stewarding StatiCrypt. But please adjust accordingly depending on your threat model: if you are an at-risk activist or have very sensitive crypto assets to protect, you might want to use something else. ### Can I customize the password prompt? Yes! Just copy `lib/password_template.html`, modify it to suit your style and point to your template file with the `-t path/to/my/file.html` flag. Be careful to not break the encrypting javascript part, the variables replaced by StatiCrypt are in this format: `/*[|variable|]*/0`. Don't leave out the `0` at the end, this weird syntax is to avoid conflict with other templating engines while still being read as valid JS to parsers so we can use auto-formatting on the template files. Prettier will add a space before the `0` if the variable is in a JS section - this isn't very pretty but it won't break the templating engine so don't worry about it (if you want to add a prettier plugin that will avoid this behavior, feel free to open a PR!). ### Can I support multiple users with different passwords? At the moment you can only use one passsword per page (though there is a reflection on supporting decryption with multiple different passwords in [#158](https://github.com/robinmoisson/staticrypt/issues/158)). If you want to support multiple users so you can invalidate passwords individualy, the current recommended way is the following: - Make a script that will encrypt your files with different passwords and different output folders ``` staticrypt test.html -p -d john ... ``` - send each user the link to their folder with their password: `https://example.com/john/test.html` In a way, the username input becomes the folder in the `https://example.com/` URL, and the password input is the HTML form. You can then invalidate a single password by changing it in your script and running it again. ### Why doesn't StatiCrypt work in HTTP? From version 3.x StatiCrypt only uses the browser WebCrypto API, which makes it more secure but is only available in HTTPS or on localhost. If you need to use it in HTTP, you can use version 2.x which offers the CryptoJS engine as an option, and will work everywhere. ### Why does StatiCrypt create a config file? The "Remember me" feature stores the user password hashed and salted in the browser's localStorage, so it needs the salt to be the same each time you encrypt otherwise the user would be logged out when you encrypt the page again. The config file is a way to store the salt in between runs, so you don't have to remember it and pass it manually. When deciding what salt to use, StatiCrypt will first look for a `--salt` flag, then try to get the salt from the config file, and if it still doesn't find a salt it will generate a random one. It then saves the salt in the config file. If you don't want StatiCrypt to create or use the config file, you can set `--config false` to disable it. The salt isn't secret (it's publicly visible on the encrypted file), so you don't need to worry about hiding the config file. If you're encrypting as part of a CI step, you can commit the `.staticrypt.json` file so it's accessible to your build server. ### How does the "Remember me" checkbox work? The CLI will add a "Remember me" checkbox on the password prompt by default (`--remember false` to disable). If the user checks it, the (salted + hashed) password will be stored in their browser's localStorage and the page will attempt to auto-decrypt when they come back. If no value is provided the stored password doesn't expire, you can also give it a value in days for how long should the store value be kept with `--remember NUMBER_OF_DAYS`. If the user reconnects to the page after the expiration date the stored value will be cleared. #### "Logging out" You can clear StatiCrypt values in localStorage (effectively "logging out") at any time by appending `staticrypt_logout` to the URL fragment (`https://mysite.com#staticrypt_logout`). #### Encrypting multiple pages This allows encrypting multiple page on a single domain with the same password: if you check "Remember me", you'll have to enter your password once then all the pages on that domain will automatically decrypt their content. Because the hashed value is stored in the browser's localStorage, this will only work if all the pages are on the same domain name. #### Is the "Remember me" checkbox secure? In case the value stored in the browser becomes compromised an attacker can decrypt the page, but because it's stored salted and hashed this should still protect against password reuse attacks if you've used the password on other websites (of course, please use a long, unique password nonetheless). #### Can I remove the "Remember me" checkbox? If you don't want the checkbox to be included, you can set the `--remember false` flag to disable it. ## Contributing ### 🙏 Thank you! - [@AaronCoplan](https://github.com/AaronCoplan) for bringing the CLI to life - [@epicfaace](https://github.com/epicfaace) & [@thomasmarr](https://github.com/thomasmarr) for sparking the caching of the password in localStorage, allowing the "Remember me" checkbox - [@hurrymaplelad](https://github.com/hurrymaplelad) for refactoring a lot of the code and making the project much more pleasant to work with - [@hurrymaplelad](https://github.com/hurrymaplelad) and [@tarpdalton](https://github.com/tarpdalton) for their work in bringing WebCrypto to StatiCrypt ### Opening PRs and issues I administer the project when I have time and motivation. You're free to open PRs if you're ok with having no response for a (possibly very) long time and me possibly ending up getting inspiration from your proposal but merging something different myself (I'll try to credit you though). Apologies in advance for the delay, and thank you for making the project better! Opening issues with suggestions and bug reports is welcome. If you find a serious security bug please open an issue or contact me following the instructions in [SECURITY.md](SECURITY.md) and I'll try to fix it relatively quickly. ### Security You can find the security policy and secure contact details in [SECURITY.md](SECURITY.md). If you have general ideas or feedback around the implementation or StatiCrypt security model they are very welcome, if it's not extra sensitive feel free to open an issue. A couple of place where security was discussed previously are [#19](https://github.com/robinmoisson/staticrypt/issues/19) and [#159](https://github.com/robinmoisson/staticrypt/issues/159). ### Guidelines to contributing #### Source map - `cli/` - The command-line interface published to NPM. - `example/` - Example encrypted files, used as an example in the public website and for manual testing. - `lib/` - Files shared across www and cli. - `scripts/` - Convenient scripts for building the project. - `index.html` - The root of the in-browser encryption site hosted at https://robinmoisson.github.io/staticrypt. Kept in the root of the repo for easy deploys to GitHub Pages. #### Build When editing StatiCrypt logic, we want to sync the changes to the browser version, the CLI and the example files, so all of them use the new logic. To do so, run: ``` npm install npm run build ``` #### Test The testing is done manually for now - you can run [build](#build), then open `example/encrypted/example.html` and check everything works correctly. There is an open issue to automate this in [#136](https://github.com/robinmoisson/staticrypt/issues/136), feel free to contribute to setting up a test framework if you'd like! ## Community and alternatives Here are some other projects and community resources you might find interesting. **This is included as an informative section only, I haven't personally vetted any of those.** If you have a StatiCrypt project you'd like to share, feel free to open an issue describing it - I'll probably be happy to add it to the list! ### Based on StatiCrypt, tutorials and projects **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. ### Alternatives to StatiCrypt (I haven't vetted any of those beyond a quick look, use at your own discretion) - [mprimi/portable-secret](https://github.com/mprimi/portable-secret) is a similar project that supports files and looks awesome - [dividuum/html-vault](https://github.com/dividuum/html-vault) is a similar project which aims as being as tiny as possible to be really easy to audit - [MaxLaumeister/PageCrypt](https://github.com/MaxLaumeister/PageCrypt) is a project with similar features in a different style (I think it was created before StatiCrypt). - [sowbug/quaid](https://github.com/sowbug/quaid) was described by its author as "Similar (but also for a narrower use case with fewer features). It wraps a JS implementation of only the decryption side of GPG symmetric encryption, so there's less opportunity for the tool itself to introduce security errors." - [Izumiko/hugo-encrypt](https://github.com/Izumiko/hugo-encrypt) is a go tool to encrypt Hugo pages with good documentation that allows you to do similar things. ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions These versions will receive security updates: | Version | Security updates | |---------|------------------| | 3.x | ✅ Yes | | 2.x | ✅ Yes | | 1.x | ❌ No | When a version stops being supported, the last update will be to display a warning when running the command. ## Reporting a Vulnerability If you'd like to warn me privately of a vulnerabilty, you can write me at robin.moisson on my protonmail.com address. I am attaching my GPG key at the end of this document, though it's not mandatory to use it. If it's a more of a suggestion, or something that can be discussed openly without negative impact, you can also open an issue. Discussions on the security model and choices of StatiCrypt are very welcome! In case of a severe vulnerability I'll try to get back to you quickly to acknowledge I got the message, and to get a fix out relatively quickly as well. (I am sometimes offline for a few weeks at a time but that's pretty rare.) And finally if you're reporting anything security related: thank you very much for making StatiCrypt safer, your benevolence is very much appreciated! 🙏 ## Public key My public key: ``` -----BEGIN PGP PUBLIC KEY BLOCK----- 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 m4zPXCE1QrrgPtbBL6GXiE5z =Ab3R -----END PGP PUBLIC KEY BLOCK----- ``` ================================================ FILE: cli/helpers.js ================================================ const pathModule = require("path"); const fs = require("fs"); const readline = require("readline"); const { generateRandomSalt, generateRandomString } = require("../lib/cryptoEngine.js"); const { renderTemplate } = require("../lib/formater.js"); const Yargs = require("yargs"); 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 */ function exitWithError(message) { console.log("ERROR: " + message); process.exit(1); } exports.exitWithError = exitWithError; /** * Check if a particular option has been set by the user. Useful for distinguishing default value with flag without * parameter. * * Ex use case: '-s' means "give me a salt", '-s 1234' means "use 1234 as salt" * * From https://github.com/yargs/yargs/issues/513#issuecomment-221412008 * * @param {string} option * @param yargs * @returns {boolean} */ function isOptionSetByUser(option, yargs) { function searchForOption(option) { return process.argv.indexOf(option) > -1; } if (searchForOption(`-${option}`) || searchForOption(`--${option}`)) { return true; } // Handle aliases for same option for (let aliasIndex in yargs.parsed.aliases[option]) { const alias = yargs.parsed.aliases[option][aliasIndex]; if (searchForOption(`-${alias}`) || searchForOption(`--${alias}`)) { return true; } } return false; } exports.isOptionSetByUser = isOptionSetByUser; /** * Prompts the user for input on the CLI. * * @param {string} question * @returns {Promise} */ function prompt(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { return rl.question(question, (answer) => { rl.close(); return resolve(answer); }); }); } /** * @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, 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." + "\nDo you want to still want to use the shorter password? [y/N] " ); if (!shouldUseShort.match(/^\s*(y|yes)\s*$/i)) { console.log("Aborting."); process.exit(0); } } } exports.validatePassword = validatePassword; /** * Get the config from the config file. * * @param {string|null} configPath * @returns {{}|object} */ function getConfig(configPath) { if (configPath && fs.existsSync(configPath)) { return JSON.parse(fs.readFileSync(configPath, "utf8")); } return {}; } exports.getConfig = getConfig; function writeConfig(configPath, config) { if (configPath) { fs.writeFileSync(configPath, JSON.stringify(config, null, 4)); } } exports.writeConfig = writeConfig; /** * Get the password from the command arguments or environment variables. * * @param {string} passwordArgument - password from the command line * @returns {Promise} */ async function getPassword(passwordArgument) { // try to get the password from the environment variable const envPassword = process.env.STATICRYPT_PASSWORD; const hasEnvPassword = envPassword !== undefined && envPassword !== ""; if (hasEnvPassword) { return envPassword; } // try to get the password from the command line arguments if (passwordArgument !== null) { return passwordArgument; } // prompt the user for their password return prompt("Enter your long, unusual password: "); } exports.getPassword = getPassword; /** * @param {string} filepath * @returns {string} */ function getFileContent(filepath) { try { return fs.readFileSync(filepath, "utf8"); } catch (e) { exitWithError(`input file '${filepath}' does not exist!`); } } exports.getFileContent = getFileContent; /** * @param {object} namedArgs * @param {object} config * @returns {string} */ function getValidatedSalt(namedArgs, config) { 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 ); } return salt; } exports.getValidatedSalt = getValidatedSalt; /** * @param {object} namedArgs * @param {object} config * @returns {string} */ function getSalt(namedArgs, config) { // either a salt was provided by the user through the flag --salt if (!!namedArgs.salt) { return String(namedArgs.salt).toLowerCase(); } // or try to read the salt from config file if (config.salt) { return config.salt; } return generateRandomSalt(); } /** * A dead-simple alternative to webpack or rollup for inlining simple * CommonJS modules in a browser ================================================ FILE: example/example.html ================================================

Many secrets

You unlocked me!

Back to StatiCrypt

================================================ FILE: index.html ================================================ StatiCrypt: Password protect static HTML

StatiCrypt

Password protect a static HTML page

StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in your browser (client side).

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

The tool is also available as a CLI on NPM and is open source on GitHub.


HOW IT WORKS


+ More options

Encrypted HTML

Download html file with password prompt

Your encrypted string
================================================ FILE: lib/codec.js ================================================ /** * 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) { const exports = {}; /** * Top-level function for encoding a message. * Includes password hashing, encryption, and signing. * * @param {string} msg * @param {string} password * @param {string} salt * * @returns {string} The encoded text */ async function encode(msg, password, salt) { const hashedPassword = await cryptoEngine.hashPassword(password, salt); const encrypted = await cryptoEngine.encrypt(msg, hashedPassword); // 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 = await cryptoEngine.signMessage(hashedPassword, encrypted); return hmac + encrypted; } exports.encode = encode; /** * Encode using a password that has already been hashed. This is useful to encode multiple messages in a row, that way * we don't need to hash the password multiple times. * * @param {string} msg * @param {string} hashedPassword * * @returns {string} The encoded text */ async function encodeWithHashedPassword(msg, hashedPassword) { const encrypted = await cryptoEngine.encrypt(msg, hashedPassword); // 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 = await cryptoEngine.signMessage(hashedPassword, encrypted); return hmac + encrypted; } exports.encodeWithHashedPassword = encodeWithHashedPassword; /** * Top-level function for decoding a message. * Includes signature check and decryption. * * @param {string} signedMsg * @param {string} hashedPassword * @param {string} salt * @param {int} backwardCompatibleAttempt * @param {string} originalPassword * * @returns {Object} {success: true, decoded: string} | {success: false, message: string} */ async function decode(signedMsg, hashedPassword, salt, backwardCompatibleAttempt = 0, originalPassword = "") { const encryptedHMAC = signedMsg.substring(0, 64); const encryptedMsg = signedMsg.substring(64); const decryptedHMAC = await cryptoEngine.signMessage(hashedPassword, encryptedMsg); if (decryptedHMAC !== encryptedHMAC) { // 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. originalPassword = originalPassword || hashedPassword; if (backwardCompatibleAttempt === 0) { const updatedHashedPassword = await cryptoEngine.hashThirdRound(originalPassword, salt); return decode(signedMsg, updatedHashedPassword, salt, backwardCompatibleAttempt + 1, originalPassword); } if (backwardCompatibleAttempt === 1) { let updatedHashedPassword = await cryptoEngine.hashSecondRound(originalPassword, salt); updatedHashedPassword = await cryptoEngine.hashThirdRound(updatedHashedPassword, salt); return decode(signedMsg, updatedHashedPassword, salt, backwardCompatibleAttempt + 1, originalPassword); } return { success: false, message: "Signature mismatch" }; } return { success: true, decoded: await cryptoEngine.decrypt(encryptedMsg, hashedPassword), }; } exports.decode = decode; return exports; } exports.init = init; ================================================ FILE: lib/cryptoEngine.js ================================================ 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. */ 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, hashedPassword) { // 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(hashedPassword), 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} hashedPassword * @returns {Promise} */ async function decrypt(encryptedMsg, hashedPassword) { 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(hashedPassword), 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 password so it can be stored in localStorage without opening a password reuse vulnerability. * * @param {string} password * @param {string} salt * @returns {Promise} */ async function hashPassword(password, salt) { // we hash the password 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 hashedPassword = await hashLegacyRound(password, salt); hashedPassword = await hashSecondRound(hashedPassword, salt); return hashThirdRound(hashedPassword, salt); } exports.hashPassword = hashPassword; /** * This hashes the password with 1k iterations. This is a low number, we need this function to support backwards * compatibility. * * @param {string} password * @param {string} salt * @returns {Promise} */ function hashLegacyRound(password, salt) { return pbkdf2(password, 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 hashedPassword * @param salt * @returns {Promise} */ function hashSecondRound(hashedPassword, salt) { return pbkdf2(hashedPassword, 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 hashedPassword * @param salt * @returns {Promise} */ function hashThirdRound(hashedPassword, salt) { return pbkdf2(hashedPassword, salt, 585000, "SHA-256"); } exports.hashThirdRound = hashThirdRound; /** * Salt and hash the password so it can be stored in localStorage without opening a password reuse vulnerability. * * @param {string} password * @param {string} salt * @param {int} iterations * @param {string} hashAlgorithm * @returns {Promise} */ async function pbkdf2(password, salt, iterations, hashAlgorithm) { const key = await subtle.importKey("raw", UTF8Encoder.parse(password), "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(hashedPassword, message) { const key = await subtle.importKey( "raw", HexEncoder.parse(hashedPassword), { 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; ================================================ FILE: lib/formater.js ================================================ /** * Replace the variable in template tags, between '/*[|variable|]* /0' (without the space in '* /0', ommiting it would * break this comment), with the provided data. * * This weird format is so that we have something that doesn't break JS parser in the template files (it understands it * as '0'), so we can still use auto-formatting. The auto-formatter might add a space before the '0', we accept both. * * @param {string} templateString * @param {Object} data * * @returns string */ function renderTemplate(templateString, data) { return templateString.replace(/\/\*\[\|\s*(\w+)\s*\|]\*\/\s*0/g, function (_, key) { if (!data || data[key] === undefined) { return key; } if (typeof data[key] === "object") { return JSON.stringify(data[key]); } return data[key]; }); } exports.renderTemplate = renderTemplate; ================================================ FILE: lib/password_template.html ================================================ /*[|template_title|]*/0
================================================ FILE: lib/staticryptJs.js ================================================ const cryptoEngine = /*[|js_crypto_engine|]*/ 0; const codec = /*[|js_codec|]*/ 0; const decode = codec.init(cryptoEngine).decode; /** * Initialize the staticrypt module, that exposes functions callbable by the password_template. * * @param {{ * staticryptEncryptedMsgUniqueVariableName: string, * isRememberEnabled: boolean, * rememberDurationInDays: number, * staticryptSaltUniqueVariableName: string, * }} staticryptConfig - object of data that is stored on the password_template at encryption time. * * @param {{ * rememberExpirationKey: string, * rememberPassphraseKey: string, * replaceHtmlCallback: function, * clearLocalStorageCallback: function, * }} templateConfig - object of data that can be configured by a custom password_template. */ function init(staticryptConfig, templateConfig) { const exports = {}; /** * Decrypt our encrypted page, replace the whole HTML. * * @param {string} hashedPassword * @returns {Promise} */ async function decryptAndReplaceHtml(hashedPassword) { const { staticryptEncryptedMsgUniqueVariableName, staticryptSaltUniqueVariableName } = staticryptConfig; const { replaceHtmlCallback } = templateConfig; const result = await decode( staticryptEncryptedMsgUniqueVariableName, hashedPassword, staticryptSaltUniqueVariableName ); if (!result.success) { return false; } const plainHTML = result.decoded; // if the user configured a callback call it, otherwise just replace the whole HTML if (typeof replaceHtmlCallback === "function") { replaceHtmlCallback(plainHTML); } else { document.write(plainHTML); document.close(); } return true; } /** * Attempt to decrypt the page and replace the whole HTML. * * @param {string} password * @param {boolean} isRememberChecked * * @returns {Promise<{isSuccessful: boolean, hashedPassword?: string}>} - we return an object, so that if we want to * expose more information in the future we can do it without breaking the password_template */ async function handleDecryptionOfPage(password, isRememberChecked) { const { staticryptSaltUniqueVariableName } = staticryptConfig; // decrypt and replace the whole page const hashedPassword = await cryptoEngine.hashPassword(password, staticryptSaltUniqueVariableName); return handleDecryptionOfPageFromHash(hashedPassword, isRememberChecked); } exports.handleDecryptionOfPage = handleDecryptionOfPage; async function handleDecryptionOfPageFromHash(hashedPassword, isRememberChecked) { const { isRememberEnabled, rememberDurationInDays } = staticryptConfig; const { rememberExpirationKey, rememberPassphraseKey } = templateConfig; const isDecryptionSuccessful = await decryptAndReplaceHtml(hashedPassword); if (!isDecryptionSuccessful) { return { isSuccessful: false, hashedPassword, }; } // remember the hashedPassword and set its expiration if necessary if (isRememberEnabled && isRememberChecked) { window.localStorage.setItem(rememberPassphraseKey, hashedPassword); // set the expiration if the duration isn't 0 (meaning no expiration) if (rememberDurationInDays > 0) { window.localStorage.setItem( rememberExpirationKey, (new Date().getTime() + rememberDurationInDays * 24 * 60 * 60 * 1000).toString() ); } } return { isSuccessful: true, hashedPassword, }; } exports.handleDecryptionOfPageFromHash = handleDecryptionOfPageFromHash; /** * Clear localstorage from staticrypt related values */ function clearLocalStorage() { const { clearLocalStorageCallback, rememberExpirationKey, rememberPassphraseKey } = templateConfig; if (typeof clearLocalStorageCallback === "function") { clearLocalStorageCallback(); } else { localStorage.removeItem(rememberPassphraseKey); localStorage.removeItem(rememberExpirationKey); } } async function handleDecryptOnLoad() { let isSuccessful = await decryptOnLoadFromUrl(); if (!isSuccessful) { isSuccessful = await decryptOnLoadFromRememberMe(); } return { isSuccessful }; } exports.handleDecryptOnLoad = handleDecryptOnLoad; /** * Clear storage if we are logging out * * @returns {boolean} - whether we logged out */ function logoutIfNeeded() { const logoutKey = "staticrypt_logout"; // handle logout through query param const queryParams = new URLSearchParams(window.location.search); if (queryParams.has(logoutKey)) { clearLocalStorage(); return true; } // handle logout through URL fragment const hash = window.location.hash.substring(1); if (hash.includes(logoutKey)) { clearLocalStorage(); return true; } return false; } /** * To be called on load: check if we want to try to decrypt and replace the HTML with the decrypted content, and * try to do it if needed. * * @returns {Promise} true if we derypted and replaced the whole page, false otherwise */ async function decryptOnLoadFromRememberMe() { const { rememberDurationInDays } = staticryptConfig; const { rememberExpirationKey, rememberPassphraseKey } = templateConfig; // if we are login out, terminate if (logoutIfNeeded()) { return false; } // if there is expiration configured, check if we're not beyond the expiration if (rememberDurationInDays && rememberDurationInDays > 0) { const expiration = localStorage.getItem(rememberExpirationKey), isExpired = expiration && new Date().getTime() > parseInt(expiration); if (isExpired) { clearLocalStorage(); return false; } } const hashedPassword = localStorage.getItem(rememberPassphraseKey); if (hashedPassword) { // try to decrypt const isDecryptionSuccessful = await decryptAndReplaceHtml(hashedPassword); // if the decryption is unsuccessful the password might be wrong - silently clear the saved data and let // the user fill the password form again if (!isDecryptionSuccessful) { clearLocalStorage(); return false; } return true; } return false; } async function decryptOnLoadFromUrl() { const passwordKey = "staticrypt_pwd"; const rememberMeKey = "remember_me"; // try to get the password from the query param (for backward compatibility - we now want to avoid this method, // since it sends the hashed password to the server which isn't needed) const queryParams = new URLSearchParams(window.location.search); const hashedPasswordQuery = queryParams.get(passwordKey); const rememberMeQuery = queryParams.get(rememberMeKey); const urlFragment = window.location.hash.substring(1); // get the password from the url fragment const hashedPasswordRegexMatch = urlFragment.match(new RegExp(passwordKey + "=([^&]*)")); const hashedPasswordFragment = hashedPasswordRegexMatch ? hashedPasswordRegexMatch[1] : null; const rememberMeFragment = urlFragment.includes(rememberMeKey); const hashedPassword = hashedPasswordFragment || hashedPasswordQuery; const rememberMe = rememberMeFragment || rememberMeQuery; if (hashedPassword) { return handleDecryptionOfPageFromHash(hashedPassword, rememberMe); } return false; } return exports; } exports.init = init; ================================================ FILE: package.json ================================================ { "name": "staticrypt", "version": "3.5.4", "description": "Password protect a static HTML file without a backend - StatiCrypt uses AES-256 wiht WebCrypto 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": [ "/cli", "/lib" ], "bin": { "staticrypt": "./cli/index.js" }, "dependencies": { "dotenv": "^16.0.3", "yargs": ">=10.0.3 <=17.7.2" }, "engines": { "node": ">=16.0.0" }, "funding": { "type": "github", "url": "https://github.com/robinmoisson/staticrypt?sponsor=1" }, "author": "Robin Moisson (https://github.com/robinmoisson)", "contributors": [ "Aaron Coplan (https://github.com/AaronCoplan)", "Adam Hull (https://github.com/hurrymaplelad)" ], "license": "MIT", "scripts": { "build": "bash ./scripts/build.sh", "format": "prettier --write \"**/*.{js,json,html}\"", "prepare": "husky" }, "lint-staged": { "**/*.{js,json,html}": [ "prettier --write" ] }, "repository": { "type": "git", "url": "git+https://github.com/robinmoisson/staticrypt.git" }, "keywords": [ "static", "html", "password", "protected", "encrypted", "encryption", "crypto", "webcrypto" ], "bugs": { "url": "https://github.com/robinmoisson/staticrypt/issues" }, "homepage": "https://github.com/robinmoisson/staticrypt", "devDependencies": { "husky": "^9.1.6", "lint-staged": "^15.2.10", "prettier": "^2.8.8" } } ================================================ FILE: scripts/build.sh ================================================ # Build the website files # Should be run with "npm run build" - npm handles the pathing better (so no "#!/usr/bin/env" bash on top) # build the index.html file node ./scripts/buildIndex.js # encrypt the example file cd example node ../cli/index.js example.html \ -p test \ --short \ --salt b93bbaf35459951c47721d1f3eaeb5b9 \ --config false \ --template-instructions "Enter \"test\" to unlock the page" ================================================ FILE: scripts/buildIndex.js ================================================ const { convertCommonJSToBrowserJS, genFile, buildStaticryptJS } = require("../cli/helpers.js"); const data = { js_codec: convertCommonJSToBrowserJS("lib/codec"), js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"), js_formater: convertCommonJSToBrowserJS("lib/formater"), js_staticrypt: buildStaticryptJS(), }; genFile(data, "./index.html", "./scripts/index_template.html"); ================================================ FILE: scripts/index_template.html ================================================ StatiCrypt: Password protect static HTML

StatiCrypt

Password protect a static HTML page

StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in your browser (client side).

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

The tool is also available as a CLI on NPM and is open source on GitHub.


HOW IT WORKS


+ More options

Encrypted HTML

Download html file with password prompt

Your encrypted string