UNPKG

staticrypt

Version:

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).

258 lines (221 loc) 7.78 kB
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;