From 0a6f314e35e4120d5c36182872e7670677076d32 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Tue, 31 Mar 2020 00:49:40 +0800 Subject: [PATCH 01/19] Add plugin encrypt --- docs/plugins.rst | 5 + setup.cfg | 2 +- sigal/plugins/encrypt/__init__.py | 1 + sigal/plugins/encrypt/encrypt.py | 297 +++++++++++++ sigal/plugins/encrypt/endec.py | 112 +++++ .../plugins/encrypt/static/decrypt-worker.js | 26 ++ sigal/plugins/encrypt/static/decrypt.js | 395 ++++++++++++++++++ tests/sample/sigal.conf.py | 9 + tox.ini | 2 + 9 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 sigal/plugins/encrypt/__init__.py create mode 100644 sigal/plugins/encrypt/encrypt.py create mode 100644 sigal/plugins/encrypt/endec.py create mode 100644 sigal/plugins/encrypt/static/decrypt-worker.js create mode 100644 sigal/plugins/encrypt/static/decrypt.js diff --git a/docs/plugins.rst b/docs/plugins.rst index 4da49dcd..ad8771b7 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -135,3 +135,8 @@ ZIP Gallery plugin ================== .. automodule:: sigal.plugins.zip_gallery + +Encrypt plugin +============== + +.. automodule:: sigal.plugins.encrypt diff --git a/setup.cfg b/setup.cfg index 67bab001..c18838fc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ install_requires = pilkit [options.extras_require] -all = boto; brotli; feedgenerator; zopfli +all = boto; brotli; feedgenerator; zopfli; cryptography; beautifulsoup4 tests = pytest; pytest-cov docs = Sphinx; alabaster diff --git a/sigal/plugins/encrypt/__init__.py b/sigal/plugins/encrypt/__init__.py new file mode 100644 index 00000000..6fe3cc82 --- /dev/null +++ b/sigal/plugins/encrypt/__init__.py @@ -0,0 +1 @@ +from .encrypt import register diff --git a/sigal/plugins/encrypt/encrypt.py b/sigal/plugins/encrypt/encrypt.py new file mode 100644 index 00000000..5ee888b8 --- /dev/null +++ b/sigal/plugins/encrypt/encrypt.py @@ -0,0 +1,297 @@ +# 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. + +'''Plugin to protect gallery by encrypting image files using a password. + +Options: + encrypt_options = { + 'password': 'password', + 'ask_password': False, + 'gcm_tag': 'randomly_generated', + 'kdf_salt': 'randomly_generated', + 'kdf_iters': 10000, + 'encrypt_symlinked_originals': False + } + +- ``password``: The password used to encrypt the images on gallery build, + and decrypt them when viewers access the gallery. No default value. You must + specify a password. +- ``ask_password``: Whether or not viewers are asked for the password to view + the gallery. If set to ``False``, the password will be present in the HTML files + so the images are decrypted automatically. Defaults to ``False``. +- ``gcm_tag``, ``kdf_salt``, ``kdf_iters``: Cryptographic parameters used when + encrypting the files. ``gcm_tag``, ``kdf_salt`` are meant to be randomly generated, + ``kdf_iters`` defaults to 10000. Do not specify them in the config file unless + you have good reasons to do so. +- ``encrypt_symlinked_originals``: Force encrypting original images even if they + are symlinked. If you don't know what it means, leave it to ``False``. + +Note: The plugin caches the cryptographic parameters (but not the password) after +the first build, so that incremental builds can share the same credentials. +DO NOT CHANGE THE PASSWORD OR OTHER CRYPTOGRAPHIC PARAMETERS ONCE A GALLERY IS +BUILT, or there will be inconsistency in encrypted files and viewers will not be able +to see some of the images any more. +''' + +import os, random, string, logging, pickle +from io import BytesIO +from itertools import chain + +from sigal import signals +from sigal.utils import url_from_path, copy +from sigal.settings import get_thumb +from click import progressbar +from bs4 import BeautifulSoup + +from .endec import encrypt, kdf_gen_key + +ASSETS_PATH = os.path.normpath(os.path.join( + os.path.abspath(os.path.dirname(__file__)), 'static')) + +class Abort(Exception): + pass + +def gen_rand_string(length=16): + return "".join(random.SystemRandom().choices(string.ascii_letters + string.digits, k=length)) + +def get_options(gallery): + settings = gallery.settings + cache = gallery.encryptCache + if "encrypt_options" not in settings: + raise ValueError("Encrypt: no options in settings") + + #try load credential from cache + try: + options = cache["credentials"] + except KeyError: + options = settings["encrypt_options"] + + table = str.maketrans({'"': r'\"', '\\': r'\\'}) + if "password" not in settings["encrypt_options"]: + raise ValueError("Encrypt: no password provided") + else: + options["password"] = settings["encrypt_options"]["password"] + options["escaped_password"] = options["password"].translate(table) + + if "gcm_tag" not in options: + options["gcm_tag"] = gen_rand_string() + options["escaped_gcm_tag"] = options["gcm_tag"].translate(table) + + if "kdf_salt" not in options: + options["kdf_salt"] = gen_rand_string() + options["escaped_kdf_salt"] = options["kdf_salt"].translate(table) + + if "galleryId" not in options: + options["galleryId"] = gen_rand_string(6) + + if "kdf_iters" not in options: + options["kdf_iters"] = 10000 + + if "ask_password" not in options: + options["ask_password"] = settings["encrypt_options"].get("ask_password", False) + + gallery.encryptCache["credentials"] = { + "gcm_tag": options["gcm_tag"], + "kdf_salt": options["kdf_salt"], + "kdf_iters": options["kdf_iters"], + "galleryId": options["galleryId"] + } + + if "encrypt_symlinked_originals" not in options: + options["encrypt_symlinked_originals"] = settings["encrypt_options"].get("encrypt_symlinked_originals", False) + + return options + +def cache_key(media): + return os.path.join(media.path, media.filename) + +def save_property(cache, media): + key = cache_key(media) + if key not in cache: + cache[key] = {} + cache[key]["size"] = media.size + cache[key]["thumb_size"] = media.thumb_size + cache[key]["encrypted"] = set() + +def get_encrypt_list(settings, media): + to_encrypt = [] + to_encrypt.append(media.filename) #resized image + if settings["make_thumbs"]: + to_encrypt.append(get_thumb(settings, media.filename)) #thumbnail + if media.big is not None: + to_encrypt.append(media.big) #original image + to_encrypt = list(map(lambda path: os.path.join(media.path, path), to_encrypt)) + return to_encrypt + +def load_property(album): + if not hasattr(album.gallery, "encryptCache"): + load_cache(album.gallery) + cache = album.gallery.encryptCache + + for media in album.medias: + if media.type == "image": + key = cache_key(media) + if key in cache: + media.size = cache[key]["size"] + media.thumb_size = cache[key]["thumb_size"] + +def load_cache(gallery): + if hasattr(gallery, "encryptCache"): + return + logger = gallery.logger + settings = gallery.settings + cachePath = os.path.join(settings["destination"], ".encryptCache") + + try: + with open(cachePath, "rb") as cacheFile: + gallery.encryptCache = pickle.load(cacheFile) + logger.debug("Loaded encryption cache with %d entries", len(gallery.encryptCache)) + except FileNotFoundError: + gallery.encryptCache = {} + except Exception as e: + logger.error("Could not load encryption cache: %s", e) + logger.error("Giving up encryption. Please delete and rebuild the entire gallery.") + raise Abort + +def save_cache(gallery): + if hasattr(gallery, "encryptCache"): + cache = gallery.encryptCache + else: + cache = gallery.encryptCache = {} + + logger = gallery.logger + settings = gallery.settings + cachePath = os.path.join(settings["destination"], ".encryptCache") + try: + with open(cachePath, "wb") as cacheFile: + pickle.dump(cache, cacheFile) + logger.debug("Stored encryption cache with %d entries", len(cache)) + except Exception as e: + logger.warning("Could not store encryption cache: %s", e) + logger.warning("Next build of the gallery is likely to fail!") + +def encrypt_gallery(gallery): + logger = gallery.logger + albums = gallery.albums + settings = gallery.settings + + try: + load_cache(gallery) + config = get_options(gallery) + logger.debug("encryption config: %s", config) + logger.info("starting encryption") + encrypt_files(gallery, settings, config, albums) + fix_html(gallery, settings, config, albums) + copy_assets(settings) + except Abort: + pass + + save_cache(gallery) + +def encrypt_files(gallery, settings, config, albums): + logger = gallery.logger + if settings["keep_orig"]: + if settings["orig_link"] and not config["encrypt_symlinked_originals"]: + logger.warning("Original files are symlinked! Set encrypt_options[\"encrypt_symlinked_originals\"] to True to force encrypting them, if this is what you want.") + raise Abort + + key = kdf_gen_key(config["password"].encode("utf-8"), config["kdf_salt"].encode("utf-8"), config["kdf_iters"]) + medias = list(chain.from_iterable(albums.values())) + with progressbar(medias, label="%16s" % "Encrypting files", file=gallery.progressbar_target, show_eta=True) as medias: + for media in medias: + if media.type != "image": + logger.info("Skipping non-image file %s", media.filename) + continue + + save_property(gallery.encryptCache, media) + to_encrypt = get_encrypt_list(settings, media) + + cacheEntry = gallery.encryptCache[cache_key(media)]["encrypted"] + for f in to_encrypt: + if f in cacheEntry: + logger.info("Skipping %s as it is already encrypted", f) + continue + + full_path = os.path.join(settings["destination"], f) + with BytesIO() as outBuffer: + try: + with open(full_path, "rb") as infile: + encrypt(key, infile, outBuffer, config["gcm_tag"].encode("utf-8")) + except Exception as e: + logger.error("Encryption failed for %s: %s", f, e) + else: + logger.info("Encrypting %s...", f) + try: + with open(full_path, "wb") as outfile: + outfile.write(outBuffer.getbuffer()) + cacheEntry.add(f) + except Exception as e: + logger.error("Could not write to file %s: %s", f, e) + +def fix_html(gallery, settings, config, albums): + logger = gallery.logger + if gallery.settings["write_html"]: + decryptorConfigTemplate = """ + Decryptor.init({{ + password: "{filtered_password}", + worker_script: "{worker_script}", + galleryId: "{galleryId}", + gcm_tag: "{escaped_gcm_tag}", + kdf_salt: "{escaped_kdf_salt}", + kdf_iters: {kdf_iters} + }}); + """ + config["filtered_password"] = "" if config.get("ask_password", False) else config["escaped_password"] + + with progressbar(albums.values(), label="%16s" % "Fixing html files", file=gallery.progressbar_target, show_eta=True) as albums: + for album in albums: + index_file = os.path.join(album.dst_path, album.output_file) + contents = None + with open(index_file, "r", encoding="utf-8") as f: + contents = f.read() + root = BeautifulSoup(contents, "html.parser") + head = root.find("head") + if head.find(id="_decrypt_script"): + head.find(id="_decrypt_script").decompose() + if head.find(id="_decrypt_script_config"): + head.find(id="_decrypt_script_config").decompose() + theme_path = os.path.join(settings["destination"], 'static') + theme_url = url_from_path(os.path.relpath(theme_path, album.dst_path)) + scriptNode = root.new_tag("script", id="_decrypt_script", src="{url}/decrypt.js".format(url=theme_url)) + scriptConfig = root.new_tag("script", id="_decrypt_script_config") + config["worker_script"] = "{url}/decrypt-worker.js".format(url=theme_url) + decryptorConfig = decryptorConfigTemplate.format(**config) + scriptConfig.append(root.new_string(decryptorConfig)) + head.append(scriptNode) + head.append(scriptConfig) + with open(index_file, "w", encoding="utf-8") as f: + f.write(root.prettify()) + +def copy_assets(settings): + theme_path = os.path.join(settings["destination"], 'static') + for root, dirs, files in os.walk(ASSETS_PATH): + for file in files: + copy(os.path.join(root, file), theme_path, symlink=False, rellink=False) + + +def register(settings): + signals.gallery_build.connect(encrypt_gallery) + signals.album_initialized.connect(load_property) + diff --git a/sigal/plugins/encrypt/endec.py b/sigal/plugins/encrypt/endec.py new file mode 100644 index 00000000..37ca8d81 --- /dev/null +++ b/sigal/plugins/encrypt/endec.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +#coding: utf-8 + +# 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. + +from pathlib import Path +import os, io +from base64 import b64decode +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import hashes +from cryptography.exceptions import InvalidTag +from typing import BinaryIO + +backend = default_backend() + +def kdf_gen_key(password: bytes, salt:bytes, iters: int) -> bytes: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA1(), + length=16, + salt=salt, + iterations=iters, + backend=backend + ) + key = kdf.derive(password) + return key + +def dispatchargs(decorated): + def wrapper(args): + if args.key is not None: + key = b64decode(args.key.encode("utf-8")) + elif args.password is not None: + key = kdf_gen_key( + args.password.encode("utf-8"), + args.kdf_salt.encode("utf-8"), + args.kdf_iters + ) + else: + raise ValueError("Neither password nor key is provided") + tag = args.gcm_tag.encode("utf-8") + outputBuffer = io.BytesIO() + with Path(args.infile).open("rb") as in_: + decorated(key, in_, outputBuffer, tag) + with Path(args.outfile).open("wb") as out: + out.write(outputBuffer.getbuffer()) + + return wrapper + +def encrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes): + if len(key) != 128/8: + raise ValueError("Unsupported key length: %d" % len(key)) + aesgcm = AESGCM(key) + iv = os.urandom(12) + plaintext = infile + ciphertext = outfile + rawbytes = plaintext.read() + encrypted = aesgcm.encrypt(iv, rawbytes, tag) + ciphertext.write(iv) + ciphertext.write(encrypted) + +def decrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes): + if len(key) != 128/8: + raise ValueError("Unsupported key length: %d" % len(key)) + aesgcm = AESGCM(key) + ciphertext = infile + plaintext = outfile + iv = ciphertext.read(12) + rawbytes = ciphertext.read() + try: + decrypted = aesgcm.decrypt(iv, rawbytes, tag) + except InvalidTag: + raise ValueError("Incorrect tag, iv, or corrupted ciphertext") + plaintext.write(decrypted) + +if __name__ == "__main__": + import argparse as ap + parser = ap.ArgumentParser(description="Encrypt or decrypt using AES-128-GCM") + parser.add_argument("-k", "--key", help="Base64-encoded key") + parser.add_argument("-p", "--password", help="Password in plaintext") + parser.add_argument("--kdf-salt", help="PBKDF2 salt", default="saltysaltsweetysweet") + parser.add_argument("--kdf-iters", type=int, help="PBKDF2 iterations", default=10000) + parser.add_argument("--gcm-tag", help="AES-GCM tag", default="AuTheNTiCatIoNtAG") + parser.add_argument("-i", "--infile", help="Input file") + parser.add_argument("-o", "--outfile", help="Output file") + subparsers = parser.add_subparsers(title="commands", dest="action") + parser_enc = subparsers.add_parser("enc", help="Encrypt") + parser_enc.set_defaults(execute=dispatchargs(encrypt)) + parser_dec = subparsers.add_parser("dec", help="Decrypt") + parser_dec.set_defaults(execute=dispatchargs(decrypt)) + + args = parser.parse_args() + args.execute(args) + diff --git a/sigal/plugins/encrypt/static/decrypt-worker.js b/sigal/plugins/encrypt/static/decrypt-worker.js new file mode 100644 index 00000000..f86c3290 --- /dev/null +++ b/sigal/plugins/encrypt/static/decrypt-worker.js @@ -0,0 +1,26 @@ +/* + * 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" +importScripts("decrypt.js"); + +onmessage = Decryptor.onWorkerMessage; diff --git a/sigal/plugins/encrypt/static/decrypt.js b/sigal/plugins/encrypt/static/decrypt.js new file mode 100644 index 00000000..7a0c3b8d --- /dev/null +++ b/sigal/plugins/encrypt/static/decrypt.js @@ -0,0 +1,395 @@ +/* + * 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) { + const c = Decryptor._getCrypto(); + if (Decryptor.isWorker()) { + this._role = "worker"; + 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); + + Decryptor + ._initAesKey(c, salt, iters, shared_key) + .then((aes_key) => { + this._decrypt = (encrypted_blob_arraybuffer) => + Decryptor.decrypt(c, encrypted_blob_arraybuffer, + aes_key, gcm_tag); + }) + .then(() => { + Decryptor._sendEvent(self, "DecryptWorkerReady"); + }); + } else { + this._role = "main"; + this._jobCount = 0; + this._numWorkers = Math.min(4, navigator.hardwareConcurrency); + this._jobMap = new Map(); + this._workerReady = false; + this._galleryId = config.galleryId; + Decryptor._initPage(); + if (!("password" in config && config.password)) { + this._askPassword() + .then((password) => { + config.password = password; + this._createWorkerPool(config); + }) + } else { + this._createWorkerPool(config); + } + } + + console.info("Decryptor initialized"); + } + + /* main thread only */ + static init(config) { + if (Decryptor.isWorker()) return; + window.decryptor = new Decryptor(config); + } + + static isWorker() { + return ('undefined' !== typeof WorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator); + } + + static _getCrypto() { + if(crypto && crypto.subtle) { + return crypto.subtle; + } else { + throw new Error("Fatal: Browser does not support Web Crypto"); + } + } + + /* main thread only */ + async _askPassword() { + let password = sessionStorage.getItem(this._galleryId); + if (!password) { + return new Promise((s, e) => { + window.addEventListener( + "load", + s, + { once: true, passive: true } + ); + }).then((e) => { + const password = prompt("Input password to view this gallery:"); + if (password) { + sessionStorage.setItem(this._galleryId, password); + return password; + } else { + return "__wrong_password__"; + } + }); + } else { + return 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"] + ); + } + + async _doReload(url, img) { + const proceed = Decryptor._sendEvent(img, "DecryptImageBeforeLoad", {oldSrc: url}); + if (proceed) { + let old_src = url; + try { + const blobUrl = await this.dispatchJob("reloadImage", [old_src, null]); + img.addEventListener( + "load", + (e) => Decryptor._sendEvent(e.target, "DecryptImageLoaded", {oldSrc: old_src}), + {once: true, passive: true} + ); + img.src = blobUrl; + } catch (error) { + img.addEventListener( + "load", + (e) => Decryptor._sendEvent(e.target, "DecryptImageError", {oldSrc: old_src, error: error}), + {once: true, passive: true} + ); + img.src = Decryptor.imagePlaceholderURL; + // password is incorrect + if (error.message.indexOf("decryption failed") >= 0) { + sessionStorage.removeItem(this._galleryId); + } + throw new Error(`Image reload failed: ${error.message}`); + } + } + } + + async reloadImage(url, img) { + if (this._role === "main") { + const full_url = (new URL(url, window.location)).toString(); + if (!this.isWorkerReady()) { + document.addEventListener( + "DecryptWorkerReady", + (e) => { this._doReload(full_url, img); }, + {once: true, passive: true} + ); + } else { + this._doReload(full_url, img); + } + } else if (this._role === "worker") { + let r; + try { + r = await fetch(url); + } catch (e) { + throw new Error("fetch failed"); + } + if (r && r.ok) { + const encrypted_blob = await r.blob(); + try { + const decrypted_blob = await this._decrypt(encrypted_blob); + return URL.createObjectURL(decrypted_blob); + } catch (e) { + throw new Error(`decryption failed: ${e.message}`); + } + } else { + throw new Error("fetch failed"); + } + } + } + + /* main thread only */ + static onNewImageError(e) { + if (e.target.src.startsWith("blob")) return; + if (!window.decryptor) return; + + window.decryptor.reloadImage(e.target.src, e.target); + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + } + + static _sendEvent(target, type, detail = null) { + const eventInit = { + detail: detail, + bubbles: true, + cancelable: true + }; + return target.dispatchEvent(new CustomEvent(type, eventInit)); + } + + /* main thread only */ + static _initPage() { + document.addEventListener( + "error", + e => { + if (e.target instanceof HTMLImageElement) { + Decryptor.onNewImageError(e); + } + }, + {capture: true} + ); + + Image = (function (oldImage) { + function Image(...args) { + let img = new oldImage(...args); + img.addEventListener( + "error", + Decryptor.onNewImageError + ); + return img; + } + Image.prototype = oldImage.prototype; + Image.prototype.constructor = Image; + return Image; + })(Image); + + document.createElement = (function(create) { + return function() { + let ret = create.apply(this, arguments); + if (ret.tagName.toLowerCase() === "img") { + ret.addEventListener( + "error", + Decryptor.onNewImageError + ); + } + return ret; + }; + })(document.createElement); + } + + static async decrypt(crypto, blob, aes_key, gcm_tag) { + const iv = await blob.slice(0, 12).arrayBuffer(); + const ciphertext = await blob.slice(12).arrayBuffer(); + const decrypted = await crypto.decrypt( + { + name: "AES-GCM", + iv: iv, + additionalData: gcm_tag + }, + aes_key, + ciphertext + ); + return new Blob([decrypted], {type: blob.type}); + } + + isWorkerReady() { + return this._workerReady; + } + + _createWorkerPool(config) { + if (this._role !== "main") return; + if (this._workerReady) return; + + let callback = (e) => { + const callbacks = this._jobMap.get(e.data.id); + if (e.data.success) { + if (callbacks.success) callbacks.success(e.data.result); + } else { + if (callbacks.error) callbacks.error(new Error(e.data.result)); + } + this._jobMap.delete(e.data.id); + }; + + let pool = Array(); + + for (let i = 0; i < this._numWorkers; i++) { + let worker = new Worker(config.worker_script); + worker.onmessage = callback; + pool.push(worker); + } + this._workerPool = pool; + + let notReadyWorkers = this._numWorkers; + for (let i = 0; i < this._numWorkers; i++) { + this.dispatchJob("new", [config]) + .then(() => { + if (--notReadyWorkers <= 0) { + this._workerReady = true; + Decryptor._sendEvent(document, "DecryptWorkerReady"); + } + }); + } + } + + /* + * method: string + * args: Array + */ + dispatchJob(method, args) { + if (this._role === "main") { + return new Promise((success, error) => { + const jobId = this._jobCount++; + const worker = this._workerPool[jobId % this._numWorkers]; + this._jobMap.set(jobId, {success: success, error: error}); + Decryptor._postJobToWorker(jobId, worker, method, args); + }); + } else if (this._role === "worker") { + return Decryptor._asyncReturn(this, method, args) + .then( + (result) => { return {success: true, result: result}; }, + (error) => { return {success: false, result: error.message}; } + ); + } + } + + static _asyncReturn(instance, method, args) { + if (method in instance && instance[method] instanceof Function) { + 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); + } + } else { + return Promise.reject(new Error(`no such method: ${method}`)) + } + } + + static _postJobToWorker(jobId, worker, method, args) { + const job = { + id: jobId, + method: method, + args: args + }; + worker.postMessage(job); + } + + /* worker thread only */ + static onWorkerMessage(e) { + const id = e.data.id; + const method = e.data.method; + const args = e.data.args; + + if (method === "new") { + self.decryptor = new Decryptor(...args); + self.addEventListener( + "DecryptWorkerReady", + (e) => self.postMessage({id: id, success: true, result: "worker ready"}), + {once: true, passive: true} + ); + } else { + self.decryptor + .dispatchJob(method, args) + .then((reply) => { + reply.id = id; + self.postMessage(reply); + }); + } + } +} + +Decryptor.imagePlaceholderURL = URL.createObjectURL(new Blob([ +` + + + background + + + + + + + Layer 1 + Could not + load + image + +`], {type: "image/svg+xml"})); + diff --git a/tests/sample/sigal.conf.py b/tests/sample/sigal.conf.py index 4db648a3..b07d7710 100644 --- a/tests/sample/sigal.conf.py +++ b/tests/sample/sigal.conf.py @@ -17,7 +17,16 @@ 'sigal.plugins.nomedia', 'sigal.plugins.watermark', 'sigal.plugins.zip_gallery', + 'sigal.plugins.encrypt' ] +encrypt_options = { + 'password': 'password', + 'ask_password': True, + 'gcm_tag': 'AuTheNTiCatIoNtAG', + 'kdf_salt': 'saltysaltsweetysweet', + 'kdf_iters': 10000, + 'encrypt_symlinked_originals': False +} copyright = '© An example copyright message' adjust_options = {'color': 0.9, 'brightness': 1.0, 'contrast': 1.0, 'sharpness': 0.0} diff --git a/tox.ini b/tox.ini index b9eef2db..96ef8eba 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,8 @@ commands = usedevelop = true deps = feedgenerator + cryptography + beautifulsoup4 commands = sigal build -c tests/sample/sigal.conf.py sigal serve tests/sample/_build From 7f649e20ad5adffdb523ea4f23b12e397cd55734 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Tue, 31 Mar 2020 01:04:39 +0800 Subject: [PATCH 02/19] Add name in AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 3ee43af8..2c544acb 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ alphabetical order): - Andriy Dzedolik (@IrvinDitz) - Antoine Beaupré - Antoine Pitrou +- Bowen Ding (@dbw9580) - Brent Bandelgar (@brentbb) - Cédric Bosdonnat - Christophe-Marie Duquesne From 9b84cb4e0420e0400d93a2e652e5251724f458c7 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Tue, 31 Mar 2020 16:08:09 +0800 Subject: [PATCH 03/19] Add plugin example settings in config template --- sigal/templates/sigal.conf.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/sigal/templates/sigal.conf.py b/sigal/templates/sigal.conf.py index 480fbad2..dd4ea4f8 100644 --- a/sigal/templates/sigal.conf.py +++ b/sigal/templates/sigal.conf.py @@ -243,7 +243,8 @@ # from this file must be serializable). # plugins = ['sigal.plugins.adjust', 'sigal.plugins.copyright', # 'sigal.plugins.upload_s3', 'sigal.plugins.media_page', -# 'sigal.plugins.nomedia', 'sigal.plugins.extended_caching'] +# 'sigal.plugins.nomedia', 'sigal.plugins.extended_caching', +# 'sigal.plugins.encrypt'] # Add a copyright text on the image (default: '') # copyright = "© An example copyright message" @@ -266,3 +267,9 @@ # compress_assets_options = { # 'method': 'gzip' # Or 'zopfli' or 'brotli' # } + +# Settings for encryption plugin +# encrypt_options = { +# 'password': 'password', +# 'ask_password': False +# } From d08ec6727cbcb6f6fc8ec9522bdd1af22c6e0372 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Thu, 2 Apr 2020 00:07:06 +0800 Subject: [PATCH 04/19] Add signal to inject JS files via Jinja templates --- sigal/plugins/encrypt/encrypt.py | 129 ++++++++------------ sigal/signals.py | 1 + sigal/themes/colorbox/templates/base.html | 1 + sigal/themes/default/templates/decrypt.html | 13 ++ sigal/themes/galleria/templates/base.html | 1 + sigal/themes/photoswipe/templates/base.html | 1 + sigal/writer.py | 7 +- 7 files changed, 70 insertions(+), 83 deletions(-) create mode 100644 sigal/themes/default/templates/decrypt.html diff --git a/sigal/plugins/encrypt/encrypt.py b/sigal/plugins/encrypt/encrypt.py index 5ee888b8..32ab0e39 100644 --- a/sigal/plugins/encrypt/encrypt.py +++ b/sigal/plugins/encrypt/encrypt.py @@ -62,6 +62,8 @@ from .endec import encrypt, kdf_gen_key +logger = logging.getLogger(__name__) + ASSETS_PATH = os.path.normpath(os.path.join( os.path.abspath(os.path.dirname(__file__)), 'static')) @@ -71,13 +73,12 @@ class Abort(Exception): def gen_rand_string(length=16): return "".join(random.SystemRandom().choices(string.ascii_letters + string.digits, k=length)) -def get_options(gallery): - settings = gallery.settings - cache = gallery.encryptCache +def get_options(settings, cache): if "encrypt_options" not in settings: - raise ValueError("Encrypt: no options in settings") + logging.error("Encrypt: no options in settings") + raise Abort - #try load credential from cache + # try load credential from cache try: options = cache["credentials"] except KeyError: @@ -85,11 +86,16 @@ def get_options(gallery): table = str.maketrans({'"': r'\"', '\\': r'\\'}) if "password" not in settings["encrypt_options"]: - raise ValueError("Encrypt: no password provided") + logger.error("Encrypt: no password provided") + raise Abort else: options["password"] = settings["encrypt_options"]["password"] options["escaped_password"] = options["password"].translate(table) + if "ask_password" not in options: + options["ask_password"] = settings["encrypt_options"].get("ask_password", False) + options["filtered_password"] = "" if options["ask_password"] else options["escaped_password"] + if "gcm_tag" not in options: options["gcm_tag"] = gen_rand_string() options["escaped_gcm_tag"] = options["gcm_tag"].translate(table) @@ -104,10 +110,8 @@ def get_options(gallery): if "kdf_iters" not in options: options["kdf_iters"] = 10000 - if "ask_password" not in options: - options["ask_password"] = settings["encrypt_options"].get("ask_password", False) - - gallery.encryptCache["credentials"] = { + # in case any of the credentials are newly generated, write them back to cache + cache["credentials"] = { "gcm_tag": options["gcm_tag"], "kdf_salt": options["kdf_salt"], "kdf_iters": options["kdf_iters"], @@ -141,9 +145,11 @@ def get_encrypt_list(settings, media): return to_encrypt def load_property(album): - if not hasattr(album.gallery, "encryptCache"): - load_cache(album.gallery) - cache = album.gallery.encryptCache + gallery = album.gallery + try: + cache = load_cache(gallery.settings) + except Abort: + return for media in album.medias: if media.type == "image": @@ -152,32 +158,22 @@ def load_property(album): media.size = cache[key]["size"] media.thumb_size = cache[key]["thumb_size"] -def load_cache(gallery): - if hasattr(gallery, "encryptCache"): - return - logger = gallery.logger - settings = gallery.settings +def load_cache(settings): cachePath = os.path.join(settings["destination"], ".encryptCache") - try: with open(cachePath, "rb") as cacheFile: - gallery.encryptCache = pickle.load(cacheFile) - logger.debug("Loaded encryption cache with %d entries", len(gallery.encryptCache)) + encryptCache = pickle.load(cacheFile) + logger.debug("Loaded encryption cache with %d entries", len(encryptCache)) + return encryptCache except FileNotFoundError: - gallery.encryptCache = {} + encryptCache = {} + return encryptCache except Exception as e: logger.error("Could not load encryption cache: %s", e) logger.error("Giving up encryption. Please delete and rebuild the entire gallery.") raise Abort -def save_cache(gallery): - if hasattr(gallery, "encryptCache"): - cache = gallery.encryptCache - else: - cache = gallery.encryptCache = {} - - logger = gallery.logger - settings = gallery.settings +def save_cache(settings, cache): cachePath = os.path.join(settings["destination"], ".encryptCache") try: with open(cachePath, "wb") as cacheFile: @@ -188,25 +184,24 @@ def save_cache(gallery): logger.warning("Next build of the gallery is likely to fail!") def encrypt_gallery(gallery): - logger = gallery.logger albums = gallery.albums settings = gallery.settings try: - load_cache(gallery) - config = get_options(gallery) + cache = load_cache(settings) + config = get_options(settings, cache) logger.debug("encryption config: %s", config) + # make cache available from gallery object + # gallery.encryptCache = cache + logger.info("starting encryption") - encrypt_files(gallery, settings, config, albums) - fix_html(gallery, settings, config, albums) + encrypt_files(settings, config, cache, albums, gallery.progressbar_target) copy_assets(settings) + save_cache(settings, cache) except Abort: pass - save_cache(gallery) - -def encrypt_files(gallery, settings, config, albums): - logger = gallery.logger +def encrypt_files(settings, config, cache, albums, progressbar_target): if settings["keep_orig"]: if settings["orig_link"] and not config["encrypt_symlinked_originals"]: logger.warning("Original files are symlinked! Set encrypt_options[\"encrypt_symlinked_originals\"] to True to force encrypting them, if this is what you want.") @@ -214,16 +209,16 @@ def encrypt_files(gallery, settings, config, albums): key = kdf_gen_key(config["password"].encode("utf-8"), config["kdf_salt"].encode("utf-8"), config["kdf_iters"]) medias = list(chain.from_iterable(albums.values())) - with progressbar(medias, label="%16s" % "Encrypting files", file=gallery.progressbar_target, show_eta=True) as medias: + with progressbar(medias, label="%16s" % "Encrypting files", file=progressbar_target, show_eta=True) as medias: for media in medias: if media.type != "image": logger.info("Skipping non-image file %s", media.filename) continue - save_property(gallery.encryptCache, media) + save_property(cache, media) to_encrypt = get_encrypt_list(settings, media) - cacheEntry = gallery.encryptCache[cache_key(media)]["encrypted"] + cacheEntry = cache[cache_key(media)]["encrypted"] for f in to_encrypt: if f in cacheEntry: logger.info("Skipping %s as it is already encrypted", f) @@ -245,53 +240,25 @@ def encrypt_files(gallery, settings, config, albums): except Exception as e: logger.error("Could not write to file %s: %s", f, e) -def fix_html(gallery, settings, config, albums): - logger = gallery.logger - if gallery.settings["write_html"]: - decryptorConfigTemplate = """ - Decryptor.init({{ - password: "{filtered_password}", - worker_script: "{worker_script}", - galleryId: "{galleryId}", - gcm_tag: "{escaped_gcm_tag}", - kdf_salt: "{escaped_kdf_salt}", - kdf_iters: {kdf_iters} - }}); - """ - config["filtered_password"] = "" if config.get("ask_password", False) else config["escaped_password"] - - with progressbar(albums.values(), label="%16s" % "Fixing html files", file=gallery.progressbar_target, show_eta=True) as albums: - for album in albums: - index_file = os.path.join(album.dst_path, album.output_file) - contents = None - with open(index_file, "r", encoding="utf-8") as f: - contents = f.read() - root = BeautifulSoup(contents, "html.parser") - head = root.find("head") - if head.find(id="_decrypt_script"): - head.find(id="_decrypt_script").decompose() - if head.find(id="_decrypt_script_config"): - head.find(id="_decrypt_script_config").decompose() - theme_path = os.path.join(settings["destination"], 'static') - theme_url = url_from_path(os.path.relpath(theme_path, album.dst_path)) - scriptNode = root.new_tag("script", id="_decrypt_script", src="{url}/decrypt.js".format(url=theme_url)) - scriptConfig = root.new_tag("script", id="_decrypt_script_config") - config["worker_script"] = "{url}/decrypt-worker.js".format(url=theme_url) - decryptorConfig = decryptorConfigTemplate.format(**config) - scriptConfig.append(root.new_string(decryptorConfig)) - head.append(scriptNode) - head.append(scriptConfig) - with open(index_file, "w", encoding="utf-8") as f: - f.write(root.prettify()) - def copy_assets(settings): theme_path = os.path.join(settings["destination"], 'static') for root, dirs, files in os.walk(ASSETS_PATH): for file in files: copy(os.path.join(root, file), theme_path, symlink=False, rellink=False) +def inject_scripts(context): + try: + cache = load_cache(context['settings']) + context["encrypt_options"] = get_options(context['settings'], cache) + except Abort: + # we can't do anything useful without info in cache + # so just return the context unmodified + pass + + return context def register(settings): signals.gallery_build.connect(encrypt_gallery) signals.album_initialized.connect(load_property) + signals.before_render.connect(inject_scripts) diff --git a/sigal/signals.py b/sigal/signals.py index a4a8cf6a..9f1f0e4b 100644 --- a/sigal/signals.py +++ b/sigal/signals.py @@ -8,3 +8,4 @@ media_initialized = signal('media_initialized') albums_sorted = signal('albums_sorted') medias_sorted = signal('medias_sorted') +before_render = signal('before_render') diff --git a/sigal/themes/colorbox/templates/base.html b/sigal/themes/colorbox/templates/base.html index 27a8d592..18362608 100644 --- a/sigal/themes/colorbox/templates/base.html +++ b/sigal/themes/colorbox/templates/base.html @@ -13,6 +13,7 @@ {% block extra_head %}{% endblock extra_head %} {% include 'analytics.html' %} + {% include 'decrypt.html' %} {% include 'gtm.html' %} diff --git a/sigal/themes/default/templates/decrypt.html b/sigal/themes/default/templates/decrypt.html new file mode 100644 index 00000000..39146abe --- /dev/null +++ b/sigal/themes/default/templates/decrypt.html @@ -0,0 +1,13 @@ +{% if 'sigal.plugins.encrypt' is in settings.plugins %} + + +{% endif %} \ No newline at end of file diff --git a/sigal/themes/galleria/templates/base.html b/sigal/themes/galleria/templates/base.html index 05a970b0..6e96933e 100644 --- a/sigal/themes/galleria/templates/base.html +++ b/sigal/themes/galleria/templates/base.html @@ -14,6 +14,7 @@ {% block extra_head %}{% endblock extra_head %} {% include 'analytics.html' %} + {% include 'decrypt.html' %} {% include 'gtm.html' %} diff --git a/sigal/themes/photoswipe/templates/base.html b/sigal/themes/photoswipe/templates/base.html index ce556f43..70b4f884 100644 --- a/sigal/themes/photoswipe/templates/base.html +++ b/sigal/themes/photoswipe/templates/base.html @@ -11,6 +11,7 @@ {% block extra_head %}{% endblock extra_head %} {% include 'analytics.html' %} + {% include 'decrypt.html' %} {% include 'gtm.html' %} diff --git a/sigal/writer.py b/sigal/writer.py index e91c3b66..5a629f56 100644 --- a/sigal/writer.py +++ b/sigal/writer.py @@ -31,6 +31,7 @@ from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader from jinja2.exceptions import TemplateNotFound +from . import signals from .utils import url_from_path THEMES_PATH = os.path.normpath(os.path.join( @@ -112,8 +113,10 @@ def generate_context(self, album): def write(self, album): """Generate the HTML page and save it.""" - - page = self.template.render(**self.generate_context(album)) + context = self.generate_context(album) + for receiver in signals.before_render.receivers_for(context): + context = receiver(context) + page = self.template.render(**context) output_file = os.path.join(album.dst_path, album.output_file) with open(output_file, 'w', encoding='utf-8') as f: From fa072c5abacd3704c55c82e09566570df17fd0de Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Thu, 2 Apr 2020 00:12:02 +0800 Subject: [PATCH 05/19] Hardcode all JS files to copy --- sigal/plugins/encrypt/encrypt.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sigal/plugins/encrypt/encrypt.py b/sigal/plugins/encrypt/encrypt.py index 32ab0e39..bd94d47e 100644 --- a/sigal/plugins/encrypt/encrypt.py +++ b/sigal/plugins/encrypt/encrypt.py @@ -242,9 +242,8 @@ def encrypt_files(settings, config, cache, albums, progressbar_target): def copy_assets(settings): theme_path = os.path.join(settings["destination"], 'static') - for root, dirs, files in os.walk(ASSETS_PATH): - for file in files: - copy(os.path.join(root, file), theme_path, symlink=False, rellink=False) + copy(ASSETS_PATH + "/decrypt.js", theme_path, symlink=False, rellink=False) + copy(ASSETS_PATH + "/decrypt-worker.js", theme_path, symlink=False, rellink=False) def inject_scripts(context): try: From bbfbe746dd694d4bff4c93f2e983eefd97eabf96 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Thu, 2 Apr 2020 00:25:29 +0800 Subject: [PATCH 06/19] Prevent empty passwords --- sigal/plugins/encrypt/encrypt.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sigal/plugins/encrypt/encrypt.py b/sigal/plugins/encrypt/encrypt.py index bd94d47e..2886400a 100644 --- a/sigal/plugins/encrypt/encrypt.py +++ b/sigal/plugins/encrypt/encrypt.py @@ -85,7 +85,8 @@ def get_options(settings, cache): options = settings["encrypt_options"] table = str.maketrans({'"': r'\"', '\\': r'\\'}) - if "password" not in settings["encrypt_options"]: + if "password" not in settings["encrypt_options"] \ + or len(settings["encrypt_options"]["password"]) == 0: logger.error("Encrypt: no password provided") raise Abort else: From 551f26df63782fe3547ce4ccc364be64f7c87412 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Mon, 6 Apr 2020 23:42:56 +0800 Subject: [PATCH 07/19] Rewrite with service worker --- sigal/plugins/encrypt/encrypt.py | 49 +- sigal/plugins/encrypt/endec.py | 5 + .../plugins/encrypt/static/decrypt-worker.js | 26 - sigal/plugins/encrypt/static/decrypt.js | 680 +++++++++++------- sigal/plugins/encrypt/static/keycheck.txt | 1 + sigal/plugins/encrypt/static/sw.js | 7 + sigal/themes/default/templates/decrypt.html | 2 +- 7 files changed, 456 insertions(+), 314 deletions(-) delete mode 100644 sigal/plugins/encrypt/static/decrypt-worker.js create mode 100644 sigal/plugins/encrypt/static/keycheck.txt create mode 100644 sigal/plugins/encrypt/static/sw.js diff --git a/sigal/plugins/encrypt/encrypt.py b/sigal/plugins/encrypt/encrypt.py index 2886400a..f3e1f5c1 100644 --- a/sigal/plugins/encrypt/encrypt.py +++ b/sigal/plugins/encrypt/encrypt.py @@ -196,8 +196,8 @@ def encrypt_gallery(gallery): # gallery.encryptCache = cache logger.info("starting encryption") - encrypt_files(settings, config, cache, albums, gallery.progressbar_target) copy_assets(settings) + encrypt_files(settings, config, cache, albums, gallery.progressbar_target) save_cache(settings, cache) except Abort: pass @@ -209,6 +209,8 @@ def encrypt_files(settings, config, cache, albums, progressbar_target): raise Abort key = kdf_gen_key(config["password"].encode("utf-8"), config["kdf_salt"].encode("utf-8"), config["kdf_iters"]) + gcm_tag = config["gcm_tag"].encode("utf-8") + medias = list(chain.from_iterable(albums.values())) with progressbar(medias, label="%16s" % "Encrypting files", file=progressbar_target, show_eta=True) as medias: for media in medias: @@ -226,25 +228,38 @@ def encrypt_files(settings, config, cache, albums, progressbar_target): continue full_path = os.path.join(settings["destination"], f) - with BytesIO() as outBuffer: - try: - with open(full_path, "rb") as infile: - encrypt(key, infile, outBuffer, config["gcm_tag"].encode("utf-8")) - except Exception as e: - logger.error("Encryption failed for %s: %s", f, e) - else: - logger.info("Encrypting %s...", f) - try: - with open(full_path, "wb") as outfile: - outfile.write(outBuffer.getbuffer()) - cacheEntry.add(f) - except Exception as e: - logger.error("Could not write to file %s: %s", f, e) + if encrypt_file(f, full_path, key, gcm_tag): + cacheEntry.add(f) + + key_check_path = os.path.join( + os.path.join(settings["destination"], 'static'), + 'keycheck.txt' + ) + encrypt_file("keycheck.txt", key_check_path, key, gcm_tag) + +def encrypt_file(filename, full_path, key, gcm_tag): + with BytesIO() as outBuffer: + try: + with open(full_path, "rb") as infile: + encrypt(key, infile, outBuffer, gcm_tag) + except Exception as e: + logger.error("Encryption failed for %s: %s", filename, e) + return False + else: + logger.info("Encrypting %s...", filename) + try: + with open(full_path, "wb") as outfile: + outfile.write(outBuffer.getbuffer()) + except Exception as e: + logger.error("Could not write to file %s: %s", filename, e) + return False + return True def copy_assets(settings): theme_path = os.path.join(settings["destination"], 'static') - copy(ASSETS_PATH + "/decrypt.js", theme_path, symlink=False, rellink=False) - copy(ASSETS_PATH + "/decrypt-worker.js", theme_path, symlink=False, rellink=False) + copy(os.path.join(ASSETS_PATH, "decrypt.js"), theme_path, symlink=False, rellink=False) + copy(os.path.join(ASSETS_PATH, "keycheck.txt"), theme_path, symlink=False, rellink=False) + copy(os.path.join(ASSETS_PATH, "sw.js"), settings["destination"], symlink=False, rellink=False) def inject_scripts(context): try: diff --git a/sigal/plugins/encrypt/endec.py b/sigal/plugins/encrypt/endec.py index 37ca8d81..9316fc83 100644 --- a/sigal/plugins/encrypt/endec.py +++ b/sigal/plugins/encrypt/endec.py @@ -32,6 +32,7 @@ from typing import BinaryIO backend = default_backend() +MAGIC_STRING = "_e_n_c_r_y_p_t_e_d_" def kdf_gen_key(password: bytes, salt:bytes, iters: int) -> bytes: kdf = PBKDF2HMAC( @@ -74,6 +75,7 @@ def encrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes): ciphertext = outfile rawbytes = plaintext.read() encrypted = aesgcm.encrypt(iv, rawbytes, tag) + ciphertext.write(MAGIC_STRING.encode("utf-8")) ciphertext.write(iv) ciphertext.write(encrypted) @@ -83,6 +85,9 @@ def decrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes): aesgcm = AESGCM(key) ciphertext = infile plaintext = outfile + magicstring = ciphertext.read(len(MAGIC_STRING)) + if magicstring != MAGIC_STRING.encode("utf-8"): + raise ValueError("Data is not encrypted") iv = ciphertext.read(12) rawbytes = ciphertext.read() try: diff --git a/sigal/plugins/encrypt/static/decrypt-worker.js b/sigal/plugins/encrypt/static/decrypt-worker.js deleted file mode 100644 index f86c3290..00000000 --- a/sigal/plugins/encrypt/static/decrypt-worker.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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" -importScripts("decrypt.js"); - -onmessage = Decryptor.onWorkerMessage; diff --git a/sigal/plugins/encrypt/static/decrypt.js b/sigal/plugins/encrypt/static/decrypt.js index 7a0c3b8d..740e781d 100644 --- a/sigal/plugins/encrypt/static/decrypt.js +++ b/sigal/plugins/encrypt/static/decrypt.js @@ -23,51 +23,154 @@ "use strict" class Decryptor { constructor(config) { - const c = Decryptor._getCrypto(); - if (Decryptor.isWorker()) { - this._role = "worker"; - 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); - - Decryptor - ._initAesKey(c, salt, iters, shared_key) - .then((aes_key) => { - this._decrypt = (encrypted_blob_arraybuffer) => - Decryptor.decrypt(c, encrypted_blob_arraybuffer, - aes_key, gcm_tag); - }) - .then(() => { - Decryptor._sendEvent(self, "DecryptWorkerReady"); - }); - } else { + 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. Please update your browser."); + return; + } this._role = "main"; - this._jobCount = 0; - this._numWorkers = Math.min(4, navigator.hardwareConcurrency); - this._jobMap = new Map(); - this._workerReady = false; this._galleryId = config.galleryId; - Decryptor._initPage(); - if (!("password" in config && config.password)) { - this._askPassword() - .then((password) => { - config.password = password; - this._createWorkerPool(config); - }) - } else { - this._createWorkerPool(config); - } + this._mSetupServiceWorker(config); } console.info("Decryptor initialized"); } - /* main thread only */ static init(config) { - if (Decryptor.isWorker()) return; - window.decryptor = new Decryptor(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 { + const array_clients = await self.clients.matchAll({includeUncontrolled: true}); + for (let client of array_clients) { + this._proxyWrap(client)._mNotifyIncorrectPassword(); + } + } + } + + 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)._mReload(); + } + } + + 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"); + } + } + + _mReload() { + window.location.reload(); + } + + _mNotifyIncorrectPassword() { + localStorage.removeItem(this._galleryId); + } + + async _mSetupServiceWorker(config) { + 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(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 config && config.password)) { + config.password = await this._mAskPassword(); + } + this.serviceWorker._swInitServiceWorker(config); + } + } + + static isServiceWorker() { + return ('undefined' !== typeof ServiceWorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator); } static isWorker() { @@ -75,7 +178,7 @@ class Decryptor { } static _getCrypto() { - if(crypto && crypto.subtle) { + if('undefined' !== typeof crypto && crypto.subtle) { return crypto.subtle; } else { throw new Error("Fatal: Browser does not support Web Crypto"); @@ -83,24 +186,16 @@ class Decryptor { } /* main thread only */ - async _askPassword() { - let password = sessionStorage.getItem(this._galleryId); + async _mAskPassword() { + let password = localStorage.getItem(this._galleryId); if (!password) { - return new Promise((s, e) => { - window.addEventListener( - "load", - s, - { once: true, passive: true } - ); - }).then((e) => { - const password = prompt("Input password to view this gallery:"); - if (password) { - sessionStorage.setItem(this._galleryId, password); - return password; - } else { - return "__wrong_password__"; - } - }); + const password = prompt("Input password to view this gallery:"); + if (password) { + localStorage.setItem(this._galleryId, password); + return password; + } else { + return "__wrong_password__"; + } } else { return password; } @@ -129,78 +224,6 @@ class Decryptor { ); } - async _doReload(url, img) { - const proceed = Decryptor._sendEvent(img, "DecryptImageBeforeLoad", {oldSrc: url}); - if (proceed) { - let old_src = url; - try { - const blobUrl = await this.dispatchJob("reloadImage", [old_src, null]); - img.addEventListener( - "load", - (e) => Decryptor._sendEvent(e.target, "DecryptImageLoaded", {oldSrc: old_src}), - {once: true, passive: true} - ); - img.src = blobUrl; - } catch (error) { - img.addEventListener( - "load", - (e) => Decryptor._sendEvent(e.target, "DecryptImageError", {oldSrc: old_src, error: error}), - {once: true, passive: true} - ); - img.src = Decryptor.imagePlaceholderURL; - // password is incorrect - if (error.message.indexOf("decryption failed") >= 0) { - sessionStorage.removeItem(this._galleryId); - } - throw new Error(`Image reload failed: ${error.message}`); - } - } - } - - async reloadImage(url, img) { - if (this._role === "main") { - const full_url = (new URL(url, window.location)).toString(); - if (!this.isWorkerReady()) { - document.addEventListener( - "DecryptWorkerReady", - (e) => { this._doReload(full_url, img); }, - {once: true, passive: true} - ); - } else { - this._doReload(full_url, img); - } - } else if (this._role === "worker") { - let r; - try { - r = await fetch(url); - } catch (e) { - throw new Error("fetch failed"); - } - if (r && r.ok) { - const encrypted_blob = await r.blob(); - try { - const decrypted_blob = await this._decrypt(encrypted_blob); - return URL.createObjectURL(decrypted_blob); - } catch (e) { - throw new Error(`decryption failed: ${e.message}`); - } - } else { - throw new Error("fetch failed"); - } - } - } - - /* main thread only */ - static onNewImageError(e) { - if (e.target.src.startsWith("blob")) return; - if (!window.decryptor) return; - - window.decryptor.reloadImage(e.target.src, e.target); - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - } - static _sendEvent(target, type, detail = null) { const eventInit = { detail: detail, @@ -210,186 +233,303 @@ class Decryptor { return target.dispatchEvent(new CustomEvent(type, eventInit)); } - /* main thread only */ - static _initPage() { - document.addEventListener( - "error", - e => { - if (e.target instanceof HTMLImageElement) { - Decryptor.onNewImageError(e); - } - }, - {capture: true} + static async checkMagicString(arraybuffer) { + const sample = new DataView( + arraybuffer, + 0, + Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength ); - - Image = (function (oldImage) { - function Image(...args) { - let img = new oldImage(...args); - img.addEventListener( - "error", - Decryptor.onNewImageError - ); - return img; + for (let i = 0; i < Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength; i++) { + if (Decryptor.MAGIC_STRING_ARRAYBUFFER[i] !== sample.getUint8(i)) { + return false; } - Image.prototype = oldImage.prototype; - Image.prototype.constructor = Image; - return Image; - })(Image); - - document.createElement = (function(create) { - return function() { - let ret = create.apply(this, arguments); - if (ret.tagName.toLowerCase() === "img") { - ret.addEventListener( - "error", - Decryptor.onNewImageError - ); - } - return ret; - }; - })(document.createElement); - } - - static async decrypt(crypto, blob, aes_key, gcm_tag) { - const iv = await blob.slice(0, 12).arrayBuffer(); - const ciphertext = await blob.slice(12).arrayBuffer(); - const decrypted = await crypto.decrypt( - { - name: "AES-GCM", - iv: iv, - additionalData: gcm_tag - }, - aes_key, - ciphertext - ); - return new Blob([decrypted], {type: blob.type}); - } - - isWorkerReady() { - return this._workerReady; + } + return true; } - _createWorkerPool(config) { - if (this._role !== "main") return; - if (this._workerReady) return; + 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"); + } - let callback = (e) => { - const callbacks = this._jobMap.get(e.data.id); - if (e.data.success) { - if (callbacks.success) callbacks.success(e.data.result); - } else { - if (callbacks.error) callbacks.error(new Error(e.data.result)); - } - this._jobMap.delete(e.data.id); - }; + // 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"); + } - let pool = Array(); - - for (let i = 0; i < this._numWorkers; i++) { - let worker = new Worker(config.worker_script); - worker.onmessage = callback; - pool.push(worker); + if (check_magic_string && !(await Decryptor.checkMagicString(arraybuffer))) { + // data is not encrypted + return blob; } - this._workerPool = pool; - - let notReadyWorkers = this._numWorkers; - for (let i = 0; i < this._numWorkers; i++) { - this.dispatchJob("new", [config]) - .then(() => { - if (--notReadyWorkers <= 0) { - this._workerReady = true; - Decryptor._sendEvent(document, "DecryptWorkerReady"); - } - }); + + 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; } } - /* - * method: string - * args: Array - */ - dispatchJob(method, args) { - if (this._role === "main") { - return new Promise((success, error) => { - const jobId = this._jobCount++; - const worker = this._workerPool[jobId % this._numWorkers]; - this._jobMap.set(jobId, {success: success, error: error}); - Decryptor._postJobToWorker(jobId, worker, method, args); - }); - } else if (this._role === "worker") { - return Decryptor._asyncReturn(this, method, args) - .then( - (result) => { return {success: true, result: result}; }, - (error) => { return {success: false, result: error.message}; } - ); + _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); } - static _asyncReturn(instance, method, args) { - if (method in instance && instance[method] instanceof Function) { - 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); + _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_); + }); } - } else { - return Promise.reject(new Error(`no such method: ${method}`)) - } + }; + return new Proxy(dummyFunction, handler); } - static _postJobToWorker(jobId, worker, method, args) { + static _rpcPostJob(jobId, messagePort, method, args, static_=false) { const job = { + type: "job", id: jobId, method: method, - args: args + args: args, + static: static_ }; - worker.postMessage(job); + 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); + } } - /* worker thread only */ - static onWorkerMessage(e) { + static onMessage(replyPort, e) { + const type = e.data.type; const id = e.data.id; const method = e.data.method; const args = e.data.args; + const instance = e.data.static ? Decryptor : (Decryptor.isWorker() ? self : window).decryptor; - if (method === "new") { - self.decryptor = new Decryptor(...args); - self.addEventListener( - "DecryptWorkerReady", - (e) => self.postMessage({id: id, success: true, result: "worker ready"}), - {once: true, passive: true} - ); - } else { - self.decryptor - .dispatchJob(method, args) + if (type === "job") { + 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; - self.postMessage(reply); + replyPort.postMessage(reply); }); + } else if (type === "reply") { + const callbacks = decryptor._jobMap.get(e.data.id); + if (e.data.success) { + if (callbacks.success) callbacks.success(e.data.result); + } else { + if (callbacks.error) callbacks.error(new Error(e.data.result)); + } + decryptor._jobMap.delete(e.data.id); + } + } + + static async 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()) { + 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.imagePlaceholderURL = URL.createObjectURL(new Blob([ +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([ ` - background - - - - + background + + + + - Layer 1 - Could not - load - image + Layer 1 + Could not + load + image -`], {type: "image/svg+xml"})); +`], {type: "image/svg+xml"}); + +Decryptor.errorResponse = new Response( + Decryptor.imagePlaceholderBlob, + { + status: 200, + statusText: "OK", + headers: { + "content-type": "image/svg+xml" + } + } +); diff --git a/sigal/plugins/encrypt/static/keycheck.txt b/sigal/plugins/encrypt/static/keycheck.txt new file mode 100644 index 00000000..227ac909 --- /dev/null +++ b/sigal/plugins/encrypt/static/keycheck.txt @@ -0,0 +1 @@ +This file will be decrypted to test if the password supplied by the user is correct. diff --git a/sigal/plugins/encrypt/static/sw.js b/sigal/plugins/encrypt/static/sw.js new file mode 100644 index 00000000..cf389d3b --- /dev/null +++ b/sigal/plugins/encrypt/static/sw.js @@ -0,0 +1,7 @@ +"use strict" +importScripts("static/decrypt.js"); +oninstall = Decryptor.onServiceWorkerInstall; +onactivate = Decryptor.onServiceWorkerActivate; +onfetch = Decryptor.onServiceWorkerFetch; +onmessage = Decryptor.onServiceWorkerMesssage; +Decryptor.init({}); \ No newline at end of file diff --git a/sigal/themes/default/templates/decrypt.html b/sigal/themes/default/templates/decrypt.html index 39146abe..0d7faf5a 100644 --- a/sigal/themes/default/templates/decrypt.html +++ b/sigal/themes/default/templates/decrypt.html @@ -3,7 +3,7 @@