601 lines
21 KiB
JavaScript
601 lines
21 KiB
JavaScript
/*
|
|
* copyright (c) 2020 Bowen Ding
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
* of this software and associated documentation files (the "Software"), to
|
|
* deal in the Software without restriction, including without limitation the
|
|
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
* sell copies of the Software, and to permit persons to whom the Software is
|
|
* furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in
|
|
* all copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
* IN THE SOFTWARE.
|
|
*/
|
|
|
|
"use strict"
|
|
class Decryptor {
|
|
constructor(config) {
|
|
this._jobCount = 0;
|
|
this._jobMap = new Map();
|
|
this._workerReady = false;
|
|
|
|
if (Decryptor.isServiceWorker()) {
|
|
this._role = "service_worker";
|
|
} else if (!Decryptor.isWorker()) {
|
|
if (!Decryptor.featureTest()) {
|
|
alert("This page cannot function properly because your browser does not support some critical features or you are in private browsing mode. Please update your browser or exit private browsing mode.");
|
|
return;
|
|
}
|
|
this._role = "main";
|
|
this._config = config;
|
|
const local_config = this._mGetLocalConfig();
|
|
if (local_config) {
|
|
this._config = local_config;
|
|
}
|
|
window.addEventListener(
|
|
"load",
|
|
(e) => this._mSetupServiceWorker(),
|
|
{ once: true, passive: true }
|
|
);
|
|
}
|
|
|
|
console.info("Decryptor initialized");
|
|
}
|
|
|
|
static init(config) {
|
|
if (Decryptor.isServiceWorker()) {
|
|
self.decryptor = new Decryptor(config);
|
|
} else {
|
|
window.decryptor = new Decryptor(config);
|
|
}
|
|
}
|
|
|
|
static featureTest() {
|
|
let features = [
|
|
typeof crypto,
|
|
typeof TextEncoder,
|
|
typeof navigator.serviceWorker,
|
|
typeof Proxy,
|
|
typeof fetch,
|
|
typeof Blob.prototype.arrayBuffer,
|
|
typeof Response.prototype.clone,
|
|
typeof caches
|
|
];
|
|
return features.every((e) => e !== "undefined");
|
|
}
|
|
|
|
async _swInitServiceWorker(config) {
|
|
const crypto = Decryptor._getCrypto();
|
|
const encoder = new TextEncoder("utf-8");
|
|
const salt = encoder.encode(config.kdf_salt);
|
|
const iters = config.kdf_iters;
|
|
const shared_key = encoder.encode(config.password);
|
|
const gcm_tag = encoder.encode(config.gcm_tag);
|
|
|
|
const aes_key = await Decryptor._initAesKey(crypto, salt, iters, shared_key);
|
|
if (await this._swCheckAesKey(aes_key, gcm_tag)) {
|
|
this.workerReady = true;
|
|
this._decrypt = (encrypted_blob_arraybuffer) =>
|
|
Decryptor.decrypt(crypto, encrypted_blob_arraybuffer, aes_key, gcm_tag);
|
|
this._swNotifyWorkerReady();
|
|
} else {
|
|
this.workerReady = false;
|
|
this._swNotifyIncorrectPassword()
|
|
}
|
|
}
|
|
|
|
async _swCheckAesKey(aes_key, gcm_tag) {
|
|
let response;
|
|
try {
|
|
response = await fetch(Decryptor.keyCheckURL);
|
|
} catch (error) {
|
|
throw new Error("Fetched failed when checking encryption key");
|
|
}
|
|
try {
|
|
await Decryptor.decrypt(
|
|
Decryptor._getCrypto(),
|
|
await response.blob(),
|
|
aes_key,
|
|
gcm_tag,
|
|
true
|
|
);
|
|
} catch (error) {
|
|
console.warn("Password is incorrect!");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async _swNotifyWorkerReady() {
|
|
const array_clients = await self.clients.matchAll({includeUncontrolled: true});
|
|
for (let client of array_clients) {
|
|
this._proxyWrap(client)._mSetWorkerReady();
|
|
}
|
|
}
|
|
|
|
async _swNotifyIncorrectPassword() {
|
|
const array_clients = await self.clients.matchAll({includeUncontrolled: true});
|
|
for (let client of array_clients) {
|
|
this._proxyWrap(client)._mUnsetWorkerReady();
|
|
}
|
|
}
|
|
|
|
static isInitialized() {
|
|
if (Decryptor.isServiceWorker()) {
|
|
return 'decryptor' in self && self.decryptor.workerReady;
|
|
} else {
|
|
return 'decryptor' in window && window.decryptor.workerReady;
|
|
}
|
|
}
|
|
|
|
get workerReady() {
|
|
return this._workerReady;
|
|
}
|
|
|
|
set workerReady(val) {
|
|
this._workerReady = (val ? true : false);
|
|
if (this._workerReady) {
|
|
const eventTarget = (Decryptor.isWorker() ? self : document);
|
|
Decryptor._sendEvent(eventTarget, "DecryptWorkerReady");
|
|
}
|
|
}
|
|
|
|
_mSetWorkerReady() {
|
|
this.workerReady = true;
|
|
const had_been_ready_before = localStorage.getItem(this._config.galleryId) !== null;
|
|
localStorage.setItem(this._config.galleryId, JSON.stringify(this._config));
|
|
if (!had_been_ready_before) {
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
_mUnsetWorkerReady() {
|
|
this.workerReady = false;
|
|
localStorage.removeItem(this._config.galleryId);
|
|
}
|
|
|
|
_mGetLocalConfig() {
|
|
const local_config = JSON.parse(localStorage.getItem(this._config.galleryId));
|
|
if (local_config
|
|
&& local_config.galleryId
|
|
&& local_config.sw_script
|
|
&& local_config.password
|
|
&& local_config.gcm_tag
|
|
&& local_config.kdf_salt
|
|
&& local_config.kdf_iters) {
|
|
return local_config;
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async _mSetupServiceWorker() {
|
|
if (!('serviceWorker' in navigator)) {
|
|
console.error("Fatal: Your browser does not support service worker");
|
|
throw new Error("no service worker support");
|
|
}
|
|
|
|
if (navigator.serviceWorker.controller) {
|
|
this.serviceWorker = navigator.serviceWorker.controller;
|
|
} else {
|
|
navigator.serviceWorker.register(this._config.sw_script);
|
|
const registration = await navigator.serviceWorker.ready;
|
|
this.serviceWorker = registration.active;
|
|
}
|
|
|
|
navigator.serviceWorker.onmessage =
|
|
(e) => Decryptor.onMessage(this.serviceWorker, e);
|
|
this.serviceWorker = this._proxyWrap(this.serviceWorker);
|
|
|
|
if (!(await this.serviceWorker.Decryptor.isInitialized())) {
|
|
if (!('password' in this._config && this._config.password)) {
|
|
this._config.password = await this._mAskPassword();
|
|
}
|
|
this.serviceWorker._swInitServiceWorker(this._config);
|
|
}
|
|
}
|
|
|
|
static isServiceWorker() {
|
|
return ('undefined' !== typeof ServiceWorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator);
|
|
}
|
|
|
|
static isWorker() {
|
|
return ('undefined' !== typeof WorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator);
|
|
}
|
|
|
|
static _getCrypto() {
|
|
if('undefined' !== typeof crypto && crypto.subtle) {
|
|
return crypto.subtle;
|
|
} else {
|
|
throw new Error("Fatal: Browser does not support Web Crypto");
|
|
}
|
|
}
|
|
|
|
/* main thread only */
|
|
async _mAskPassword() {
|
|
const config = JSON.parse(localStorage.getItem(this._config.galleryId));
|
|
if (config && config.password) {
|
|
return config.password;
|
|
}
|
|
const password = prompt("Input password to view this gallery:");
|
|
if (password) {
|
|
this._config.password = password;
|
|
return password;
|
|
} else {
|
|
return "__wrong_password__";
|
|
}
|
|
}
|
|
|
|
static async _initAesKey(crypto, kdf_salt, kdf_iters, shared_key) {
|
|
const pbkdf2key = await crypto.importKey(
|
|
"raw",
|
|
shared_key,
|
|
"PBKDF2",
|
|
false,
|
|
["deriveKey"]
|
|
);
|
|
const pbkdf2params = {
|
|
name: "PBKDF2",
|
|
hash: "SHA-1",
|
|
salt: kdf_salt,
|
|
iterations: kdf_iters
|
|
};
|
|
return await crypto.deriveKey(
|
|
pbkdf2params,
|
|
pbkdf2key,
|
|
{ name: "AES-GCM", length: 128 },
|
|
false,
|
|
["decrypt"]
|
|
);
|
|
}
|
|
|
|
static _sendEvent(target, type, detail = null) {
|
|
const eventInit = {
|
|
detail: detail,
|
|
bubbles: true,
|
|
cancelable: true
|
|
};
|
|
return target.dispatchEvent(new CustomEvent(type, eventInit));
|
|
}
|
|
|
|
static async checkMagicString(arraybuffer) {
|
|
const sample = new DataView(
|
|
arraybuffer,
|
|
0,
|
|
Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength
|
|
);
|
|
for (let i = 0; i < Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength; i++) {
|
|
if (Decryptor.MAGIC_STRING_ARRAYBUFFER[i] !== sample.getUint8(i)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static async decrypt(crypto, blob_or_arraybuffer, aes_key, gcm_tag, check_magic_string=false) {
|
|
let arraybuffer, return_blob;
|
|
if (blob_or_arraybuffer instanceof Blob) {
|
|
arraybuffer = await blob_or_arraybuffer.arrayBuffer();
|
|
return_blob = true;
|
|
} else if (blob_or_arraybuffer instanceof ArrayBuffer) {
|
|
arraybuffer = blob_or_arraybuffer
|
|
return_blob = false;
|
|
} else {
|
|
throw new TypeError("decrypt accepts either a Blob or an ArrayBuffer");
|
|
}
|
|
|
|
// make sure there is enough data to decrypt
|
|
// although 1 byte of data seems not acceptable for some browsers
|
|
// in which case crypto.decrypt will throw an error
|
|
// "The provided data is too small"
|
|
if (arraybuffer.byteLength <
|
|
Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength
|
|
+ Decryptor.IV_LENGTH
|
|
+ 1) {
|
|
throw new Error("not enough data to decrypt");
|
|
}
|
|
|
|
if (check_magic_string && !(await Decryptor.checkMagicString(arraybuffer))) {
|
|
// data is not encrypted
|
|
return blob_or_arraybuffer;
|
|
}
|
|
|
|
const iv = new DataView(
|
|
arraybuffer,
|
|
Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength,
|
|
Decryptor.IV_LENGTH
|
|
);
|
|
const ciphertext = new DataView(
|
|
arraybuffer,
|
|
Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength + Decryptor.IV_LENGTH
|
|
);
|
|
const decrypted = await crypto.decrypt(
|
|
{
|
|
name: "AES-GCM",
|
|
iv: iv,
|
|
additionalData: gcm_tag
|
|
},
|
|
aes_key,
|
|
ciphertext
|
|
);
|
|
if (return_blob) {
|
|
return new Blob([decrypted], {type: blob_or_arraybuffer.type});
|
|
} else {
|
|
return decrypted;
|
|
}
|
|
}
|
|
|
|
_proxyWrap(target) {
|
|
const decryptor = this;
|
|
const handler = {
|
|
get: (wrappedObj, prop) => {
|
|
if (prop in wrappedObj) {
|
|
if (wrappedObj[prop] instanceof Function) {
|
|
return (...args) => wrappedObj[prop].apply(wrappedObj, args);
|
|
} else {
|
|
return wrappedObj[prop];
|
|
}
|
|
}
|
|
if (prop === "Decryptor") {
|
|
return new Proxy(target, {
|
|
get: (wrappedObj, prop) => {
|
|
return decryptor._rpcCall(wrappedObj, prop, true);
|
|
}
|
|
});
|
|
}
|
|
return decryptor._rpcCall(wrappedObj, prop, false);
|
|
}
|
|
}
|
|
return new Proxy(target, handler);
|
|
}
|
|
|
|
_rpcCall(target, method, static_) {
|
|
const decryptor = this;
|
|
const dummyFunction = () => {};
|
|
const handler = {
|
|
apply: (wrappedFunc, thisArg, args) => {
|
|
return new Promise((success, error) => {
|
|
const jobId = decryptor._jobCount++;
|
|
decryptor._jobMap.set(jobId, {success: success, error: error});
|
|
Decryptor._rpcPostJob(jobId, target, method, args, static_);
|
|
});
|
|
}
|
|
};
|
|
return new Proxy(dummyFunction, handler);
|
|
}
|
|
|
|
static _rpcPostJob(jobId, messagePort, method, args, static_=false) {
|
|
const job = {
|
|
type: "job",
|
|
id: jobId,
|
|
method: method,
|
|
args: args,
|
|
static: static_
|
|
};
|
|
messagePort.postMessage(job);
|
|
}
|
|
|
|
static _asyncReturn(instance, method, args) {
|
|
if (!(instance instanceof Object)) {
|
|
return Promise.reject(new Error("calling method on a primitive"));
|
|
}
|
|
if (!(method in instance && instance[method] instanceof Function)) {
|
|
return Promise.reject(new Error(`no such method: ${method}`))
|
|
}
|
|
|
|
try {
|
|
let promise_or_value = instance[method].apply(instance, args);
|
|
if (promise_or_value instanceof Promise) {
|
|
return promise_or_value;
|
|
} else {
|
|
return Promise.resolve(promise_or_value);
|
|
}
|
|
} catch (e) {
|
|
return Promise.reject(e);
|
|
}
|
|
}
|
|
|
|
static onMessage(replyPort, e) {
|
|
const type = e.data.type;
|
|
const id = e.data.id;
|
|
|
|
if (type === "job") {
|
|
const method = e.data.method;
|
|
const args = e.data.args;
|
|
const instance = e.data.static ? Decryptor :
|
|
(Decryptor.isWorker() ? self : window).decryptor;
|
|
|
|
Decryptor._asyncReturn(instance, method, args)
|
|
.then(
|
|
(result) => { return {type: "reply", success: true, result: result}; },
|
|
(error) => { return {type: "reply", success: false, result: error.message}; }
|
|
)
|
|
.then((reply) => {
|
|
reply.id = id;
|
|
replyPort.postMessage(reply);
|
|
});
|
|
} else if (type === "reply") {
|
|
// if we are receiving replies, we must have been initialized
|
|
// so no need to check if "decryptor" exists here
|
|
const success = e.data.success;
|
|
const result = e.data.result;
|
|
const callbacks = decryptor._jobMap.get(id);
|
|
|
|
if (success) {
|
|
if (callbacks.success) callbacks.success(result);
|
|
} else {
|
|
if (callbacks.error) callbacks.error(new Error(result));
|
|
}
|
|
decryptor._jobMap.delete(id);
|
|
}
|
|
}
|
|
|
|
static onServiceWorkerInstall(e) {
|
|
console.log("service worker on install: ", e);
|
|
e.waitUntil(self.skipWaiting());
|
|
}
|
|
|
|
static onServiceWorkerActivate(e) {
|
|
console.log("service worker on activate: ", e);
|
|
e.waitUntil(self.clients.claim());
|
|
}
|
|
|
|
static onServiceWorkerMesssage(e) {
|
|
return Decryptor.onMessage(e.source, e);
|
|
}
|
|
|
|
static async _swHandleFetch(e) {
|
|
const request = e.request;
|
|
try {
|
|
const cached_response = await caches.match(request);
|
|
if (cached_response) {
|
|
// TODO: handle cache expiration
|
|
console.debug(`Found cached response for ${request.url}`);
|
|
return cached_response;
|
|
}
|
|
} catch (error) {
|
|
console.error("Caches.match error!");
|
|
}
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(request);
|
|
} catch (error) {
|
|
console.debug(`Fetch failed when trying for ${request.url}: ${error}`);
|
|
throw error;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
console.debug(`Fetch succeeded but server returned non-2xx: ${request.url}`);
|
|
return response;
|
|
}
|
|
|
|
const is_image = [
|
|
request.destination === "image",
|
|
(() => {
|
|
const content_type = response.headers.get("content-type");
|
|
return content_type && content_type.startsWith("image");
|
|
})()
|
|
];
|
|
|
|
if (!is_image.some((e) => e)) {
|
|
console.debug(`Fetch succeeded but response is likely not an image ${request.url}`);
|
|
return response;
|
|
}
|
|
|
|
const response_clone = response.clone();
|
|
const encrypted_blob = await response.blob();
|
|
const encrypted_arraybuffer = await encrypted_blob.arrayBuffer();
|
|
if (!(await Decryptor.checkMagicString(encrypted_arraybuffer))) {
|
|
console.debug(`Response image is not encrypted: ${request.url}`);
|
|
return response_clone;
|
|
}
|
|
console.debug(`Fetch succeeded with encrypted image ${request.url}, trying to decrypt`);
|
|
|
|
if (!Decryptor.isInitialized()) {
|
|
if ('decryptor' in self) {
|
|
try{
|
|
const clients = await self.clients.matchAll({type: "window"});
|
|
const races = Promise.race(
|
|
clients.map((client) => {
|
|
return self.decryptor._proxyWrap(client)._mGetLocalConfig();
|
|
})
|
|
);
|
|
const config = await Promise.timeout(races, 100);
|
|
await self.decryptor._swInitServiceWorker(config);
|
|
} catch (error) {
|
|
// do nothing
|
|
}
|
|
}
|
|
if (!Decryptor.isInitialized()) {
|
|
console.debug(`Service worker not initialized on fetch event`);
|
|
return Decryptor.errorResponse.clone();
|
|
}
|
|
}
|
|
|
|
let decrypted_blob;
|
|
try {
|
|
decrypted_blob = new Blob(
|
|
[await self.decryptor._decrypt(encrypted_arraybuffer)],
|
|
{type: encrypted_blob.type}
|
|
);
|
|
} catch (error) {
|
|
console.debug(`Decryption failed for ${request.url}: ${error.message}`);
|
|
console.error("Corrupted data??? This shouldn't occur.");
|
|
return Decryptor.errorResponse.clone();
|
|
}
|
|
|
|
const decrypted_response = new Response(
|
|
decrypted_blob,
|
|
{
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: response.headers
|
|
}
|
|
);
|
|
decrypted_response.headers.set("content-length", decrypted_blob.size);
|
|
|
|
const decrypted_response_clone = decrypted_response.clone();
|
|
const cache = await caches.open("v1");
|
|
cache.put(request, decrypted_response_clone);
|
|
|
|
console.debug(`Responding with decrypted response ${request.url}`);
|
|
return decrypted_response;
|
|
}
|
|
|
|
static onServiceWorkerFetch(e) {
|
|
e.respondWith(Decryptor._swHandleFetch(e));
|
|
}
|
|
}
|
|
|
|
Decryptor.MAGIC_STRING = "_e_n_c_r_y_p_t_e_d_";
|
|
Decryptor.MAGIC_STRING_ARRAYBUFFER = (new TextEncoder("utf-8")).encode(Decryptor.MAGIC_STRING);
|
|
Decryptor.IV_LENGTH = 12;
|
|
Decryptor.keyCheckURL = "static/keycheck.txt";
|
|
Decryptor.imagePlaceholderBlob = new Blob([
|
|
`<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
|
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
|
|
<g>
|
|
<title>background</title>
|
|
<rect fill="#ffffff" id="canvas_background" height="202" width="202" y="-1" x="-1"/>
|
|
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
|
|
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
|
|
</g>
|
|
</g>
|
|
<g>
|
|
<title>Layer 1</title>
|
|
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_1" y="61.949997" x="22.958336" stroke-width="0" stroke="#000" fill="#7f7f7f">Could not</text>
|
|
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_4" y="112.600002" x="65.974998" stroke-width="0" stroke="#000" fill="#7f7f7f">load</text>
|
|
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_5" y="162.949997" x="50.983334" stroke-width="0" stroke="#000" fill="#7f7f7f">image</text>
|
|
</g>
|
|
</svg>`], {type: "image/svg+xml"});
|
|
|
|
Decryptor.errorResponse = new Response(
|
|
Decryptor.imagePlaceholderBlob,
|
|
{
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: {
|
|
"content-type": "image/svg+xml"
|
|
}
|
|
}
|
|
);
|
|
|
|
Promise.timeout = function(cb_or_pm, timeout) {
|
|
return Promise.race([
|
|
cb_or_pm instanceof Function ? new Promise(cb) : cb_or_pm,
|
|
new Promise((resolve, reject) => {
|
|
setTimeout(() => {
|
|
reject('Timed out');
|
|
}, timeout);
|
|
})
|
|
]);
|
|
} |