From 87f29707b8dd79f28a3bda47b1588c5d5ec26465 Mon Sep 17 00:00:00 2001 From: Bowen Ding Date: Tue, 31 Mar 2020 00:49:40 +0800 Subject: [PATCH] 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 4da49dc..ad8771b 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 67bab00..c18838f 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 0000000..6fe3cc8 --- /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 0000000..5ee888b --- /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 0000000..37ca8d8 --- /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 0000000..f86c329 --- /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 0000000..7a0c3b8 --- /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 4db648a..b07d771 100644 --- a/tests/sample/sigal.conf.py +++ b/tests/sample/sigal.conf.py @@ -17,7 +17,16 @@ plugins = [ '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 b9eef2d..96ef8eb 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