Showing preview only (213K chars total). Download the full file or copy to clipboard to get everything.
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 <password>`, 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
================================================
<p align="center"><a href="https://robinmoisson.github.io/staticrypt/example/encrypted/example.html"><img src="preview.png" alt="password prompt preview" width="480"/></a><a href="https://robinmoisson.github.io/staticrypt/example/encrypted/example.html"><br/>live example</a></p>
# 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!
>
> <a href="https://github.com/sponsors/robinmoisson"><img src="https://user-images.githubusercontent.com/5664025/234358001-65dfb967-19ab-49da-a8f5-27deca92ceb1.png" alt="Sponsor" /></a>
## 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 <long-password>
```
#### 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 <filename> [<filename> ...] [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=<hashed_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 <john-password> -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/<username>` 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-----
mQINBGP7Nw0BEADTAxpYuydugSjQ4OtfzFwLqIG0lRZG8dCQVSFXEaPL2IjRis/j
HgeGeaNuVi6X30YDOnc+P/FKTP0CrheDEgECMj5Ua0E7taEbA3vlAVIjHMC5F91n
Ac2G/yUC/7wSiIJkqL+joEYX3QadxaJQe65ljg3QCKGgWwdzDubCLc5KLb0r5Mjj
GqI3CxAlEelNjAqF0PhQNcdT6FBPLXfhdNBdcUBhlEVSGif+D0idrzBpxGZUYV3h
LjuYSMyzpc1sPnPpOW419/mjM4m3i1UvuUKgh/GA8QE0SWEzgs0yiMzO7gQvYl/A
iO7MIRP1mbCRWRmLLEF4RfyinAI+NlVs8KnKhhXjD43TBm9VykmWP7CHkfstYkHQ
oWKTna+Ht1uuL2voXi1FCkL5+SFFSE9/MJNhIkWHPVt8LPrkK6EJzEweQ+vtWkBj
/Vli78z19+d7LpMRlnIW72lXIthHjQfhMxm+Zf2CKqLXYBXQ14v0iyotBZO5cs3Y
RqsOYZ49c7dTfxZpHqKBI61+9dT79la7gsXomf9Of4vZUAHuDt6q9a+lYN6swPx1
35pbxAOWGFUe7vTXkY4OcUCr6Fncmh7YNSCkhHaoM5MRuX7mJn95/nugMEv/ouL5
npNiyy1haFtESRt/NyYk0KpSCP2W5NA2zUAPdjezz/8rKWsLdFruqC0p1QARAQAB
tCxSb2JpbiBNb2lzc29uIDxyb2Jpbi5tb2lzc29uQHByb3Rvbm1haWwuY29tPokC
TgQTAQoAOBYhBBrJWEyXrIIFasyfipQZcWUAB4WDBQJj+zcNAhsDBQsJCAcCBhUK
CQgLAgQWAgMBAh4BAheAAAoJEJQZcWUAB4WD4DwP/3C6NyTl7fPWk8CenhDD9SJC
vNcsasXIOiEaDyf4JeFGcOb2rsiIKyt+eAorGaioRD+RjZQUMiVQhRm7pRquyc+t
6M/Db53yhBrZ4h+fryBv9+lAizxYMg5NmCTgyf7YaZDxAuT1kWpQ23f2TZc98WCP
KtkMvWP0HVJF+/UZKZ2qVcvvErmogqgrr7QxlLWiF/JUlAe2mdwSpC/FxaGCcWNA
BJv/8tQ4QkKBkGguMvl3qq8BQlmuW3uE+RdRtcC6Gh1nvxUGEBMC6Ut53Vm4rGxl
nFQ+sX7q6lVADJrkxPqWlYoQnvPqWA2uFr5CWJiLDMIi5oZIiHa5WctxFBLDJp+g
q3ZqVeoY/5Q0o/LzeknxbKdI9BBKKmnZz1Qqk02a7KfhN13xQeJ3ZHpetT9LfG6G
k6kFO9v6UVVDO6DmmTefU/LpDdnZYQLK09GtQgLrIHv3+MoEp15BrLFgSpQ/uiur
VSU0K3D5EtvbFDxxrpedGrzWlu1wLIdk5mXX3GMKGS4zoZJAZEgm6fAJC1jmWU0u
frOIgukls1a4WrNlFerMPJPbG9p6llV2miGgSYe2s43ubJ1vkFVmq0gyx5t8JvW0
h7ctj2lNqtYeGGhYGXvvOKtMGFYhhnjJKzj8s+KcvDv56DQ6/cvNiqn7vo0301SV
6+TeY+vMo+ugPvlZOAS/tCdSb2JpbiBNb2lzc29uIDxyb2Jpbi5tb2lzc29uQGdt
YWlsLmNvbT6JAk4EEwEKADgWIQQayVhMl6yCBWrMn4qUGXFlAAeFgwUCY/s5WAIb
AwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgAAKCRCUGXFlAAeFg3GOD/411DbRodhw
FipO3QnJpRON6acp6QpyxlZvhoMRJt7tKVVQr3BZFGVcYlzLJ4VTtE+KgPqe+/sX
7JUUpfmIzGwXtvviJ87ZFjbzTAbPucuPR5cEAwFxp7rPkWMPIPCW+BKgb+iPha3A
2q58bHnLwS+/kQ0/SbmgF+ir7hdbjzSzsnsus9k/bB1aLviB1bQ39heQLk+k1fN6
qoCrxoyXps6NZ9LdPYUUgpXJ/d3rqvrxKSHVpNZP3171CqD6+451+i5q60xUNotM
LHXxzAcNBGe+ySe7UV69fhCZNnj3mOhp1QAxuA2us1aKyAJraWY5HipFnAdHP1Rx
QOJz7evW2D/21Od1ygIc1W7Xs1cO+5C9GsU1WHZTk9XtU8RwMBbxU2t8dhcN8+nn
u3iwMmPtPzj3Go5aOiIlQGSAMn9v1IR5LT+A+Is3sQz5xmYpZlW6ELrRW237e5H0
YxlEtfGcJJieYeco78FJ2dmYCUbPn8Oqo3s6RiWbkwczX7cc3J427kH/wuj4sPbF
MTCrLrHeGKcsSbnpjaR8yV7MwDh00ZlIcN0q4FVGgJuR9oSaBTXiNNOFlWVRw9kU
PtGoi0i5j8a9VtGDQYtF9vVtwzwKVA6WUExmwtpfeiXNPdkeJIeov4YOSMN1XTfV
REwenLoVdcuLNIzLj8N4JVsahwh8DsHRh7kCDQRj+zcNARAA21sHU/mjDxxaeu/w
6NDKBm5AV/nO629893PhhUW3DAWJQbZqO4Z6AksJCcz4gZgXbjqowNLfxDZY8WAX
Py6CtGo67NjZj9k34IGwcMUU0tHuVfwWpwq/1zHTtz0PGUM5yTCFQHxv9TInyY6L
e2FLtR6ZX+9bEv1TALqvpw2X4fks2qy8lBj9XfiQfAPTDYjCrM5WZewFhwaXisXP
lJFNqsOSSMCzGaCyXDN6Rj69aEbo9m3dr+DKyrRWjuvFAeG4xPpISFdB7mEYwszV
R6TLileANODakGtqjzGEX/MZs0QflGUjxKavN9p6rZM6gL/6Drpg0Oo5LkBHPbl9
YZappKGblOIrPcapUNt7274+xx3rUV4qJ+9UTB8SKxvKXdK6flpznwKcCaD9euZc
HxfeYjMlWdAKGx0NwkAfa3Zk3znQX/m1SOkOhK6ayTCETX7hwqXXc21IZ+SBp3j2
DMaNmsHCxufhEwZT/+LJXlns54jyKlvZewUBLYwm77VDjd+PhCwv5wpDazgYneGY
T0ymOfPYpOJbEjWhvRRduYJSK/YujHDa6PhA7/bsVzXSl5dhCQJrQYUhGIvGWIsx
Lfz2XDHm7FKfclvjOgad9tQ/JPq6fyO5QdphPdne1BQs1AGQomqAdTTmf9xhtf/h
aAnISg52IZeUCcKryJN/hzmIoZ8AEQEAAYkCNgQYAQoAIBYhBBrJWEyXrIIFasyf
ipQZcWUAB4WDBQJj+zcNAhsMAAoJEJQZcWUAB4WDVggP/0bB5R/+azDy8y4p5wcj
mfGLLsWFWkvJukAGglZpCRS3G5G4LtTnLoTTqZ2S83ZNeQc+fTzm+45xBcJov0j4
57dEUXsQvIfodGsWaEBQIaJrkusgdTqx8gv/AeB7h63iUuTeL98uqbhqNm5coxtO
Si7SBQ36SbpvuV1GptnxHTKs6jOpADyN+Bx5tPKukTP+6Yuo51WwMmIffv2VcTv9
04/jV1JljCEg7NFvU6eg9/99AmGG0INe/z8CcvM8cAyqWbbWlzXXeM/ut9ri7lJI
DhW9wk0u3ppXLFHF+X63rQJS+IvPLC/MZpym6oYNpGl9El92uBlCABHoWnJ0xgsE
T5B+EOoMKiJog7qGRC+aHfxGwpAJ4PHFZH89oRUA2shYnqhUsvVWmfIR3lw55mn4
6iy12MiGJFRhJajowL5Q+6xbSuKQu/wLm0U8Vznuo6fpsHFoKpoduNJwJN+3zRh+
YwCgnIc986AMGu0l3E302BO/qxUhXtXCaln0Pj7lBmJ58ksjhv2Y6vyrS8ef+eUs
KIw9dJ4rFhfpwy4DqgDkYN74vTMd+naVnqrKmrOrItjTwpIcMcNgyQhmlsGytAkR
8coKjowpSSac4zc982N97mOJ683cLEbZvu1cL3xEaRgLt0AR0QWNInIlAL/CbUD7
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<string>}
*/
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<void>}
*/
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<string>}
*/
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 <script>.
* - Removes all lines containing require().
* - Wraps the module in an immediately invoked function that returns `exports`.
*
* @param {string} modulePath - path from staticrypt root directory
*/
function convertCommonJSToBrowserJS(modulePath) {
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}"`);
}
const moduleText = fs.readFileSync(resolvedPath, "utf8").replace(/^.*\brequire\(.*$\n/gm, "");
return `
((function(){
const exports = {};
${moduleText}
return exports;
})())
`.trim();
}
exports.convertCommonJSToBrowserJS = convertCommonJSToBrowserJS;
/**
* Build the staticrypt script string to inject in our template.
*
* @returns {string}
*/
function buildStaticryptJS() {
let staticryptJS = convertCommonJSToBrowserJS("lib/staticryptJs");
const scriptsToInject = {
js_codec: convertCommonJSToBrowserJS("lib/codec"),
js_crypto_engine: convertCommonJSToBrowserJS("lib/cryptoEngine"),
};
return renderTemplate(staticryptJS, scriptsToInject);
}
exports.buildStaticryptJS = buildStaticryptJS;
/**
* @param {string} filePath
* @param {string} errorName
* @returns {string}
*/
function readFile(filePath, errorName = "file") {
try {
return fs.readFileSync(filePath, "utf8");
} catch (e) {
console.error(e);
exitWithError(`could not read ${errorName} at path "${filePath}"`);
}
}
/**
* Fill the template with provided data and writes it to output file.
*
* @param {Object} data
* @param {string} outputFilePath
* @param {string} templateFilePath
*/
function genFile(data, outputFilePath, templateFilePath) {
const templateContents = readFile(templateFilePath, "template");
const renderedTemplate = renderTemplate(templateContents, data);
writeFile(outputFilePath, renderedTemplate);
}
exports.genFile = genFile;
/**
* @param {string} path
* @param {string} fullRootDirectory
* @param {string} outputDirectory
* @returns {string}
*/
function getFullOutputPath(path, fullRootDirectory, outputDirectory) {
const relativePath = pathModule.relative(fullRootDirectory, path);
return outputDirectory + "/" + relativePath;
}
exports.getFullOutputPath = getFullOutputPath;
/**
* @param {string} inputFilePath
* @param {string} outputFilePath
*/
function copyFile(inputFilePath, outputFilePath) {
// create output directory if it does not exist
createDirectoryStructureForFile(outputFilePath);
try {
fs.copyFileSync(inputFilePath, outputFilePath, fs.constants.COPYFILE_FICLONE);
} catch (e) {
console.error(e);
exitWithError(`could not write file at path "${filePath}"`);
}
}
/**
* @param {string} filePath
* @param {string} contents
*/
function writeFile(filePath, contents) {
// create output directory if it does not exist
createDirectoryStructureForFile(filePath);
try {
fs.writeFileSync(filePath, contents);
} catch (e) {
console.error(e);
exitWithError(`could not write file at path "${filePath}"`);
}
}
exports.writeFile = writeFile;
/**
* @param {string} filePath
*/
function createDirectoryStructureForFile(filePath) {
const dirname = pathModule.dirname(filePath);
if (!fs.existsSync(dirname)) {
fs.mkdirSync(dirname, { recursive: true });
}
}
/**
* @param {string} templatePathParameter
* @returns {boolean}
*/
function isCustomPasswordTemplateDefault(templatePathParameter) {
// if the user uses the default template, it's up to date
return templatePathParameter === PASSWORD_TEMPLATE_DEFAULT_PATH;
}
exports.isCustomPasswordTemplateDefault = isCustomPasswordTemplateDefault;
/**
* @param {string} path
* @param {string} outputDirectory
* @param {string} rootDirectory
* @param {(fullPath: string, rootDirectoryFromArgument: string) => void} callback
*/
function recursivelyApplyCallbackToHtmlFiles(callback, path, outputDirectory, 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}`;
recursivelyApplyCallbackToHtmlFiles(callback, fullFilePath, outputDirectory, fullRootDirectory);
});
return;
}
// apply the callback if it's an HTML file
if (fullPath.endsWith(".html") || fullPath.endsWith(".htm")) {
callback(fullPath, fullRootDirectory);
}
// else just copy the file as is
else {
const fullOutputPath = getFullOutputPath(fullPath, fullRootDirectory, outputDirectory);
copyFile(fullPath, fullOutputPath);
}
}
exports.recursivelyApplyCallbackToHtmlFiles = recursivelyApplyCallbackToHtmlFiles;
function parseCommandLineArguments() {
return (
Yargs.usage("Usage: staticrypt <filename> [<filename> ...] [options]")
.option("c", {
alias: "config",
type: "string",
describe: 'Path to the config file. Set to "false" to disable.',
default: ".staticrypt.json",
})
.option("d", {
alias: "directory",
type: "string",
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",
type: "string",
describe:
"The password to encrypt your file with. Leave empty to be prompted for it. If STATICRYPT_PASSWORD" +
" is set in the env, we'll use that instead.",
default: null,
})
.option("r", {
alias: "recursive",
type: "boolean",
describe: "Whether to recursively encrypt the input directory.",
default: false,
})
.option("remember", {
describe:
'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,
})
// 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:
"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.",
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("share-remember", {
type: "boolean",
describe: "Whether the share link should auto-enable 'Remember-me'.",
default: false,
})
.option("short", {
describe: 'Hide the "short password" warning.',
type: "boolean",
default: false,
})
.option("t", {
alias: "template",
type: "string",
describe: "Path to custom HTML template with password prompt.",
default: PASSWORD_TEMPLATE_DEFAULT_PATH,
})
.option("template-button", {
type: "string",
describe: 'Label to use for the decrypt button. Default: "DECRYPT".',
default: "DECRYPT",
})
.option("template-color-primary", {
type: "string",
describe: "Primary color (button...)",
default: "#4CAF50",
})
.option("template-color-secondary", {
type: "string",
describe: "Secondary color (page background...)",
default: "#76B852",
})
.option("template-instructions", {
type: "string",
describe: "Special instructions to display to the user.",
default: "",
})
.option("template-error", {
type: "string",
describe: "Error message to display on entering wrong password.",
default: "Bad password!",
})
.option("template-placeholder", {
type: "string",
describe: "Placeholder to use for the password input.",
default: "Password",
})
.option("template-remember", {
type: "string",
describe: 'Label to use for the "Remember me" checkbox.',
default: "Remember me",
})
.option("template-title", {
type: "string",
describe: "Title for the output HTML page.",
default: "Protected Page",
})
.option("template-toggle-hide", {
type: "string",
describe: 'Alt text for toggling password visibility - "hide" action.',
default: "Hide password",
})
.option("template-toggle-show", {
type: "string",
describe: 'Alt text for toggling password visibility - "show" action.',
default: "Show password",
})
);
}
exports.parseCommandLineArguments = parseCommandLineArguments;
================================================
FILE: cli/index.js
================================================
#!/usr/bin/env node
"use strict";
// check node version before anything else
const nodeVersion = process.versions.node.split(".");
if (nodeVersion[0] < 16) {
console.log("ERROR: Node version 16 or higher is required.");
process.exit(1);
}
// 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 { decode, encodeWithHashedPassword } = codec.init(cryptoEngine);
const {
OUTPUT_DIRECTORY_DEFAULT_PATH,
buildStaticryptJS,
exitWithError,
genFile,
getConfig,
getFileContent,
getPassword,
getValidatedSalt,
isOptionSetByUser,
parseCommandLineArguments,
recursivelyApplyCallbackToHtmlFiles,
validatePassword,
writeConfig,
writeFile,
getFullOutputPath,
} = require("./helpers.js");
// parse arguments
const yargs = parseCommandLineArguments();
const namedArgs = yargs.argv;
async function runStatiCrypt() {
const hasSaltFlag = isOptionSetByUser("s", yargs);
const hasShareFlag = isOptionSetByUser("share", yargs);
const positionalArguments = namedArgs._;
// require at least one positional argument unless some specific flags are passed
if (!hasShareFlag && !(hasSaltFlag && !namedArgs.salt)) {
if (positionalArguments.length === 0) {
console.log("ERROR: Invalid number of arguments. Please provide an input file.\n");
yargs.showHelp();
process.exit(1);
}
}
// get config file
const configPath = namedArgs.config.toLowerCase() === "false" ? null : "./" + namedArgs.config;
const config = getConfig(configPath);
// if the 's' flag is passed without parameter, generate a salt, display & exit
if (hasSaltFlag && !namedArgs.salt) {
const generatedSalt = generateRandomSalt();
// show salt
console.log(generatedSalt);
// write to config file if it doesn't exist
if (!config.salt) {
config.salt = generatedSalt;
writeConfig(configPath, config);
}
return;
}
// get the salt & password
const salt = getValidatedSalt(namedArgs, config);
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);
let url = namedArgs.share || "";
url += "#staticrypt_pwd=" + hashedPassword;
if (namedArgs.shareRemember) {
url += `&remember_me`;
}
console.log(url);
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) => {
recursivelyApplyCallbackToHtmlFiles(
(fullPath, fullRootDirectory) => {
decodeAndGenerateFile(fullPath, fullRootDirectory, hashedPassword, outputDirectory);
},
path,
namedArgs.directory
);
});
return;
}
await validatePassword(password, namedArgs.short);
// write salt to config file
if (config.salt !== salt) {
config.salt = salt;
writeConfig(configPath, config);
}
const isRememberEnabled = namedArgs.remember !== "false";
const baseTemplateData = {
is_remember_enabled: JSON.stringify(isRememberEnabled),
js_staticrypt: buildStaticryptJS(),
template_button: namedArgs.templateButton,
template_color_primary: namedArgs.templateColorPrimary,
template_color_secondary: namedArgs.templateColorSecondary,
template_error: namedArgs.templateError,
template_instructions: namedArgs.templateInstructions,
template_placeholder: namedArgs.templatePlaceholder,
template_remember: namedArgs.templateRemember,
template_title: namedArgs.templateTitle,
template_toggle_show: namedArgs.templateToggleShow,
template_toggle_hide: namedArgs.templateToggleHide,
};
// encode all the files
positionalArguments.forEach((path) => {
recursivelyApplyCallbackToHtmlFiles(
(fullPath, fullRootDirectory) => {
encodeAndGenerateFile(
fullPath,
fullRootDirectory,
hashedPassword,
salt,
baseTemplateData,
isRememberEnabled,
namedArgs
);
},
path,
namedArgs.directory
);
});
}
async function decodeAndGenerateFile(path, fullRootDirectory, hashedPassword, outputDirectory) {
// get the file content
const encryptedFileContent = getFileContent(path);
// extract the cipher text from the encrypted file
const cipherTextMatch = encryptedFileContent.match(/"staticryptEncryptedMsgUniqueVariableName":\s*"([^"]+)"/);
const saltMatch = encryptedFileContent.match(/"staticryptSaltUniqueVariableName":\s*"([^"]+)"/);
if (!cipherTextMatch || !saltMatch) {
return console.log(`ERROR: could not extract cipher text or salt from ${path}`);
}
// decrypt input
const { success, decoded } = await decode(cipherTextMatch[1], hashedPassword, saltMatch[1]);
if (!success) {
return console.log(`ERROR: could not decrypt ${path}`);
}
const outputFilepath = getFullOutputPath(path, fullRootDirectory, outputDirectory);
writeFile(outputFilepath, decoded);
}
async function encodeAndGenerateFile(
path,
rootDirectoryFromArguments,
hashedPassword,
salt,
baseTemplateData,
isRememberEnabled,
namedArgs
) {
// get the file content
const contents = getFileContent(path);
// encrypt input
const encryptedMsg = await encodeWithHashedPassword(contents, hashedPassword);
let rememberDurationInDays = parseInt(namedArgs.remember);
rememberDurationInDays = isNaN(rememberDurationInDays) ? 0 : rememberDurationInDays;
const staticryptConfig = {
staticryptEncryptedMsgUniqueVariableName: encryptedMsg,
isRememberEnabled,
rememberDurationInDays,
staticryptSaltUniqueVariableName: salt,
};
const templateData = {
...baseTemplateData,
staticrypt_config: staticryptConfig,
};
// 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);
}
runStatiCrypt();
================================================
FILE: example/encrypted/example.html
================================================
<!DOCTYPE html>
<html class="staticrypt-html">
<head>
<meta charset="utf-8" />
<title>Protected Page</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- do not cache this page -->
<meta http-equiv="cache-control" content="max-age=0" />
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="expires" content="0" />
<meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />
<meta http-equiv="pragma" content="no-cache" />
<style>
.staticrypt-hr {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #eee;
}
.staticrypt-page {
width: 360px;
padding: 8% 0 0;
margin: auto;
box-sizing: border-box;
}
.staticrypt-form {
position: relative;
z-index: 1;
background: #ffffff;
max-width: 360px;
margin: 0 auto 100px;
padding: 45px;
text-align: center;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
}
.staticrypt-form input[type="password"],
input[type="text"] {
background: inherit;
border: 0;
box-sizing: border-box; /* This ensures padding is included in the total width */
font-size: 14px;
outline: 0;
padding: 15px 30px 15px 15px; /* Adjust the padding to ensure there is space for the icon */
width: 100%;
}
.staticrypt-password-container {
position: relative;
outline: 0;
background: #f2f2f2;
width: 100%;
border: 0;
margin: 0 0 15px;
box-sizing: border-box;
}
.staticrypt-toggle-password-visibility {
cursor: pointer;
height: 20px;
opacity: 60%;
padding: 13px;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
}
.staticrypt-form .staticrypt-decrypt-button {
text-transform: uppercase;
outline: 0;
background: #4CAF50;
width: 100%;
border: 0;
padding: 15px;
color: #ffffff;
font-size: 14px;
cursor: pointer;
}
.staticrypt-form .staticrypt-decrypt-button:hover,
.staticrypt-form .staticrypt-decrypt-button:active,
.staticrypt-form .staticrypt-decrypt-button:focus {
background: #4CAF50;
filter: brightness(92%);
}
.staticrypt-html {
height: 100%;
}
.staticrypt-body {
height: 100%;
margin: 0;
}
.staticrypt-content {
height: 100%;
margin-bottom: 1em;
background: #76B852;
font-family: "Arial", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.staticrypt-instructions {
margin-top: -1em;
margin-bottom: 1em;
}
.staticrypt-title {
font-size: 1.5em;
}
label.staticrypt-remember {
display: flex;
align-items: center;
margin-bottom: 1em;
}
.staticrypt-remember input[type="checkbox"] {
transform: scale(1.5);
margin-right: 1em;
}
.hidden {
display: none !important;
}
.staticrypt-spinner-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.staticrypt-spinner {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid gray;
border-right-color: transparent;
border-radius: 50%;
-webkit-animation: spinner-border 0.75s linear infinite;
animation: spinner-border 0.75s linear infinite;
animation-duration: 0.75s;
animation-timing-function: linear;
animation-delay: 0s;
animation-iteration-count: infinite;
animation-direction: normal;
animation-fill-mode: none;
animation-play-state: running;
animation-name: spinner-border;
}
@keyframes spinner-border {
100% {
transform: rotate(360deg);
}
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.staticrypt-form input[type="password"],
input[type="text"] {
font-size: 16px;
}
}
</style>
</head>
<body class="staticrypt-body">
<div id="staticrypt_loading" class="staticrypt-spinner-container">
<div class="staticrypt-spinner"></div>
</div>
<div id="staticrypt_content" class="staticrypt-content hidden">
<div class="staticrypt-page">
<div class="staticrypt-form">
<div class="staticrypt-instructions">
<p class="staticrypt-title">Protected Page</p>
<p>Enter "test" to unlock the page</p>
</div>
<hr class="staticrypt-hr" />
<form id="staticrypt-form" action="#" method="post">
<div class="staticrypt-password-container">
<input
id="staticrypt-password"
type="password"
name="password"
placeholder="Password"
autofocus
/>
<img
class="staticrypt-toggle-password-visibility"
alt="Show password"
title="Show password"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTM4LjggNS4xQzI4LjQtMy4xIDEzLjMtMS4yIDUuMSA5LjJTLTEuMiAzNC43IDkuMiA0Mi45bDU5MiA0NjRjMTAuNCA4LjIgMjUuNSA2LjMgMzMuNy00LjFzNi4zLTI1LjUtNC4xLTMzLjdMNTI1LjYgMzg2LjdjMzkuNi00MC42IDY2LjQtODYuMSA3OS45LTExOC40YzMuMy03LjkgMy4zLTE2LjcgMC0yNC42Yy0xNC45LTM1LjctNDYuMi04Ny43LTkzLTEzMS4xQzQ2NS41IDY4LjggNDAwLjggMzIgMzIwIDMyYy02OC4yIDAtMTI1IDI2LjMtMTY5LjMgNjAuOEwzOC44IDUuMXpNMjIzLjEgMTQ5LjVDMjQ4LjYgMTI2LjIgMjgyLjcgMTEyIDMyMCAxMTJjNzkuNSAwIDE0NCA2NC41IDE0NCAxNDRjMCAyNC45LTYuMyA0OC4zLTE3LjQgNjguN0w0MDggMjk0LjVjOC40LTE5LjMgMTAuNi00MS40IDQuOC02My4zYy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM2MwIDEwLjItMi40IDE5LjgtNi42IDI4LjNsLTkwLjMtNzAuOHpNMzczIDM4OS45Yy0xNi40IDYuNS0zNC4zIDEwLjEtNTMgMTAuMWMtNzkuNSAwLTE0NC02NC41LTE0NC0xNDRjMC02LjkgLjUtMTMuNiAxLjQtMjAuMkw4My4xIDE2MS41QzYwLjMgMTkxLjIgNDQgMjIwLjggMzQuNSAyNDMuN2MtMy4zIDcuOS0zLjMgMTYuNyAwIDI0LjZjMTQuOSAzNS43IDQ2LjIgODcuNyA5MyAxMzEuMUMxNzQuNSA0NDMuMiAyMzkuMiA0ODAgMzIwIDQ4MGM0Ny44IDAgODkuOS0xMi45IDEyNi4yLTMyLjVMMzczIDM4OS45eiIvPjwvc3ZnPg=="
/>
</div>
<label id="staticrypt-remember-label" class="staticrypt-remember hidden">
<input id="staticrypt-remember" type="checkbox" name="remember" />
Remember me
</label>
<input type="submit" class="staticrypt-decrypt-button" value="DECRYPT" />
</form>
</div>
</div>
</div>
<script>
// these variables will be filled when generating the file - the template format is 'variable_name'
const staticryptInitiator = ((function(){
const exports = {};
const cryptoEngine = ((function(){
const exports = {};
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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;
return exports;
})());
const codec = ((function(){
const exports = {};
/**
* 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;
return exports;
})());
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<boolean>}
*/
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<boolean>} 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;
return exports;
})());
const templateError = "Bad password!",
templateToggleAltShow = "Show password",
templateToggleAltHide = "Hide password",
isRememberEnabled = true,
staticryptConfig = {"staticryptEncryptedMsgUniqueVariableName":"b6316cd49ce09d90d6cf34cd96603f083cd803602dc4e5c34d6cf1f2d871f236ea7e0d207814dee498ab00a44e18b94846ebb2538a18d5920e274768c25955cb3add357be6967f3b0a63669017427eb52fe6d5b2d8994f849c7d9f227062db040eac791ca7bcf7b6cd12799d3ad730d18eb63e9d533cb6277b9546c632668da3f4ca3af925e0bfdc22992bd43e2344f07dbae2c2e0bfa2e84976ab8bec4f320508008deb828196d74f7c2923c2be19111f21e285fae7400d64271e32c6f9a618","isRememberEnabled":true,"rememberDurationInDays":0,"staticryptSaltUniqueVariableName":"b93bbaf35459951c47721d1f3eaeb5b9"};
// you can edit these values to customize some of the behavior of StatiCrypt
const templateConfig = {
rememberExpirationKey: "staticrypt_expiration",
rememberPassphraseKey: "staticrypt_passphrase",
replaceHtmlCallback: null,
clearLocalStorageCallback: null,
};
// init the staticrypt engine
const staticrypt = staticryptInitiator.init(staticryptConfig, templateConfig);
// try to automatically decrypt on load if there is a saved password
window.onload = async function () {
const { isSuccessful } = await staticrypt.handleDecryptOnLoad();
// if we didn't decrypt anything on load, show the password prompt. Otherwise the content has already been
// replaced, no need to do anything
if (!isSuccessful) {
// hide loading screen
document.getElementById("staticrypt_loading").classList.add("hidden");
document.getElementById("staticrypt_content").classList.remove("hidden");
document.getElementById("staticrypt-password").focus();
// show the remember me checkbox
if (isRememberEnabled) {
document.getElementById("staticrypt-remember-label").classList.remove("hidden");
}
}
};
// toggle password visibility
const toggleIcon = document.querySelector(".staticrypt-toggle-password-visibility");
// these two icons are coming from FontAwesome
const imgSrcEyeClosed =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTM4LjggNS4xQzI4LjQtMy4xIDEzLjMtMS4yIDUuMSA5LjJTLTEuMiAzNC43IDkuMiA0Mi45bDU5MiA0NjRjMTAuNCA4LjIgMjUuNSA2LjMgMzMuNy00LjFzNi4zLTI1LjUtNC4xLTMzLjdMNTI1LjYgMzg2LjdjMzkuNi00MC42IDY2LjQtODYuMSA3OS45LTExOC40YzMuMy03LjkgMy4zLTE2LjcgMC0yNC42Yy0xNC45LTM1LjctNDYuMi04Ny43LTkzLTEzMS4xQzQ2NS41IDY4LjggNDAwLjggMzIgMzIwIDMyYy02OC4yIDAtMTI1IDI2LjMtMTY5LjMgNjAuOEwzOC44IDUuMXpNMjIzLjEgMTQ5LjVDMjQ4LjYgMTI2LjIgMjgyLjcgMTEyIDMyMCAxMTJjNzkuNSAwIDE0NCA2NC41IDE0NCAxNDRjMCAyNC45LTYuMyA0OC4zLTE3LjQgNjguN0w0MDggMjk0LjVjOC40LTE5LjMgMTAuNi00MS40IDQuOC02My4zYy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM2MwIDEwLjItMi40IDE5LjgtNi42IDI4LjNsLTkwLjMtNzAuOHpNMzczIDM4OS45Yy0xNi40IDYuNS0zNC4zIDEwLjEtNTMgMTAuMWMtNzkuNSAwLTE0NC02NC41LTE0NC0xNDRjMC02LjkgLjUtMTMuNiAxLjQtMjAuMkw4My4xIDE2MS41QzYwLjMgMTkxLjIgNDQgMjIwLjggMzQuNSAyNDMuN2MtMy4zIDcuOS0zLjMgMTYuNyAwIDI0LjZjMTQuOSAzNS43IDQ2LjIgODcuNyA5MyAxMzEuMUMxNzQuNSA0NDMuMiAyMzkuMiA0ODAgMzIwIDQ4MGM0Ny44IDAgODkuOS0xMi45IDEyNi4yLTMyLjVMMzczIDM4OS45eiIvPjwvc3ZnPg==";
const imgSrcEyeOpened =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NzYgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTI4OCAzMmMtODAuOCAwLTE0NS41IDM2LjgtMTkyLjYgODAuNkM0OC42IDE1NiAxNy4zIDIwOCAyLjUgMjQzLjdjLTMuMyA3LjktMy4zIDE2LjcgMCAyNC42QzE3LjMgMzA0IDQ4LjYgMzU2IDk1LjQgMzk5LjRDMTQyLjUgNDQzLjIgMjA3LjIgNDgwIDI4OCA0ODBzMTQ1LjUtMzYuOCAxOTIuNi04MC42YzQ2LjgtNDMuNSA3OC4xLTk1LjQgOTMtMTMxLjFjMy4zLTcuOSAzLjMtMTYuNyAwLTI0LjZjLTE0LjktMzUuNy00Ni4yLTg3LjctOTMtMTMxLjFDNDMzLjUgNjguOCAzNjguOCAzMiAyODggMzJ6TTE0NCAyNTZhMTQ0IDE0NCAwIDEgMSAyODggMCAxNDQgMTQ0IDAgMSAxIC0yODggMHptMTQ0LTY0YzAgMzUuMy0yOC43IDY0LTY0IDY0Yy03LjEgMC0xMy45LTEuMi0yMC4zLTMuM2MtNS41LTEuOC0xMS45IDEuNi0xMS43IDcuNGMuMyA2LjkgMS4zIDEzLjggMy4yIDIwLjdjMTMuNyA1MS4yIDY2LjQgODEuNiAxMTcuNiA2Ny45czgxLjYtNjYuNCA2Ny45LTExNy42Yy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM3oiLz48L3N2Zz4=";
toggleIcon.addEventListener("click", function () {
const passwordInput = document.getElementById("staticrypt-password");
if (passwordInput.type === "password") {
passwordInput.type = "text";
toggleIcon.src = imgSrcEyeOpened;
toggleIcon.alt = templateToggleAltHide;
toggleIcon.title = templateToggleAltHide;
} else {
passwordInput.type = "password";
toggleIcon.src = imgSrcEyeClosed;
toggleIcon.alt = templateToggleAltShow;
toggleIcon.title = templateToggleAltShow;
}
});
// handle password form submission
document.getElementById("staticrypt-form").addEventListener("submit", async function (e) {
e.preventDefault();
const password = document.getElementById("staticrypt-password").value,
isRememberChecked = document.getElementById("staticrypt-remember").checked;
const { isSuccessful } = await staticrypt.handleDecryptionOfPage(password, isRememberChecked);
if (!isSuccessful) {
alert(templateError);
}
});
</script>
</body>
</html>
================================================
FILE: example/example.html
================================================
<h1>Many secrets</h1>
<p>You unlocked me!</p>
<p>Back to <a href="https://robinmoisson.github.io/staticrypt">StatiCrypt</a></p>
================================================
FILE: index.html
================================================
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>StatiCrypt: Password protect static HTML</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
type="text/css"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
crossorigin="anonymous"
/>
<style>
a.no-style {
color: inherit;
text-decoration: inherit;
}
body {
font-size: 16px;
}
label.no-style {
font-weight: normal;
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.staticrypt-form input[type="password"],
input[type="text"] {
font-size: 16px;
}
}
.footer {
width: 100%;
background-color: #f8f9fa;
padding: 20px;
text-align: center;
margin-top: 10em;
}
</style>
<!-- point to my other project as the canonical in the eyes of google - the two projects are kept in sync, and people can still use the github page hosted one for maximum transparency -->
<link rel="canonical" href="https://translateabook.com/staticrypt/" />
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12">
<h1>
StatiCrypt
<div class="pull-right">
<iframe
src="https://ghbtns.com/github-btn.html?user=robinmoisson&repo=staticrypt&type=star&size=large"
frameborder="0"
scrolling="0"
width="80px"
height="30px"
></iframe>
<iframe
src="https://ghbtns.com/github-btn.html?user=robinmoisson&repo=staticrypt&type=fork&size=large"
frameborder="0"
scrolling="0"
width="80px"
height="30px"
></iframe>
</div>
<br />
<small>Password protect a static HTML page</small>
</h1>
<p>
StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in
your browser (client side).
</p>
<p>
Download your encrypted string in a HTML page with a password prompt you can upload anywhere
(see <a target="_blank" href="example/encrypted/example.html">example</a>).
</p>
<p>
The tool is also available as
<a href="https://npmjs.com/package/staticrypt">a CLI on NPM</a> and is
<a href="https://github.com/robinmoisson/staticrypt">open source on GitHub</a>.
</p>
<br />
<h4>
<a class="no-style" id="toggle-concept" href="#">
<span id="toggle-concept-sign">►</span> HOW IT WORKS
</a>
</h4>
<div id="concept" class="hidden">
<p>
<b class="text-danger">Disclaimer</b> if you are an at-risk activist, or have extra
sensitive banking data, you should probably use something else!
</p>
<p>
StatiCrypt generates a static, password protected page that can be decrypted in-browser:
just send or upload the generated page to a place serving static content (github pages, for
example) and you're done: the javascript will prompt users for password, decrypt the page
and load your HTML.
</p>
<p>
The page is encrypted with AES-256 in CBC mode (see why this mode is appropriate for
StatiCrypt in
<a href="https://github.com/robinmoisson/staticrypt/issues/19">#19</a>). The password is
hashed with PBKDF2 (599k iterations with SHA-256, plus 1k with SHA-1 for legacy reasons (see
<a href="https://github.com/robinmoisson/staticrypt/issues/159">#159</a>), for the added
<a
href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2"
>recommended total</a
>
of 600k) and used to encrypt the page.
</p>
<p>
It basically encrypts your page and puts everything with a user-friendly way to use a
password in the new file. AES-256 is state of the art but
<b
>brute-force/dictionary attacks would be easy to do at a really fast pace: use a long,
unusual password!</b
>
<br />
=> To be safe, we recommend 16+ alphanum characters, and using a password manager like the
open-source <a href="http://bitwarden.com">Bitwarden</a>.
</p>
<p>
Feel free to contribute or report any thought to the
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>.
</p>
</div>
<br />
</div>
</div>
<div class="row">
<div class="col-xs-12">
<form id="encrypt_form">
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
class="form-control"
id="password"
placeholder="Password (choose a long one!)"
/>
</div>
<div class="form-group">
<label for="unencrypted_html">HTML/string to encrypt</label>
<textarea
class="form-control"
id="unencrypted_html"
placeholder="<html><head>..."
rows="5"
></textarea>
</div>
<div class="form-group">
<label class="no-style">
<input type="checkbox" id="remember" checked />
Add "Remember me" checkbox (append <code>#staticrypt_logout</code> to your URL to
logout)
<small>
<abbr
class="text-muted"
title='The password will be stored in clear text in the browser's localStorage upon entry by the user. See "More options" to set the expiration (default: none)'
>
(?)
</abbr>
</small>
</label>
</div>
<p>
<a href="#" id="toggle-extra-option">+ More options</a>
</p>
<div id="extra-options" class="hidden">
<div class="form-group">
<label for="template_title">Page title</label>
<input
type="text"
class="form-control"
id="template_title"
placeholder="Default: 'Protected Page'"
/>
</div>
<div class="form-group">
<label for="template_instructions">Instructions to display the user</label>
<textarea
class="form-control"
id="template_instructions"
placeholder="Default: nothing."
></textarea>
</div>
<div class="form-group">
<label for="template_placeholder">Password input placeholder</label>
<input
type="text"
class="form-control"
id="template_placeholder"
placeholder="Default: 'Password'"
/>
</div>
<div class="form-group">
<label for="template_remember">"Remember me" checkbox label</label>
<input
type="text"
class="form-control"
id="template_remember"
placeholder="Default: 'Remember me'"
/>
</div>
<div class="form-group">
<label for="remember_in_days">"Remember me" expiration in days</label>
<input
type="number"
class="form-control"
id="remember_in_days"
step="any"
placeholder="Default: 0 (no expiration)"
/>
<small class="form-text text-muted">
After this many days, the user will have to enter the password again. Leave empty or
set to 0 for no expiration.
</small>
</div>
<div class="form-group">
<label for="template_button">Decrypt button label</label>
<input
type="text"
class="form-control"
id="template_button"
placeholder="Default: 'DECRYPT'"
/>
</div>
<div class="form-group">
<label for="template_color_primary">Primary color (button, ...)</label>
<input
type="text"
class="form-control"
id="template_color_primary"
placeholder="Default: '#4CAF50'"
/>
</div>
<div class="form-group">
<label for="template_color_secondary">Secondary color (background, ...)</label>
<input
type="text"
class="form-control"
id="template_color_secondary"
placeholder="Default: '#76B852'"
/>
</div>
</div>
<button class="btn btn-primary pull-right" type="submit">
Generate password protected HTML
</button>
</form>
</div>
</div>
<div class="row mb-5">
<div class="col-xs-12">
<h2>Encrypted HTML</h2>
<p>
<a
class="btn btn-success download"
download="encrypted.html"
id="download-link"
disabled="disabled"
>Download html file with password prompt</a
>
</p>
<pre id="encrypted_html_display">Your encrypted string</pre>
</div>
</div>
</div>
<div class="footer">
Thank you for using StatiCrypt - I hope you like the tool!
<br />
If you'd like to support it you can
<a href="https://github.com/sponsors/robinmoisson" target="_blank">sponsor me on github</a>, or check-out my
other project to <a href="https://translateabook.com" target="_blank">Translate a Book</a> with LLMs.
</div>
<script id="cryptoEngine">
window.cryptoEngine = ((function(){
const exports = {};
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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;
return exports;
})());
</script>
<script id="codec">
window.codec = ((function(){
const exports = {};
/**
* 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;
return exports;
})());
</script>
<script id="formater">
window.formater = ((function(){
const exports = {};
/**
* 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;
return exports;
})());
</script>
<script id="staticrypt">
window.staticrypt = ((function(){
const exports = {};
const cryptoEngine = ((function(){
const exports = {};
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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;
return exports;
})());
const codec = ((function(){
const exports = {};
/**
* 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;
return exports;
})());
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<boolean>}
*/
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<boolean>} 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;
return exports;
})());
</script>
<script>
const encode = codec.init(cryptoEngine).encode;
let htmlToDownload;
/**
* Extract js code from <script> tag and return it as a string
*
* @param {string} id
* @returns {string}
*/
function getScriptAsString(id) {
return document.getElementById(id).innerText.replace(/window\.\w+ = /, "");
}
/**
* Register something happened - this uses a simple Supabase function to implement a counter, and allows to drop
* google analytics. We don't store any personal data or IP.
*
* @param {string} action
*/
function trackEvent(action) {
const xhr = new XMLHttpRequest();
xhr.open("POST", "https://zlgpaemmniviswibzuwt.supabase.co/rest/v1/rpc/increment_analytics", true);
xhr.setRequestHeader("Content-type", "application/json; charset=UTF-8");
xhr.setRequestHeader(
"apikey",
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpsZ3BhZW1tbml2aXN3aWJ6dXd0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjkxMjM0OTcsImV4cCI6MTk4NDY5OTQ5N30.wNoVDHG7F6INx-IPotMs3fL1nudfaF2qvQDgG-1PhNI"
);
xhr.setRequestHeader(
"Authorization",
"Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InpsZ3BhZW1tbml2aXN3aWJ6dXd0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2NjkxMjM0OTcsImV4cCI6MTk4NDY5OTQ5N30.wNoVDHG7F6INx-IPotMs3fL1nudfaF2qvQDgG-1PhNI"
);
xhr.send(
JSON.stringify({
action_input: action,
})
);
}
/**
* Fill the password prompt template with data provided.
* @param data
*/
function setFileToDownload(data) {
const request = new XMLHttpRequest();
request.open("GET", "lib/password_template.html", true);
request.onload = function () {
const renderedTmpl = formater.renderTemplate(request.responseText, data);
const downloadLink = document.querySelector("a.download");
downloadLink.href = "data:text/html," + encodeURIComponent(renderedTmpl);
downloadLink.removeAttribute("disabled");
htmlToDownload = renderedTmpl;
};
request.send();
}
// register page load
window.onload = function () {
trackEvent("show_index");
};
/**
* Handle form submission.
*/
document.getElementById("encrypt_form").addEventListener("submit", async function (e) {
e.preventDefault();
trackEvent("generate_encrypted");
const unencrypted = document.getElementById("unencrypted_html").value,
password = document.getElementById("password").value;
const salt = cryptoEngine.generateRandomSalt();
const encryptedMsg = await encode(unencrypted, password, salt);
const templateButton = document.getElementById("template_button").value,
templateColorPrimary = document.getElementById("template_color_primary").value,
templateColorSecondary = document.getElementById("template_color_secondary").value,
templateInstructions = document.getElementById("template_instructions").value,
isRememberEnabled = document.getElementById("remember").checked,
templateTitle = document.getElementById("template_title").value.trim(),
templatePlaceholder = document.getElementById("template_placeholder").value.trim(),
rememberDurationInDays = document.getElementById("remember_in_days").value || 0,
templateRemember = document.getElementById("template_remember").value;
const data = {
staticrypt_config: {
staticryptEncryptedMsgUniqueVariableName: encryptedMsg,
isRememberEnabled,
rememberDurationInDays,
staticryptSaltUniqueVariableName: salt,
},
is_remember_enabled: JSON.stringify(isRememberEnabled),
js_staticrypt: getScriptAsString("staticrypt"),
template_button: templateButton ? templateButton : "DECRYPT",
template_color_primary: templateColorPrimary || "#4CAF50",
template_color_secondary: templateColorSecondary || "#76B852",
template_instructions: templateInstructions || "",
template_placeholder: templatePlaceholder || "Password",
template_remember: templateRemember || "Remember me",
template_title: templateTitle || "Protected Page",
};
document.getElementById("encrypted_html_display").textContent = encryptedMsg;
setFileToDownload(data);
});
document.getElementById("toggle-extra-option").addEventListener("click", function (e) {
e.preventDefault();
document.getElementById("extra-options").classList.toggle("hidden");
});
let isConceptShown = false;
document.getElementById("toggle-concept").addEventListener("click", function (e) {
e.preventDefault();
isConceptShown = !isConceptShown;
document.getElementById("toggle-concept-sign").innerText = isConceptShown ? "▼" : "►";
document.getElementById("concept").classList.toggle("hidden");
});
/**
* Browser specific download code.
*/
document.getElementById("download-link").addEventListener("click", function (e) {
// only register the click event if there is actually a generated file
if (htmlToDownload) {
trackEvent("download_encrypted");
}
const isIE = navigator.userAgent.indexOf("MSIE") !== -1 || !!document.documentMode === true; // >= 10
const isEdge = navigator.userAgent.indexOf("Edge") !== -1;
// download with MS specific feature
if (htmlToDownload && (isIE || isEdge)) {
e.preventDefault();
const blobObject = new Blob([htmlToDownload]);
window.navigator.msSaveOrOpenBlob(blobObject, "encrypted.html");
}
return true;
});
</script>
</body>
</html>
================================================
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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<string>}
*/
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
================================================
<!DOCTYPE html>
<html class="staticrypt-html">
<head>
<meta charset="utf-8" />
<title>/*[|template_title|]*/0</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- do not cache this page -->
<meta http-equiv="cache-control" content="max-age=0" />
<meta http-equiv="cache-control" content="no-cache" />
<meta http-equiv="expires" content="0" />
<meta http-equiv="expires" content="Tue, 01 Jan 1980 1:00:00 GMT" />
<meta http-equiv="pragma" content="no-cache" />
<style>
.staticrypt-hr {
margin-top: 20px;
margin-bottom: 20px;
border: 0;
border-top: 1px solid #eee;
}
.staticrypt-page {
width: 360px;
padding: 8% 0 0;
margin: auto;
box-sizing: border-box;
}
.staticrypt-form {
position: relative;
z-index: 1;
background: #ffffff;
max-width: 360px;
margin: 0 auto 100px;
padding: 45px;
text-align: center;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24);
}
.staticrypt-form input[type="password"],
input[type="text"] {
background: inherit;
border: 0;
box-sizing: border-box; /* This ensures padding is included in the total width */
font-size: 14px;
outline: 0;
padding: 15px 30px 15px 15px; /* Adjust the padding to ensure there is space for the icon */
width: 100%;
}
.staticrypt-password-container {
position: relative;
outline: 0;
background: #f2f2f2;
width: 100%;
border: 0;
margin: 0 0 15px;
box-sizing: border-box;
}
.staticrypt-toggle-password-visibility {
cursor: pointer;
height: 20px;
opacity: 60%;
padding: 13px;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
}
.staticrypt-form .staticrypt-decrypt-button {
text-transform: uppercase;
outline: 0;
background: /*[|template_color_primary|]*/ 0;
width: 100%;
border: 0;
padding: 15px;
color: #ffffff;
font-size: 14px;
cursor: pointer;
}
.staticrypt-form .staticrypt-decrypt-button:hover,
.staticrypt-form .staticrypt-decrypt-button:active,
.staticrypt-form .staticrypt-decrypt-button:focus {
background: /*[|template_color_primary|]*/ 0;
filter: brightness(92%);
}
.staticrypt-html {
height: 100%;
}
.staticrypt-body {
height: 100%;
margin: 0;
}
.staticrypt-content {
height: 100%;
margin-bottom: 1em;
background: /*[|template_color_secondary|]*/ 0;
font-family: "Arial", sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.staticrypt-instructions {
margin-top: -1em;
margin-bottom: 1em;
}
.staticrypt-title {
font-size: 1.5em;
}
label.staticrypt-remember {
display: flex;
align-items: center;
margin-bottom: 1em;
}
.staticrypt-remember input[type="checkbox"] {
transform: scale(1.5);
margin-right: 1em;
}
.hidden {
display: none !important;
}
.staticrypt-spinner-container {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.staticrypt-spinner {
display: inline-block;
width: 2rem;
height: 2rem;
vertical-align: text-bottom;
border: 0.25em solid gray;
border-right-color: transparent;
border-radius: 50%;
-webkit-animation: spinner-border 0.75s linear infinite;
animation: spinner-border 0.75s linear infinite;
animation-duration: 0.75s;
animation-timing-function: linear;
animation-delay: 0s;
animation-iteration-count: infinite;
animation-direction: normal;
animation-fill-mode: none;
animation-play-state: running;
animation-name: spinner-border;
}
@keyframes spinner-border {
100% {
transform: rotate(360deg);
}
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.staticrypt-form input[type="password"],
input[type="text"] {
font-size: 16px;
}
}
</style>
</head>
<body class="staticrypt-body">
<div id="staticrypt_loading" class="staticrypt-spinner-container">
<div class="staticrypt-spinner"></div>
</div>
<div id="staticrypt_content" class="staticrypt-content hidden">
<div class="staticrypt-page">
<div class="staticrypt-form">
<div class="staticrypt-instructions">
<p class="staticrypt-title">/*[|template_title|]*/0</p>
<p>/*[|template_instructions|]*/0</p>
</div>
<hr class="staticrypt-hr" />
<form id="staticrypt-form" action="#" method="post">
<div class="staticrypt-password-container">
<input
id="staticrypt-password"
type="password"
name="password"
placeholder="/*[|template_placeholder|]*/0"
autofocus
/>
<img
class="staticrypt-toggle-password-visibility"
alt="/*[|template_toggle_show|]*/0"
title="/*[|template_toggle_show|]*/0"
src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTM4LjggNS4xQzI4LjQtMy4xIDEzLjMtMS4yIDUuMSA5LjJTLTEuMiAzNC43IDkuMiA0Mi45bDU5MiA0NjRjMTAuNCA4LjIgMjUuNSA2LjMgMzMuNy00LjFzNi4zLTI1LjUtNC4xLTMzLjdMNTI1LjYgMzg2LjdjMzkuNi00MC42IDY2LjQtODYuMSA3OS45LTExOC40YzMuMy03LjkgMy4zLTE2LjcgMC0yNC42Yy0xNC45LTM1LjctNDYuMi04Ny43LTkzLTEzMS4xQzQ2NS41IDY4LjggNDAwLjggMzIgMzIwIDMyYy02OC4yIDAtMTI1IDI2LjMtMTY5LjMgNjAuOEwzOC44IDUuMXpNMjIzLjEgMTQ5LjVDMjQ4LjYgMTI2LjIgMjgyLjcgMTEyIDMyMCAxMTJjNzkuNSAwIDE0NCA2NC41IDE0NCAxNDRjMCAyNC45LTYuMyA0OC4zLTE3LjQgNjguN0w0MDggMjk0LjVjOC40LTE5LjMgMTAuNi00MS40IDQuOC02My4zYy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM2MwIDEwLjItMi40IDE5LjgtNi42IDI4LjNsLTkwLjMtNzAuOHpNMzczIDM4OS45Yy0xNi40IDYuNS0zNC4zIDEwLjEtNTMgMTAuMWMtNzkuNSAwLTE0NC02NC41LTE0NC0xNDRjMC02LjkgLjUtMTMuNiAxLjQtMjAuMkw4My4xIDE2MS41QzYwLjMgMTkxLjIgNDQgMjIwLjggMzQuNSAyNDMuN2MtMy4zIDcuOS0zLjMgMTYuNyAwIDI0LjZjMTQuOSAzNS43IDQ2LjIgODcuNyA5MyAxMzEuMUMxNzQuNSA0NDMuMiAyMzkuMiA0ODAgMzIwIDQ4MGM0Ny44IDAgODkuOS0xMi45IDEyNi4yLTMyLjVMMzczIDM4OS45eiIvPjwvc3ZnPg=="
/>
</div>
<label id="staticrypt-remember-label" class="staticrypt-remember hidden">
<input id="staticrypt-remember" type="checkbox" name="remember" />
/*[|template_remember|]*/0
</label>
<input type="submit" class="staticrypt-decrypt-button" value="/*[|template_button|]*/0" />
</form>
</div>
</div>
</div>
<script>
// these variables will be filled when generating the file - the template format is '/*[|variable_name|]*/0'
const staticryptInitiator = /*[|js_staticrypt|]*/ 0;
const templateError = "/*[|template_error|]*/0",
templateToggleAltShow = "/*[|template_toggle_show|]*/0",
templateToggleAltHide = "/*[|template_toggle_hide|]*/0",
isRememberEnabled = /*[|is_remember_enabled|]*/ 0,
staticryptConfig = /*[|staticrypt_config|]*/ 0;
// you can edit these values to customize some of the behavior of StatiCrypt
const templateConfig = {
rememberExpirationKey: "staticrypt_expiration",
rememberPassphraseKey: "staticrypt_passphrase",
replaceHtmlCallback: null,
clearLocalStorageCallback: null,
};
// init the staticrypt engine
const staticrypt = staticryptInitiator.init(staticryptConfig, templateConfig);
// try to automatically decrypt on load if there is a saved password
window.onload = async function () {
const { isSuccessful } = await staticrypt.handleDecryptOnLoad();
// if we didn't decrypt anything on load, show the password prompt. Otherwise the content has already been
// replaced, no need to do anything
if (!isSuccessful) {
// hide loading screen
document.getElementById("staticrypt_loading").classList.add("hidden");
document.getElementById("staticrypt_content").classList.remove("hidden");
document.getElementById("staticrypt-password").focus();
// show the remember me checkbox
if (isRememberEnabled) {
document.getElementById("staticrypt-remember-label").classList.remove("hidden");
}
}
};
// toggle password visibility
const toggleIcon = document.querySelector(".staticrypt-toggle-password-visibility");
// these two icons are coming from FontAwesome
const imgSrcEyeClosed =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NDAgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTM4LjggNS4xQzI4LjQtMy4xIDEzLjMtMS4yIDUuMSA5LjJTLTEuMiAzNC43IDkuMiA0Mi45bDU5MiA0NjRjMTAuNCA4LjIgMjUuNSA2LjMgMzMuNy00LjFzNi4zLTI1LjUtNC4xLTMzLjdMNTI1LjYgMzg2LjdjMzkuNi00MC42IDY2LjQtODYuMSA3OS45LTExOC40YzMuMy03LjkgMy4zLTE2LjcgMC0yNC42Yy0xNC45LTM1LjctNDYuMi04Ny43LTkzLTEzMS4xQzQ2NS41IDY4LjggNDAwLjggMzIgMzIwIDMyYy02OC4yIDAtMTI1IDI2LjMtMTY5LjMgNjAuOEwzOC44IDUuMXpNMjIzLjEgMTQ5LjVDMjQ4LjYgMTI2LjIgMjgyLjcgMTEyIDMyMCAxMTJjNzkuNSAwIDE0NCA2NC41IDE0NCAxNDRjMCAyNC45LTYuMyA0OC4zLTE3LjQgNjguN0w0MDggMjk0LjVjOC40LTE5LjMgMTAuNi00MS40IDQuOC02My4zYy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM2MwIDEwLjItMi40IDE5LjgtNi42IDI4LjNsLTkwLjMtNzAuOHpNMzczIDM4OS45Yy0xNi40IDYuNS0zNC4zIDEwLjEtNTMgMTAuMWMtNzkuNSAwLTE0NC02NC41LTE0NC0xNDRjMC02LjkgLjUtMTMuNiAxLjQtMjAuMkw4My4xIDE2MS41QzYwLjMgMTkxLjIgNDQgMjIwLjggMzQuNSAyNDMuN2MtMy4zIDcuOS0zLjMgMTYuNyAwIDI0LjZjMTQuOSAzNS43IDQ2LjIgODcuNyA5MyAxMzEuMUMxNzQuNSA0NDMuMiAyMzkuMiA0ODAgMzIwIDQ4MGM0Ny44IDAgODkuOS0xMi45IDEyNi4yLTMyLjVMMzczIDM4OS45eiIvPjwvc3ZnPg==";
const imgSrcEyeOpened =
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA1NzYgNTEyIj48IS0tIUZvbnQgQXdlc29tZSBGcmVlIDYuNS4yIGJ5IEBmb250YXdlc29tZSAtIGh0dHBzOi8vZm9udGF3ZXNvbWUuY29tIExpY2Vuc2UgLSBodHRwczovL2ZvbnRhd2Vzb21lLmNvbS9saWNlbnNlL2ZyZWUgQ29weXJpZ2h0IDIwMjQgRm9udGljb25zLCBJbmMuLS0+PHBhdGggZD0iTTI4OCAzMmMtODAuOCAwLTE0NS41IDM2LjgtMTkyLjYgODAuNkM0OC42IDE1NiAxNy4zIDIwOCAyLjUgMjQzLjdjLTMuMyA3LjktMy4zIDE2LjcgMCAyNC42QzE3LjMgMzA0IDQ4LjYgMzU2IDk1LjQgMzk5LjRDMTQyLjUgNDQzLjIgMjA3LjIgNDgwIDI4OCA0ODBzMTQ1LjUtMzYuOCAxOTIuNi04MC42YzQ2LjgtNDMuNSA3OC4xLTk1LjQgOTMtMTMxLjFjMy4zLTcuOSAzLjMtMTYuNyAwLTI0LjZjLTE0LjktMzUuNy00Ni4yLTg3LjctOTMtMTMxLjFDNDMzLjUgNjguOCAzNjguOCAzMiAyODggMzJ6TTE0NCAyNTZhMTQ0IDE0NCAwIDEgMSAyODggMCAxNDQgMTQ0IDAgMSAxIC0yODggMHptMTQ0LTY0YzAgMzUuMy0yOC43IDY0LTY0IDY0Yy03LjEgMC0xMy45LTEuMi0yMC4zLTMuM2MtNS41LTEuOC0xMS45IDEuNi0xMS43IDcuNGMuMyA2LjkgMS4zIDEzLjggMy4yIDIwLjdjMTMuNyA1MS4yIDY2LjQgODEuNiAxMTcuNiA2Ny45czgxLjYtNjYuNCA2Ny45LTExNy42Yy0xMS4xLTQxLjUtNDcuOC02OS40LTg4LjYtNzEuMWMtNS44LS4yLTkuMiA2LjEtNy40IDExLjdjMi4xIDYuNCAzLjMgMTMuMiAzLjMgMjAuM3oiLz48L3N2Zz4=";
toggleIcon.addEventListener("click", function () {
const passwordInput = document.getElementById("staticrypt-password");
if (passwordInput.type === "password") {
passwordInput.type = "text";
toggleIcon.src = imgSrcEyeOpened;
toggleIcon.alt = templateToggleAltHide;
toggleIcon.title = templateToggleAltHide;
} else {
passwordInput.type = "password";
toggleIcon.src = imgSrcEyeClosed;
toggleIcon.alt = templateToggleAltShow;
toggleIcon.title = templateToggleAltShow;
}
});
// handle password form submission
document.getElementById("staticrypt-form").addEventListener("submit", async function (e) {
e.preventDefault();
const password = document.getElementById("staticrypt-password").value,
isRememberChecked = document.getElementById("staticrypt-remember").checked;
const { isSuccessful } = await staticrypt.handleDecryptionOfPage(password, isRememberChecked);
if (!isSuccessful) {
alert(templateError);
}
});
</script>
</body>
</html>
================================================
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<boolean>}
*/
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<boolean>} 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
================================================
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>StatiCrypt: Password protect static HTML</title>
<meta name="description" content="" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
rel="stylesheet"
type="text/css"
href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css"
integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u"
crossorigin="anonymous"
/>
<style>
a.no-style {
color: inherit;
text-decoration: inherit;
}
body {
font-size: 16px;
}
label.no-style {
font-weight: normal;
}
@media screen and (-webkit-min-device-pixel-ratio: 0) {
.staticrypt-form input[type="password"],
input[type="text"] {
font-size: 16px;
}
}
.footer {
width: 100%;
background-color: #f8f9fa;
padding: 20px;
text-align: center;
margin-top: 10em;
}
</style>
<!-- point to my other project as the canonical in the eyes of google - the two projects are kept in sync, and people can still use the github page hosted one for maximum transparency -->
<link rel="canonical" href="https://translateabook.com/staticrypt/" />
</head>
<body>
<div class="container">
<div class="row">
<div class="col-xs-12">
<h1>
StatiCrypt
<div class="pull-right">
<iframe
src="https://ghbtns.com/github-btn.html?user=robinmoisson&repo=staticrypt&type=star&size=large"
frameborder="0"
scrolling="0"
width="80px"
height="30px"
></iframe>
<iframe
src="https://ghbtns.com/github-btn.html?user=robinmoisson&repo=staticrypt&type=fork&size=large"
frameborder="0"
scrolling="0"
width="80px"
height="30px"
></iframe>
</div>
<br />
<small>Password protect a static HTML page</small>
</h1>
<p>
StatiCrypt uses AES-256 with WebCrypto to encrypt your html string with your long password, in
your browser (client side).
</p>
<p>
Download your encrypted string in a HTML page with a password prompt you can upload anywhere
(see <a target="_blank" href="example/encrypted/example.html">example</a>).
</p>
<p>
The tool is also available as
<a href="https://npmjs.com/package/staticrypt">a CLI on NPM</a> and is
<a href="https://github.com/robinmoisson/staticrypt">open source on GitHub</a>.
</p>
<br />
<h4>
<a class="no-style" id="toggle-concept" href="#">
<span id="toggle-concept-sign">►</span> HOW IT WORKS
</a>
</h4>
<div id="concept" class="hidden">
<p>
<b class="text-danger">Disclaimer</b> if you are an at-risk activist, or have extra
sensitive banking data, you should probably use something else!
</p>
<p>
StatiCrypt generates a static, password protected page that can be decrypted in-browser:
just send or upload the generated page to a place serving static content (github pages, for
example) and you're done: the javascript will prompt users for password, decrypt the page
and load your HTML.
</p>
<p>
The page is encrypted with AES-256 in CBC mode (see why this mode is appropriate for
StatiCrypt in
<a href="https://github.com/robinmoisson/staticrypt/issues/19">#19</a>). The password is
hashed with PBKDF2 (599k iterations with SHA-256, plus 1k with SHA-1 for legacy reasons (see
<a href="https://github.com/robinmoisson/staticrypt/issues/159">#159</a>), for the added
<a
href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2"
>recommended total</a
>
of 600k) and used to encrypt the page.
</p>
<p>
It basically encrypts your page and puts everything with a user-friendly way to use a
password in the new file. AES-256 is state of the art but
<b
>brute-force/dictionary attacks would be easy to do at a really fast pace: use a long,
unusual password!</b
>
<br />
=> To be safe, we recommend 16+ alphanum characters, and using a password manager like the
open-source <a href="http://bitwarden.com">Bitwarden</a>.
</p>
<p>
Feel free to contribute or report any thought to the
<a href="https://github.com/robinmoisson/staticrypt">GitHub project</a>.
</p>
</div>
<br />
</div>
</div>
<div class="row">
<div class="col-xs-12">
<form id="encrypt_form">
<div class="form-group">
<label for="password">Password</label>
<input
type="password"
class="form-control"
id="password"
placeholder="Password (choose a long one!)"
/>
</div>
<div class="form-group">
<label for="unencrypted_html">HTML/string to encrypt</label>
<textarea
class="form-control"
id="unencrypted_html"
placeholder="<html><head>..."
rows="5"
></textarea>
</div>
<div class="form-group">
<label class="no-style">
<input type="checkbox" id="remember" checked />
Add "Remember me" checkbox (append <code>#staticrypt_logout</code> to your URL to
logout)
<small>
<abbr
class="text-muted"
title='The password will be stored in clear text in the browser's localStorage upon entry by the user. See "More options" to set the expiration (default: none)'
>
(?)
</abbr>
</small>
</label>
</div>
<p>
<a href="#" id="toggle-extra-option">+ More options</a>
</p>
<div id="extra-options" class="hidden">
<div class="form-group">
<label for="template_title">Page title</label>
<input
type="text"
class="form-control"
id="template_title"
placeholder="Default: 'Protected Page'"
/>
</div>
<div class="form-group">
<label for="template_instructions">Instructions to display the user</label>
<textarea
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
SYMBOL INDEX (43 symbols across 6 files)
FILE: cli/helpers.js
constant PASSWORD_TEMPLATE_DEFAULT_PATH (line 9) | const PASSWORD_TEMPLATE_DEFAULT_PATH = pathModule.join(__dirname, "..", ...
constant OUTPUT_DIRECTORY_DEFAULT_PATH (line 10) | const OUTPUT_DIRECTORY_DEFAULT_PATH = "encrypted";
function exitWithError (line 16) | function exitWithError(message) {
function isOptionSetByUser (line 34) | function isOptionSetByUser(option, yargs) {
function prompt (line 62) | function prompt(question) {
function validatePassword (line 81) | async function validatePassword(password, isShortAllowed) {
function getConfig (line 105) | function getConfig(configPath) {
function writeConfig (line 114) | function writeConfig(configPath, config) {
function getPassword (line 127) | async function getPassword(passwordArgument) {
function getFileContent (line 149) | function getFileContent(filepath) {
function getValidatedSalt (line 163) | function getValidatedSalt(namedArgs, config) {
function getSalt (line 184) | function getSalt(namedArgs, config) {
function convertCommonJSToBrowserJS (line 206) | function convertCommonJSToBrowserJS(modulePath) {
function buildStaticryptJS (line 231) | function buildStaticryptJS() {
function readFile (line 248) | function readFile(filePath, errorName = "file") {
function genFile (line 264) | function genFile(data, outputFilePath, templateFilePath) {
function getFullOutputPath (line 279) | function getFullOutputPath(path, fullRootDirectory, outputDirectory) {
function copyFile (line 289) | function copyFile(inputFilePath, outputFilePath) {
function writeFile (line 305) | function writeFile(filePath, contents) {
function createDirectoryStructureForFile (line 321) | function createDirectoryStructureForFile(filePath) {
function isCustomPasswordTemplateDefault (line 332) | function isCustomPasswordTemplateDefault(templatePathParameter) {
function recursivelyApplyCallbackToHtmlFiles (line 344) | function recursivelyApplyCallbackToHtmlFiles(callback, path, outputDirec...
function parseCommandLineArguments (line 369) | function parseCommandLineArguments() {
FILE: cli/index.js
function runStatiCrypt (line 44) | async function runStatiCrypt() {
function decodeAndGenerateFile (line 172) | async function decodeAndGenerateFile(path, fullRootDirectory, hashedPass...
function encodeAndGenerateFile (line 196) | async function encodeAndGenerateFile(
FILE: lib/codec.js
function init (line 6) | function init(cryptoEngine) {
FILE: lib/cryptoEngine.js
constant IV_BITS (line 4) | const IV_BITS = 16 * 8;
constant HEX_BITS (line 5) | const HEX_BITS = 4;
constant ENCRYPTION_ALGO (line 6) | const ENCRYPTION_ALGO = "AES-CBC";
function encrypt (line 67) | async function encrypt(msg, hashedPassword) {
function decrypt (line 95) | async function decrypt(encryptedMsg, hashedPassword) {
function hashPassword (line 122) | async function hashPassword(password, salt) {
function hashLegacyRound (line 141) | function hashLegacyRound(password, salt) {
function hashSecondRound (line 154) | function hashSecondRound(hashedPassword, salt) {
function hashThirdRound (line 167) | function hashThirdRound(hashedPassword, salt) {
function pbkdf2 (line 181) | async function pbkdf2(password, salt, iterations, hashAlgorithm) {
function generateRandomSalt (line 198) | function generateRandomSalt() {
function signMessage (line 205) | async function signMessage(hashedPassword, message) {
function getRandomAlphanum (line 222) | function getRandomAlphanum() {
function generateRandomString (line 248) | function generateRandomString(length) {
FILE: lib/formater.js
function renderTemplate (line 13) | function renderTemplate(templateString, data) {
FILE: lib/staticryptJs.js
function init (line 22) | function init(staticryptConfig, templateConfig) {
Condensed preview — 26 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (217K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 738,
"preview": "# These are supported funding model platforms\n\ngithub: robinmoisson\npatreon: # Replace with a single Patreon username\nop"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 326,
"preview": "---\nname: Bug report\nabout: Open a bug report\ntitle: ''\nlabels: bug\nassignees: ''\n\n---\n\nI'm trying to...\n\n### What's hap"
},
{
"path": ".github/ISSUE_TEMPLATE/new-issue.md",
"chars": 108,
"preview": "---\nname: New issue\nabout: Any issue that isn't just a bug report\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\n\n"
},
{
"path": ".gitignore",
"chars": 97,
"preview": ".idea\n.vscode/\nnode_modules\n.staticrypt.json\n.env\nencrypted/\n!example/encrypted/\ndecrypted/\ntest/"
},
{
"path": ".husky/pre-commit",
"chars": 16,
"preview": "npx lint-staged\n"
},
{
"path": ".prettierignore",
"chars": 60,
"preview": "node_modules\nexample/encrypted\npackage-lock.json\nindex.html\n"
},
{
"path": ".prettierrc.json",
"chars": 278,
"preview": "{\n \"printWidth\": 120,\n \"semi\": true,\n \"singleQuote\": false,\n \"tabWidth\": 4,\n \"trailingComma\": \"es5\",\n "
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 1288,
"preview": "The code of conduct is here to set the tone of contributions to StatiCrypt, help turn difficult situations into growth, "
},
{
"path": "LICENSE",
"chars": 1070,
"preview": "MIT License\n\nCopyright (c) 2017 Robin Moisson\n\nPermission is hereby granted, free of charge, to any person obtaining a c"
},
{
"path": "MIGRATING.md",
"chars": 2313,
"preview": "# Migration guide\n\n## From 2.x to 3.x\n\nStatiCrypt 3.x brings a number of improvements: strong default security with WebC"
},
{
"path": "README.md",
"chars": 23604,
"preview": "<p align=\"center\"><a href=\"https://robinmoisson.github.io/staticrypt/example/encrypted/example.html\"><img src=\"preview.p"
},
{
"path": "SECURITY.md",
"chars": 5228,
"preview": "# Security Policy\n\n## Supported Versions\n\nThese versions will receive security updates:\n\n| Version | Security updates |\n"
},
{
"path": "cli/helpers.js",
"chars": 16347,
"preview": "const pathModule = require(\"path\");\nconst fs = require(\"fs\");\nconst readline = require(\"readline\");\n\nconst { generateRan"
},
{
"path": "cli/index.js",
"chars": 7556,
"preview": "#!/usr/bin/env node\n\n\"use strict\";\n\n// check node version before anything else\nconst nodeVersion = process.versions.node"
},
{
"path": "example/encrypted/example.html",
"chars": 35342,
"preview": "<!DOCTYPE html>\n<html class=\"staticrypt-html\">\n <head>\n <meta charset=\"utf-8\" />\n <title>Protected Page"
},
{
"path": "example/example.html",
"chars": 128,
"preview": "<h1>Many secrets</h1>\n<p>You unlocked me!</p>\n<p>Back to <a href=\"https://robinmoisson.github.io/staticrypt\">StatiCrypt<"
},
{
"path": "index.html",
"chars": 54366,
"preview": "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <title>StatiCrypt: Password protect static HT"
},
{
"path": "lib/codec.js",
"chars": 3960,
"preview": "/**\n * Initialize the codec with the provided cryptoEngine - this return functions to encode and decode messages.\n *\n * "
},
{
"path": "lib/cryptoEngine.js",
"chars": 7776,
"preview": "const crypto = typeof window === \"undefined\" ? require(\"node:crypto\").webcrypto : window.crypto;\nconst { subtle } = cryp"
},
{
"path": "lib/formater.js",
"chars": 898,
"preview": "/**\n * Replace the variable in template tags, between '/*[|variable|]* /0' (without the space in '* /0', ommiting it wou"
},
{
"path": "lib/password_template.html",
"chars": 15104,
"preview": "<!DOCTYPE html>\n<html class=\"staticrypt-html\">\n <head>\n <meta charset=\"utf-8\" />\n <title>/*[|template_t"
},
{
"path": "lib/staticryptJs.js",
"chars": 8209,
"preview": "const cryptoEngine = /*[|js_crypto_engine|]*/ 0;\nconst codec = /*[|js_codec|]*/ 0;\nconst decode = codec.init(cryptoEngin"
},
{
"path": "package.json",
"chars": 1771,
"preview": "{\n \"name\": \"staticrypt\",\n \"version\": \"3.5.4\",\n \"description\": \"Password protect a static HTML file without a ba"
},
{
"path": "scripts/build.sh",
"chars": 426,
"preview": "# Build the website files\n# Should be run with \"npm run build\" - npm handles the pathing better (so no \"#!/usr/bin/env\" "
},
{
"path": "scripts/buildIndex.js",
"chars": 407,
"preview": "const { convertCommonJSToBrowserJS, genFile, buildStaticryptJS } = require(\"../cli/helpers.js\");\n\nconst data = {\n js_"
},
{
"path": "scripts/index_template.html",
"chars": 21741,
"preview": "<!DOCTYPE html>\n<html>\n <head>\n <meta charset=\"utf-8\" />\n <title>StatiCrypt: Password protect static HT"
}
]
About this extraction
This page contains the full source code of the robinmoisson/staticrypt GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 26 files (204.3 KB), approximately 49.3k tokens, and a symbol index with 43 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.