mirror of
https://github.com/mprimi/portable-secret.git
synced 2026-01-14 10:07:58 -05:00
410 lines
13 KiB
HTML
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>
|