mirror of
https://github.com/mprimi/portable-secret.git
synced 2026-01-13 09:38:07 -05:00
298 lines
9.7 KiB
HTML
298 lines
9.7 KiB
HTML
<!DOCTYPE html>
|
|
<!--
|
|
Portable Secret generated with https://mprimi.github.io/portable-secret/
|
|
|
|
This file is self-contained, it embeds an encrypted payload.
|
|
It uses your browser's cryptograpy APIs to decrypt it, if you know the password.
|
|
-->
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<style>
|
|
body {
|
|
background-color: floralwhite;
|
|
font-size: large;
|
|
margin: 50px;
|
|
}
|
|
|
|
div {
|
|
margin: 5px;
|
|
}
|
|
|
|
pre {
|
|
padding: 5px;
|
|
white-space: pre-wrap;
|
|
word-break: keep-all;
|
|
}
|
|
|
|
button {
|
|
font-size: large;
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
input {
|
|
font-family: monospace;
|
|
}
|
|
|
|
textarea {
|
|
font-family: monospace;
|
|
}
|
|
|
|
.decrypted {
|
|
background-color: palegreen;
|
|
border: 2px dotted forestgreen;
|
|
}
|
|
|
|
.hint {
|
|
background-color: lavender;
|
|
border: 2px dashed black;
|
|
}
|
|
|
|
/*
|
|
pre.decrypted {
|
|
}
|
|
*/
|
|
|
|
img.decrypted {
|
|
padding: 12px 20px;
|
|
}
|
|
|
|
a.decrypted {
|
|
font-size: xx-large;
|
|
}
|
|
|
|
input.password_input {
|
|
font-size: large;
|
|
padding: 12px 20px;
|
|
}
|
|
</style>
|
|
<script>
|
|
// Display the encryption inputs on the page (invoked during body onload)
|
|
async function init() {
|
|
document.getElementById("secret_type").innerHTML = secretType
|
|
document.getElementById("salt").setAttribute("value", saltHex)
|
|
document.getElementById("iv").setAttribute("value", ivHex)
|
|
document.getElementById("cipher").innerHTML = cipherHex
|
|
|
|
if (secretType == 'file') {
|
|
document.getElementById("target_file").innerHTML = `Download file.${secretExt}`
|
|
}
|
|
|
|
document.getElementById("password").addEventListener("keydown", (event) => {
|
|
// Decrypt when the user hits the Enter key after entering their password.
|
|
if (event.key === "Enter") {
|
|
decrypt();
|
|
}
|
|
})
|
|
}
|
|
|
|
|
|
// Invoked when the 'Decrypt' button is pressed
|
|
async function decrypt() {
|
|
try {
|
|
setMessage("Generating key from password...")
|
|
|
|
// Load salt, convert hex string to byte array
|
|
let salt = hexStringToBytes(saltHex)
|
|
if (salt.length != saltSize) {
|
|
throw new Error(`Unexpected salt size: ${salt.length}`)
|
|
}
|
|
|
|
// Load IV, convert hex string to byte array
|
|
let iv = hexStringToBytes(ivHex)
|
|
if (iv.length != blockSize) {
|
|
throw new Error(`Unexpected IV size: ${iv.length}`)
|
|
}
|
|
|
|
// Load password, as byte array
|
|
let password = new TextEncoder().encode(document.getElementById("password").value)
|
|
if (password.length == 0) {
|
|
throw new Error(`Empty password`)
|
|
}
|
|
|
|
// Wrap password into a Key object, as required by cryptography APIs
|
|
let passwordKey = await window.crypto.subtle.importKey(
|
|
"raw", // Array of bytes
|
|
password,
|
|
{name: "PBKDF2"}, // What algorithm uses the key
|
|
false, // Cannot be extracted
|
|
["deriveKey"] // What the key is used for
|
|
)
|
|
|
|
// Derive a key from the password, using PBKDF2
|
|
let key = await window.crypto.subtle.deriveKey(
|
|
{
|
|
name: "PBKDF2", // https://en.wikipedia.org/wiki/PBKDF2
|
|
salt: salt,
|
|
iterations: iterations,
|
|
hash: "SHA-1", // As per standard v2.0
|
|
},
|
|
passwordKey, // Wrapped password
|
|
{
|
|
name: "AES-GCM", // What algorithm uses the key
|
|
length: keySize * 8, // Key bitsize
|
|
},
|
|
false, // Cannot be extracted
|
|
["decrypt"] // What the derived key is used for
|
|
)
|
|
|
|
setMessage("Decrypting...")
|
|
|
|
// Load ciphertext, convert hex string to byte array
|
|
let cipher = hexStringToBytes(cipherHex)
|
|
|
|
// Decrypt with AES-GCM
|
|
// https://en.wikipedia.org/wiki/Galois/Counter_Mode
|
|
let decryptedBuffer = await window.crypto.subtle.decrypt(
|
|
{
|
|
name: "AES-GCM", // Name of block cipher algorithm
|
|
iv: iv, // Initialization vector
|
|
},
|
|
key, // Derived key
|
|
cipher // Ciphertext
|
|
)
|
|
|
|
// Remove padding (added as necessary for block cipher)
|
|
// https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS#5_and_PKCS#7
|
|
decrypted = removePadding(new Uint8Array(decryptedBuffer))
|
|
|
|
// Render decrypted payload on the page
|
|
if (secretType == "message") {
|
|
// Decode bytes to UTF-8
|
|
plainText = new TextDecoder().decode(decrypted)
|
|
// Display the plaintext on the page
|
|
document.getElementById("target_text").innerHTML = plainText
|
|
document.getElementById("text_output_div").hidden = false
|
|
} else if (secretType == "image") {
|
|
// Transform image to base64 string
|
|
b64Data = btoa(decrypted.reduce((data, byte) => data + String.fromCharCode(byte), ''))
|
|
// Create 'data' URI
|
|
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
|
const imageData = `data:image/${secretExt};base64,${b64Data}`
|
|
// Display image inline
|
|
document.getElementById("target_image").setAttribute("src", imageData)
|
|
document.getElementById("image_output_div").hidden = false
|
|
} else if (secretType == "file") {
|
|
// Transform image to base64 string
|
|
b64Data = btoa(decrypted.reduce((data, byte) => data + String.fromCharCode(byte), ''))
|
|
// Create 'data' URI
|
|
// https://en.wikipedia.org/wiki/Data_URI_scheme
|
|
const fileData = `data:application/octet-stream;base64,${b64Data}`
|
|
// Activate download link
|
|
document.getElementById("target_file").setAttribute("href", fileData)
|
|
document.getElementById("target_file").setAttribute("download", `file.${secretExt}`)
|
|
document.getElementById("file_output_div").hidden = false
|
|
} else {
|
|
throw new Error(`Unknown secret type: ${secretType}`)
|
|
}
|
|
|
|
setMessage("Decrypted successfully")
|
|
|
|
} catch (err) {
|
|
// TODO better handle failing promises
|
|
setMessage(`Decryption failed: ${err}`)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Transform hexadecimal string to Uint8Array
|
|
function hexStringToBytes(input) {
|
|
for (var bytes = [], c = 0; c < input.length; c += 2) {
|
|
bytes.push(parseInt(input.substr(c, 2), 16));
|
|
}
|
|
return Uint8Array.from(bytes);
|
|
}
|
|
|
|
// The cleartext input must be padded to a multiple of the block size
|
|
// for encryption. This function removes the padding, expected to be
|
|
// compatible with PKCS#7 described in RFC 5652.
|
|
// https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS#5_and_PKCS#7
|
|
function removePadding(input) {
|
|
// Last byte is the amount of padding
|
|
padAmount = input[input.length-1]
|
|
unpaddedSize = input.length - padAmount
|
|
return input.slice(0, unpaddedSize)
|
|
}
|
|
|
|
// Update page with status of decryption
|
|
function setMessage(msg) {
|
|
document.getElementById("errormsg").innerHTML = msg
|
|
}
|
|
</script>
|
|
</head>
|
|
<body onload="init()">
|
|
|
|
<h1>This page contains a secret <span id="secret_type"></span></h1>
|
|
<h2>Enter the password to decrypt it</h2>
|
|
<h3>Created with <a href="https://mprimi.github.io/portable-secret/">Portable Secret</a></h3>
|
|
|
|
<p>
|
|
This file contains a secret (message, file, or image) that can be recovered if you know the password.<br>
|
|
The secret can be decrypted without an internet connection, this file has no dependencies and no data leaves the browser window.
|
|
</p>
|
|
|
|
<div>
|
|
<h4>Password hint:</h4>
|
|
<pre class="hint">A yellow edible fruit with elongated shape. 6 letters, all lowercase
|
|
|
|
(N.B. this is a _weak_ password. But for this example it will do)
|
|
</pre>
|
|
</div>
|
|
|
|
<div>
|
|
<h4>Password:</h4>
|
|
<input type="text" id="password" placeholder="See hint above" class="password_input" required>
|
|
</div>
|
|
|
|
<div>
|
|
<button type="button" onclick='decrypt()'>Decrypt</button>
|
|
<span id="errormsg"></span>
|
|
</div>
|
|
|
|
<div id="text_output_div" hidden>
|
|
<pre id="target_text" class="decrypted"></pre>
|
|
</div>
|
|
|
|
<div id="image_output_div" hidden>
|
|
<img id="target_image" class="decrypted">
|
|
</div>
|
|
|
|
<div id="file_output_div" hidden>
|
|
<a id="target_file" class="decrypted">Download</a>
|
|
</div>
|
|
|
|
<details>
|
|
<summary>Details</summary>
|
|
|
|
These are decryption inputs, that can be safely transmitted in the clear.
|
|
Without the correct password, they are useless.
|
|
|
|
<div>
|
|
Salt:
|
|
<input type="text" id="salt" value="" readonly>
|
|
</div>
|
|
|
|
<div>
|
|
IV:
|
|
<input type="text" id="iv" value="" readonly>
|
|
</div>
|
|
|
|
<div>
|
|
Ciphertext:<br>
|
|
<textarea rows="8" cols="80" id="cipher" readonly></textarea>
|
|
</div>
|
|
</details>
|
|
</body>
|
|
<script>
|
|
const secretType = "file"
|
|
const secretExt = "zip"
|
|
const saltSize = 16 // bytes
|
|
const blockSize = 16 // bytes
|
|
const keySize = 32 // bytes
|
|
const iterations = 1000000
|
|
const saltHex = "2129324c961c36f1f95f19a7ad3e6fbb"
|
|
const ivHex = "da90ba2419169b097b0d5c12adbafb89"
|
|
const cipherHex = "65eb864716b78d18ba170a32275328f7217c1b76f462d17589141d0007afd40bf0aadab0cc4f332e99d51208c834a78d043fec66d556ca1b28126f4d335cf54eabe755025efbe31d01d998c101ec8843134e19994a451605afca0b76533a3e85ec0b5fa98a2d2f2cb9e028842fcf5ab09b9fb999f6b651b914231f33d37ef4a515899cab577714f49ea275688d0b59074305c690dea30610bef138277a0929a4b5cc5d116c1a7dbcc088f3b06334bc40938dad673e7141bdebea661bd37103ab62e21cb3cfb81853a2d6b37a76a250fee2bf61b787a4bec53cb86a3a7099c122e38296b07eaf5bd87eee2b0f5c5311a38e698b03189ea60be0bcf360098453320abd69b6e9c0b9b5d3771eb0fd082a36722c7690e0d76f3bf54dae67e20a4e6ebfcd20424c3347ecee3d07fec2b10036d68fb5f742af91cb16265ecc25af8a2667a2a7c9916318f27f16daa684e1522c1cd6439327371519f593f744d06f98cc3597f0ea05167053463baaea2cb424dd9e940a3ea1bdc06a54e89429c3dbbcdc41a5329efbd367d175abe6f91cd08cae511ef9b4e7cc41ed416ec804fa8aaae1101706d6609910c6bac95511ed65efd4cf6db77f2886f0a7c624726876dd571dea081b25008c6aa003a809edc43a6a2363471ddf6adda08c110fd48c32bfd7c43e285889712b7796cbf77b730447be75471d0dfe7de3db900ee0c56daac30ecea12c0f62aefa9d53a8ecf7ca7f26df790b42789736446c10254a6832e5017eeba4ee3e52bb0fac7a0db30f82190705324e1fe9b42873365cd06acad450c6a0a6"
|
|
</script>
|
|
</html>
|