UNPKG

hscrypt

Version:

Encrypt Javascript bundles (at build time), inject+decrypt them into pages later (in the browser)

251 lines 14.1 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports._decrypt = exports.decryptAndCache = exports.fetchAndDecrypt = exports.decrypt = exports.inject = exports.DecryptionError = exports.encrypt = exports.clearCachedDecryptionKey = exports.getCachedDecryptionKey = exports.getLocalStorageKey = exports.LOCALSTORAGE_PREFIX = exports.SOURCE_PREFIX_ARRAY = exports.SOURCE_PREFIX = exports.HSCRYPT_CONFIG_VAR = exports.DEFAULT_ITERATIONS = void 0; const crypto_js_1 = __importDefault(require("crypto-js")); const ts_chacha20_1 = require("ts-chacha20"); const crypto_1 = require("./crypto"); const utils_1 = require("./utils"); const cache_1 = require("./cache"); var utils_2 = require("./utils"); Object.defineProperty(exports, "DEFAULT_ITERATIONS", { enumerable: true, get: function () { return utils_2.DEFAULT_ITERATIONS; } }); Object.defineProperty(exports, "HSCRYPT_CONFIG_VAR", { enumerable: true, get: function () { return utils_2.HSCRYPT_CONFIG_VAR; } }); Object.defineProperty(exports, "SOURCE_PREFIX", { enumerable: true, get: function () { return utils_2.SOURCE_PREFIX; } }); Object.defineProperty(exports, "SOURCE_PREFIX_ARRAY", { enumerable: true, get: function () { return utils_2.SOURCE_PREFIX_ARRAY; } }); var cache_2 = require("./cache"); Object.defineProperty(exports, "LOCALSTORAGE_PREFIX", { enumerable: true, get: function () { return cache_2.LOCALSTORAGE_PREFIX; } }); Object.defineProperty(exports, "getLocalStorageKey", { enumerable: true, get: function () { return cache_2.getLocalStorageKey; } }); Object.defineProperty(exports, "getCachedDecryptionKey", { enumerable: true, get: function () { return cache_2.getCachedDecryptionKey; } }); Object.defineProperty(exports, "clearCachedDecryptionKey", { enumerable: true, get: function () { return cache_2.clearCachedDecryptionKey; } }); var encrypt_1 = require("./encrypt"); Object.defineProperty(exports, "encrypt", { enumerable: true, get: function () { return encrypt_1.encrypt; } }); function checkStatus(response) { if (!response.ok) { throw new Error(`HTTP ${response.status} - ${response.statusText}`); } return response; } class DecryptionError extends Error { constructor(message) { super(message); this.name = "DecryptionError"; } } exports.DecryptionError = DecryptionError; // Coerce a "callback ref" (which may be a callback function or a "."-delimited string name of a global function, e.g. // "MyApp.myCb") to a callback function getCb(cb) { if (typeof cb === 'string') { const pieces = cb.split('.'); const fn = pieces.reduce((obj, k) => obj && obj[k], window); return fn; } else { return cb; } } function inject({ src, pswd, iterations, cacheDecryptionKey, missingKeyCb, decryptionErrorCb, scrubHash, watchHash, }) { // In the common case, the `pswd` argument is empty, and we look for it in the URL "hash" let decryptionKeyHex; if (!pswd) { const hash = document.location.hash; if (hash && hash.length > 1) { pswd = hash.substring(1); // By default, "scrub" (remove) the password from the URL hash (after reading+storing it) if (scrubHash || scrubHash === undefined) { console.log("Scrubbing password from URL fragment"); const location = window.location; const title = 'Decrypted page'; if (!document.title) { // Hscrypt makes a best effort to not store the password anywhere, but browsers seem to record it // (as part of the URL hash) in their history in a way I haven't found a workaround for. // // Chrome and Firefox (but no Safari, afaict; other browsers as yet untested), in the absence of a // page title, display the full URL (including the hash) as the tab title, somewhat prominently. // Here we set a placeholder page title to avoid this, but the recommended practice is to set a // <title> on hscrypt encrypted landing pages. // // More discussion: https://stackoverflow.com/a/41073373/544236 console.warn("No `document.title` set on page receiving password via URL hash; some browsers (Chrome " + "and Firefox, at least) display the full URL (including hash) as the title, which creates a " + "risk of \"shoulder-surfing.\" Overriding the title now, but in general it's recommended to " + "set a <title> on hscrypt encrypted landing pages. Also note that the password is likely " + "persisted in this browser's history as part of the page's location."); document.title = title; } history.replaceState(null, title, location.pathname + location.search); } } } // If `cache` is true, the `decryptionKeyHex` (post-PBKDF2) is cached in localStorage under a key that is unique to the // current URL "pathname" component (all of `localStorage` is assumed to be specific to the current "hostname"). // Caching the post-PBKDF2 decryption key allows for faster reloads of previously decrypted pages. const localStorageKey = cacheDecryptionKey ? (0, cache_1.getLocalStorageKey)() : undefined; let cacheHit = false; if (!pswd && cacheDecryptionKey) { decryptionKeyHex = localStorage.getItem(localStorageKey); if (decryptionKeyHex) { console.log("Read decryptionKeyHex from cache:", decryptionKeyHex); cacheHit = true; } } // Optionally, and if no password or decryption key is found: // - Watch for changes to the URL hash. // - Re-attempt decryption when a new hash is detected. // // If successful decryption occurs on the current pass, this listener is immediately removed let hashListener; if (watchHash || watchHash === undefined) { hashListener = () => { console.log("Detected hash change, re-injecting"); inject({ src, pswd: null, iterations, cacheDecryptionKey, missingKeyCb, decryptionErrorCb, scrubHash, watchHash: false, }); }; window.addEventListener("hashchange", hashListener, false); console.log(`Added hashListener: ${hashListener}`); } // If no decryption key was provided explicitly or found in the `localStorage` cache, we're essentially in an error // state (though the exact semantics are up to the containing application; a friendly "please enter the password" // page, or even an app with reduced functionality/data, may be desired). // `missingKeyCb` is invoked here (defaulting to `console.log`, but a string like "MyApp.myMissingKeyCb" can be // provided as well) if (!pswd && !decryptionKeyHex) { const msg = "Please provide a password / decryption key as a URL hash"; if (!missingKeyCb) { missingKeyCb = ({ msg }) => console.log(msg); } const cb = getCb(missingKeyCb); cb({ msg }); return; } return fetchAndDecrypt({ src, pswd, iterations, decryptionKeyHex: decryptionKeyHex, cacheDecryptionKey, cacheHit, decryptionErrorCb, hashListener, }); } exports.inject = inject; // Simplest entrypoint to decryption+injection from client: call with password, all other configs pulled from global // HSCRYPT_CONFIG function decrypt(pswd, config) { if (!pswd) { throw new Error("hscrypt.decrypt: password required"); } const HSCRYPT_CONFIG = window[utils_1.HSCRYPT_CONFIG_VAR]; const c = Object.assign({}, HSCRYPT_CONFIG, config, { pswd }); console.log("Full decryption object:", config); return fetchAndDecrypt(c); } exports.decrypt = decrypt; // Fetch+decrypt encrypted source bundle (and optionally cache, if `localStorageKey` is provided) function fetchAndDecrypt({ src, pswd, iterations, decryptionKeyHex, cacheDecryptionKey, cacheHit, decryptionErrorCb, hashListener, }) { // Fetch + Decrypt the remote+encrypted source bundle console.time('fetch src'); return fetch(src) .then(response => { console.timeEnd('fetch src'); checkStatus(response); return response.arrayBuffer().then(buf => new Uint8Array(buf)); }) .then(encrypted => { decryptAndCache({ encrypted, pswd, iterations, decryptionKeyHex: decryptionKeyHex, cacheDecryptionKey, cacheHit, decryptionErrorCb, hashListener, }); }); } exports.fetchAndDecrypt = fetchAndDecrypt; // Decrypt ciphertext, optionally cache decryption key in `localStorage` function decryptAndCache({ encrypted, pswd, iterations, decryptionKeyHex, cacheDecryptionKey, cacheHit, decryptionErrorCb, hashListener, }) { const localStorageKey = (0, cache_1.getLocalStorageKey)(); try { const { source, decryptionKey } = _decrypt({ encrypted, pswd, iterations, decryptionKeyHex, }); if (cacheDecryptionKey && !decryptionKeyHex) { // Cache the post-PBKDF2 decryption key for faster subsequent reloads decryptionKeyHex = (0, utils_1.toHexString)(decryptionKey); localStorage.setItem(localStorageKey, decryptionKeyHex); console.log(`Saved decryptionKeyHex, ${localStorageKey}: ${decryptionKeyHex}`); } // Inject the decrypted source by appending to document.body. TODO: make this configurable? console.log(`hscrypt: injecting source`); const script = document.createElement('script'); script.setAttribute("type", "text/javascript"); script.innerHTML = source; document.body.appendChild(script); // Remove any `hashListener`, if one was added if (hashListener) { console.log("Removing hashListener"); window.removeEventListener("hashchange", hashListener, false); } } catch (err) { console.log(`Caught: ${err} (${err instanceof DecryptionError}), ${err.name}`); if (err instanceof DecryptionError) { // DecryptionError can result from: // 1. decryption key was cached in `localStorage` for a previous version of this URL, and is now out of date // (decryption key was read from cache, where it would only have been stored after a previous // successful decryption, but now decryption has failed), or // 2. password provided in this decryption invocation failed to decrypt the ciphertext (password is // incorrect, presumably) // // In the first case, we clear the cache entry, and in either case we invoke the `decryptionErrorCb` // (defaults to `alert` + `throw`, but can be passed a string like "MyApp.myDecryptionErrorCb"). console.log(`Caught DecryptionError: ${err}`); if (cacheHit) { console.log(`Clearing cache key ${localStorageKey} after unsuccessful decryption of cached decryptionKeyHex`); localStorage.removeItem(localStorageKey); } const msg = cacheHit ? `Decryption failed: ${err.message} (bad / out of date cache; clearing)` : `Decryption failed: ${err.message} (wrong password?)`; if (!decryptionErrorCb) { decryptionErrorCb = ({ err, cacheHit, }) => { alert(msg); throw err; }; } const cb = getCb(decryptionErrorCb); cb({ err, cacheHit }); return; } else { throw err; } } } exports.decryptAndCache = decryptAndCache; // Perform+verify decryption, return decrypted source + post-PBKDF2 decryption key (for possible caching) function _decrypt({ encrypted, pswd, iterations, decryptionKeyHex, }) { let decryptionKey; const nonce = encrypted.slice(utils_1.SALT_LENGTH, utils_1.SALT_LENGTH + utils_1.NONCE_LENGTH); const ciphertext = encrypted.slice(utils_1.SALT_LENGTH + utils_1.NONCE_LENGTH); iterations = iterations || utils_1.DEFAULT_ITERATIONS; if (decryptionKeyHex) { // If the secret is already known + passed in, we can skip the expensive PBKDF2 step decryptionKey = (0, utils_1.fromHexString)(decryptionKeyHex); } else { console.log(`decrypting: ${iterations} iterations`); const saltBuf = encrypted.slice(0, utils_1.SALT_LENGTH); console.log(`salt: ${(0, utils_1.toHexString)(saltBuf)}`); // console.log(` pswd: ${pswd}`) const salt = (0, crypto_1.convertUint8ArrayToWordArray)(saltBuf); console.time('hscrypt:PBKDF2'); decryptionKey = (0, crypto_1.toUint8Array)(crypto_js_1.default.PBKDF2(pswd, salt, { hasher: crypto_js_1.default.algo.SHA512, keySize: utils_1.DECRYPTION_KEY_LENGTH / 4, iterations: iterations || utils_1.DEFAULT_ITERATIONS })); console.timeEnd('hscrypt:PBKDF2'); } console.log(`nonce: ${(0, utils_1.toHexString)(nonce)}`); console.log(`decryptionKey: ${(0, utils_1.toHexString)(decryptionKey)}`); console.log(`iterations: ${iterations}`); const decoder = new ts_chacha20_1.Chacha20(decryptionKey, nonce); console.time('hscrypt:decrypt'); const plaintext = new TextDecoder().decode(decoder.decrypt(ciphertext)); console.timeEnd('hscrypt:decrypt'); // If decryption was successful, the plaintext will begin with the `SOURCE_PREFIX` magic bytes ("/* hscrypt */ ") const prefix = plaintext.substring(0, utils_1.SOURCE_PREFIX.length); if (prefix != utils_1.SOURCE_PREFIX) { throw new DecryptionError(`Invalid prefix: ${prefix}`); } const source = plaintext.substring(utils_1.SOURCE_PREFIX.length); return { source, decryptionKey }; } exports._decrypt = _decrypt; //# sourceMappingURL=hscrypt.js.map