Files
portable-secret/creator/index.html
2024-07-14 17:10:12 -04:00

410 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
body {
background-color: lavender;
font-size: large;
margin: 50px;
}
div {
margin: 5px;
}
button {
font-size: large;
padding: 12px 20px;
}
input {
font-family: monospace;
font-size: large;
}
textarea {
font-family: monospace;
font-size: large;
}
a.download_link {
font-size: xx-large;
}
</style>
<script>
const blockSize = 16 // bytes (for AES, IV)
const saltSize = 32 // bytes (for PBKDF2)
const minIterations = 1000000 // will reject a number below this (for PBKDF2)
const keySize = 32 // bytes (derived with PBKDF2, used by AES)
const inputElementIds = {
"message": "text_input_div",
"image": "image_input_div",
"file": "file_input_div",
}
let selectedInputType = ""
async function init() {
await refreshSalt()
await refreshIV()
await refreshIterations()
setMessage("Select secret type: message, image, or file")
}
async function encrypt() {
// All cryptography is delegated to the browser engine through.
// W3C Web Cryptography API standard
// https://www.w3.org/TR/WebCryptoAPI/
// No cryptography was hand-rolled in the making of this tool. ;-)
setMessage("⏳ Importing key...")
// Whatever array of bytes is in the password field
let password = new TextEncoder().encode(document.getElementById("password").value)
if (password.length == 0) {
throw new Error(`Empty password`)
}
// Import password into a Key suitable for use with Cryptography APIs
let passwordKey = await window.crypto.subtle.importKey(
"raw", // a puny array of bytes
password,
{name: "PBKDF2"}, // What will use this key
false, // key is not extractable
["deriveKey"] // What they can use it for
)
setMessage("⏳ Deriving key from password...")
// Salt input field (0x string)
let saltHexString = document.getElementById("salt").value
// Salt for password derivation with PBKDF2 (byte array)
let salt = hexStringToBytes(saltHexString)
if (salt.length != saltSize) {
throw new Error(`Unexpected salt length: ${salt.length}`)
}
// Salt for password derivation with PBKDF2 (byte array)
let iterations = document.getElementById("iterations").value
if (iterations < minIterations) {
throw new Error(`Number of PBKDF2 iteration is below minimum recommended: ${minIterations}`)
}
// 'Strech' a password into a cryptographically secure key of a given size
let key = await window.crypto.subtle.deriveKey(
{
name: "PBKDF2", // https://en.wikipedia.org/wiki/PBKDF2
salt: salt, // for flavor
iterations: iterations, // how long to stomp on the password
hash: "SHA-256", // Hash function, blessed by NIST
},
passwordKey, // Wrapped password
{
name: "AES-GCM", // What is this key for
length: keySize * 8 // key size in bits
},
false, // key is not extractable
["encrypt"]
)
setMessage("⏳ Preparing inputs...")
// IV input field (0x string)
let ivHexString = document.getElementById("iv").value
// IV for AES
let iv = hexStringToBytes(ivHexString)
if (iv.length != blockSize) {
throw new Error(`Unexpected IV length: ${iv.length}`)
}
let plainText = new Uint8Array()
let fileExtension = ""
// TODO move this messy stuff out of encrypt path
// TODO handle files with no extension (currently captures full filename as extension)
// TODO file and image are identical except for input field
if (selectedInputType == '') {
throw new Error(`Select input type (message, file, image)`)
} else if (selectedInputType == 'message') {
// Message input field, as array of bytes
plainText = new TextEncoder().encode(document.getElementById("text_input").value);
} else if (selectedInputType == 'image') {
files = document.getElementById("image_input").files
if (files.length < 1) {
throw new Error(`No file selected`)
}
f = files[0]
fileContent = await f.arrayBuffer()
plainText = new Uint8Array(fileContent)
fileExtension = f.name.split(".").pop()
} else if (selectedInputType == 'file') {
files = document.getElementById("file_input").files
if (files.length < 1) {
throw new Error(`No file selected`)
}
f = files[0]
fileContent = await f.arrayBuffer()
plainText = new Uint8Array(fileContent)
fileExtension = f.name.split(".").pop()
} else {
throw new Error(`Unhandled input type: '${selectedInputType}'`)
}
if (plainText.length <= 0) {
throw new Error(`Plaintext is empty`)
}
// Pad plaintext to block size, as describe in:
// https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS#5_and_PKCS#7
plaintText = function(input) {
output = []
padAmount = blockSize - (input.length % blockSize)
for (var i = 0; i < input.length; i++) {
output.push(input[i])
}
for (var i = 0; i < padAmount; i++) {
output.push(padAmount)
}
return Uint8Array.from(output);
}(plainText)
setMessage("⏳ Encrypting...")
// Encrypt with AES in 'Galois/Counter Mode' (integrity + confidentiality)
// https://en.wikipedia.org/wiki/Galois/Counter_Mode
let cipherBuffer = await window.crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: iv,
},
key,
plaintText
)
let cipherHexString = bytesToHexString(new Uint8Array(cipherBuffer))
return {
salt: saltHexString,
iv: ivHexString,
iterations: iterations,
cipher: cipherHexString,
extension: fileExtension,
}
}
async function createSecret() {
setMessage("⏳ Creating Secret...")
async function loadTemplate(name) {
response = await fetch("./" + name)
if (!response.ok) {
throw new Error(`Failed to retrieve: ${name} response: ${response.status}`)
}
return await response.text()
}
try {
setMessage("⏳ Begin encryption...")
// Return salt IV cipher as hex strings
let encryption = await encrypt()
document.getElementById("cipher").innerHTML = encryption.cipher //0x string
setMessage("⏳ Retrieving templates...")
const pageTemplate = await loadTemplate("secret-template.html")
const valuesTemplate = await loadTemplate("values.js")
const passwordHint = document.getElementById("password_hint").value
setMessage("⏳ Generating download blob...")
values = valuesTemplate
values = values.replaceAll("'{.SALT_SIZE}'", saltSize)
values = values.replaceAll("'{.BLOCK_SIZE}'", blockSize)
values = values.replaceAll("'{.KEY_SIZE}'", keySize)
values = values.replaceAll("'{.ITERATIONS}'", encryption.iterations)
values = values.replaceAll("'{.SALT_HEX}'", encryption.salt)
values = values.replaceAll("'{.IV_HEX}'", encryption.iv)
values = values.replaceAll("'{.CIPHER_HEX}'", encryption.cipher)
values = values.replaceAll("'{.SECRET_TYPE}'", selectedInputType)
values = values.replaceAll("'{.SECRET_EXTENSION}'", encryption.extension)
secretPage = pageTemplate
secretPage = secretPage.replaceAll("{{PASSWORD_HINT}}", passwordHint)
secretPage = secretPage.replaceAll("{{VALUES}}", values)
var blob = new Blob([secretPage], {'type':'text/html'});
document.getElementById("target_link").setAttribute("href", window.URL.createObjectURL(blob))
document.getElementById("target_link").hidden = false
setMessage("✅ Ready for download")
// To avoid unintentional Salt & IV reuse,
// pick new ones after each secret is created
await refreshIV()
await refreshSalt()
} catch (err) {
setMessage("❌ " + err)
}
}
async function setMessage(newMessage) {
document.getElementById("errormsg").innerHTML = newMessage
}
async function refreshSalt() {
let salt = crypto.getRandomValues(new Uint8Array(saltSize));
document.getElementById("salt").value = bytesToHexString(salt)
setMessage("Refreshed salt")
}
async function refreshIV() {
let iv = crypto.getRandomValues(new Uint8Array(blockSize));
document.getElementById("iv").value = bytesToHexString(iv)
setMessage("Refreshed IV")
}
async function refreshIterations() {
// N.B. This random number does not need to be cryptographically strong
// Round numbers such as 1M are more likely to have tables available.
// So just to make it a little harder, pick a slightly higher number.
maxExtra = 1000000
let r = crypto.getRandomValues(new Uint16Array(1))[0]
i = minIterations + (r % maxExtra)
document.getElementById("iterations").value = i
setMessage("Refreshed iterations count")
}
function bytesToHexString(input) {
for (var hex = [], i = 0; i < input.length; i++) {
var current = input[i] < 0 ? input[i] + 256 : input[i];
hex.push((current >>> 4).toString(16));
hex.push((current & 0xF).toString(16));
}
return hex.join("");
}
function hexStringToBytes(input) {
// TODO accepts invalid (non-hex) values, e.g. ZZZZ
for (var bytes = [], c = 0; c < input.length; c += 2) {
bytes.push(parseInt(input.substr(c, 2), 16));
}
return Uint8Array.from(bytes);
}
function setInputType(selectedType) {
selectedInputType = selectedType;
for (let type in inputElementIds) {
let element = document.getElementById(inputElementIds[type])
if (type == selectedType) {
element.hidden = false
} else {
element.hidden = true
}
}
setMessage("Ready to encrypt 👍")
}
</script>
</head>
<body onload="init()">
<h1><a href="https://mprimi.github.io/portable-secret/">Portable Secret</a>: Secret Creator</h1>
<p>
This tool runs entirely in your browser window. <strong>The secret never leaves your computer!</strong><br>
But don't take my word for it. Check out the <a href="https://github.com/mprimi/portable-secret/tree/main/creator" target="_blank">source code</a>!
</p>
<div>
Password:<br>
<input type="text" id="password" value="banana" required>
</div>
<div>
Password hint:<br>
<textarea rows="8" cols="80" id="password_hint" required>A yellow elongated fruit (technically a berry!)
6 letters, all lowercase.</textarea>
</div>
<!-- Radio selector for type of secret -->
<p>Secret type:</p>
<form>
<input type="radio" id="text_option" name="input_type" value="message" onclick="setInputType('message')">
<label for="text_option">Message</label>
<input type="radio" id="image_option" name="input_type" value="image" onclick="setInputType('image')">
<label for="image_option">Image</label>
<input type="radio" id="file_option" name="input_type" value="file" onclick="setInputType('file')">
<label for="file_option">File</label>
</form>
<!-- Input types, only one visible at time -->
<div id="text_input_div" hidden>
<h2>📝 Secret message</h2>
<textarea rows="16" cols="80" id="text_input" name="text_content"></textarea>
</div>
<div id="image_input_div" hidden>
<h2>🌆 Secret image</h2>
<input type="file" id="image_input" alt="Upload" name="image_content" accept="image/*">
</div>
<div id="file_input_div" hidden>
<h2>📎 Secret File</h2>
<input type="file" id="file_input" name="file_content">
</div>
<!-- Generate button, and Download link -->
<p><p>
<div>
<button type="button" onclick='createSecret()'>⚡️ Generate secret</button>
<span id="errormsg"></span>
</div>
<div>
<a id="target_link" class="download_link" download="secret.html" hidden>Save secret.html</a>
</div>
<!-- Show "advanced" inputs and outputs -->
<br>
<details>
<summary>Show more</summary>
<p>⚠️ Don't modify these settings unless you know what you are doing</p>
<div>
Salt: 0x
<input type="text" id="salt" size="33" value="❓" required>
<input type="button" value="🔄" onclick='refreshSalt()'>
<br>
IV: 0x
<input type="text" id="iv" size="33" value="❓" required>
<input type="button" value="🔄" onclick='refreshIV()'>
<p><small><i>Salt and IV are random input coming straight from your browser's Random Number Generator. Do not reuse across messages.</i></small></p>
</div>
<div>
PBKDF2 iterations:
<input id="iterations" type="number" value="1000000" required>
<input type="button" value="🔄" onclick='refreshIterations()'>
<p><small><i>Higher iteration count slow down key generation, making dictionary-based attack harder.</i></small></p>
</div>
<div>
Ciphertext:
<br>
<textarea rows="8" cols="80" id="cipher"></textarea>
</div>
</details>
</body>
</html>