├── .gitignore ├── README.md └── html-vault /.gitignore: -------------------------------------------------------------------------------- 1 | *.html 2 | *.txt 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # html-vault 2 | 3 | Create self-contained HTML pages protecting secret information. Usage: 4 | 5 | ``` 6 | html-vault ~/Document/secret.txt protected.html 7 | ``` 8 | 9 | **Here's an [example HTML file](https://dividuum.de/html-vault-example.html) (password is "thisisanexample")** 10 | 11 | When called, the program requires you to enter a password. It will then 12 | generate the HTML output file. This may take a moment. Once completed, the 13 | generated `protected.html` file is a self-contained HTML file with the 14 | content of `secret.txt` embedded in a way that it can only be accessed if the 15 | password is known. You might then place this file on a hidden url 16 | for later access. 17 | 18 | If `secret.txt` happens to start with a `<` character, HTML content is 19 | assumed and the decoded content is directly shown without HTML-escaping 20 | anything. This can be used to encrypt self-contained HTML apps with 21 | everything inlined. 22 | 23 | Decoding uses browser based crypto. A derived password is generated 24 | using 5 million rounds of PBKDF2. The secret file content is AES-GCM 25 | encrypted using this derived password. 26 | 27 | # What it can and can't do 28 | 29 | * The generated HTML can of course not detect if it was copied. So you 30 | cannot know if your file is in the hands of an attacker. Placing the 31 | file on a secret https URL might make this a bit more unlikely but of 32 | course cannot prevent it. 33 | 34 | * If the browser or OS used to view the HTML file cannot be trusted, the 35 | encryption is useless as the plain password could be logged by a keylogger 36 | for later decryption and the password might still be in memory somewhere. 37 | This also includes rogue browser extensions with full DOM access. Once 38 | you're done using the decrypted page, close its browser tab. 39 | 40 | * If the password it too weak, bruteforcing the content might still be 41 | viable. PBKDF2 somewhat helps and the large number of rounds was chosen 42 | to make bruteforcing more difficult. 43 | 44 | * A server-side attacker might modify the HTML and inject code that 45 | exfiltrates the entered password. This might be mitigated by inspecting 46 | the HTML source code prior to entering the password. The generated 47 | HTML is reasonably small to make this easy. 48 | 49 | * There have been no reviews yet, but the source code should be (and stay!) 50 | easy to understand. 51 | 52 | # Status 53 | 54 | Unreviewed - use with caution. Feedback is welcome. 55 | 56 | # Requirements 57 | 58 | * Tested with a recent Chrome/Firefox version. Requires [SubtleCrypto](https://caniuse.com/#search=subtle) API. 59 | 60 | * Python2 or Python3 with [pycryptodome](https://www.pycryptodome.org/) 61 | 62 | # License 63 | 64 | BSD 2-clause 65 | -------------------------------------------------------------------------------- /html-vault: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # Copyright (c) 2022, Florian Wesch 4 | # 5 | # Redistribution and use in source and binary forms, with or without 6 | # modification, are permitted provided that the following conditions are met: 7 | # 8 | # 1. Redistributions of source code must retain the above copyright notice, this 9 | # list of conditions and the following disclaimer. 10 | # 2. Redistributions in binary form must reproduce the above copyright notice, 11 | # this list of conditions and the following disclaimer in the documentation 12 | # and/or other materials provided with the distribution. 13 | # 14 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 18 | # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 19 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 20 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 21 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 22 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 23 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | 25 | import hashlib, textwrap, sys, getpass, json, base64 26 | from html import escape 27 | from Cryptodome.Cipher import AES 28 | from Cryptodome.Random import get_random_bytes 29 | 30 | def generate_html(salt, nonce, ciphertext, iterations): 31 | return textwrap.dedent(u''' 32 | 33 | 34 | 35 | 36 | 37 | 38 | 67 | ''').strip() % json.dumps(dict( 68 | salt = base64.b64encode(salt).decode('utf-8'), 69 | nonce = base64.b64encode(nonce).decode('utf-8'), 70 | ciphertext = base64.b64encode(ciphertext).decode('utf-8'), 71 | iterations = iterations, 72 | ), sort_keys=True, indent=2) 73 | 74 | def create_html(plain, password, iterations=5*1000*1000): 75 | salt = get_random_bytes(32) 76 | dk = hashlib.pbkdf2_hmac('sha256', password, salt, iterations) 77 | cipher = AES.new(dk, AES.MODE_GCM) 78 | ciphertext, tag = cipher.encrypt_and_digest(plain.encode('utf-8')) 79 | return generate_html(salt, cipher.nonce, ciphertext+tag, iterations) 80 | 81 | if __name__ == "__main__": 82 | if len(sys.argv) < 3: 83 | print("%s [passphrase]" % sys.argv[0]) 84 | sys.exit(1) 85 | plain = open(sys.argv[1]).read() 86 | if plain and plain[0] != '<': 87 | plain = '
' + escape(plain) # bit of hack: use 
 for non-HTML
88 |     password = len(sys.argv) == 4 and sys.argv[3] or getpass.getpass('Password: ')
89 |     if isinstance(password, bytes):
90 |         password = password.decode(sys.stdin.encoding)
91 |     if len(password) < 8:
92 |         print("That's not a good password. Use at least 8 characters!")
93 |         sys.exit(1)
94 |     password = password.encode('utf-8')
95 |     print("Encrypting... (this might take a moment)")
96 |     with open(sys.argv[2], 'wb') as out:
97 |         out.write(create_html(plain, password).encode('utf-8'))
98 | 


--------------------------------------------------------------------------------