hscrypt
Version:
Encrypt Javascript bundles (at build time), inject+decrypt them into pages later (in the browser)
251 lines • 14.1 kB
JavaScript
;
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