diff --git a/AUTHORS b/AUTHORS index 3ee43af..2c544ac 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 diff --git a/docs/plugins.rst b/docs/plugins.rst index 4da49dc..3ddc5fd 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -101,6 +101,11 @@ Copyright plugin .. automodule:: sigal.plugins.copyright +Encrypt plugin +============== + +.. automodule:: sigal.plugins.encrypt + Extended caching plugin ======================= @@ -135,3 +140,4 @@ ZIP Gallery plugin ================== .. automodule:: sigal.plugins.zip_gallery + diff --git a/setup.cfg b/setup.cfg index dcefa38..3b5b507 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,7 @@ install_requires = natsort [options.extras_require] -all = boto; brotli; feedgenerator; zopfli +all = boto; brotli; feedgenerator; zopfli; cryptography 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..60866cc --- /dev/null +++ b/sigal/plugins/encrypt/encrypt.py @@ -0,0 +1,271 @@ +# 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 + } + +- ``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. + +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. + +.. _compatibility-with-encrypt: + +Compatibility with other plugins: + +- ``zip_gallery``: if you enable both this plugin and the ``zip_gallery`` plugin, + the generated zip archives will contain encrypted images, which is generally + meaningless since viewers cannot easily decrypt them outside a browser. +''' + +import os +import random +import string +import logging +import 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 .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')) + +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(settings, cache): + if "encrypt_options" not in settings: + logging.error("Encrypt: no encrypt_options in settings") + raise ValueError("no encrypt_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"] \ + or len(settings["encrypt_options"]["password"]) == 0: + logger.error("Encrypt: no password provided") + raise ValueError("no password provided") + 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) + + 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 + + # 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"], + "galleryId": options["galleryId"] + } + + 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 or in case of "use_orig", the original + if settings["make_thumbs"]: + to_encrypt.append(get_thumb(settings, media.filename)) #thumbnail + if media.big is not None and not settings["use_orig"]: + 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): + gallery = album.gallery + cache = load_cache(gallery.settings) + + 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(settings): + cachePath = os.path.join(settings["destination"], ".encryptCache") + try: + with open(cachePath, "rb") as cacheFile: + encryptCache = pickle.load(cacheFile) + logger.debug("Loaded encryption cache with %d entries", len(encryptCache)) + return encryptCache + except FileNotFoundError: + encryptCache = {} + return encryptCache + except Exception as e: + logger.error("Could not load encryption cache: %s", e) + logger.error("Giving up encryption. You may have to delete and rebuild the entire gallery.") + raise Abort + +def save_cache(settings, cache): + 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): + albums = gallery.albums + settings = gallery.settings + + cache = load_cache(settings) + config = get_options(settings, cache) + logger.debug("encryption config: %s", config) + + logger.info("starting encryption") + copy_assets(settings) + encrypt_files(settings, config, cache, albums, gallery.progressbar_target) + save_cache(settings, cache) + +def encrypt_files(settings, config, cache, albums, progressbar_target): + if settings["keep_orig"] and settings["orig_link"]: + logger.warning("Original images are symlinked! Encryption is aborted. Please set \"orig_link\" to False and restart gallery build.") + raise Abort + + key = kdf_gen_key(config["password"], config["kdf_salt"], 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: + if media.type != "image": + logger.info("Skipping non-image file %s", media.filename) + continue + + save_property(cache, media) + to_encrypt = get_encrypt_list(settings, media) + + 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) + continue + + full_path = os.path.join(settings["destination"], f) + if encrypt_file(f, full_path, key, gcm_tag): + cacheEntry.add(f) + else: + # save the progress and abort the build if any image + # fails to be encrypted + save_cache(settings, cache) + raise Abort + + key_check_path = 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(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): + cache = load_cache(context['settings']) + context["encrypt_options"] = get_options(context['settings'], cache) + +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/plugins/encrypt/endec.py b/sigal/plugins/encrypt/endec.py new file mode 100644 index 0000000..8bc4f64 --- /dev/null +++ b/sigal/plugins/encrypt/endec.py @@ -0,0 +1,117 @@ +#!/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. + +import io +import os +from pathlib import Path +from base64 import b64decode +from typing import BinaryIO + +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 + +backend = default_backend() +MAGIC_STRING = "_e_n_c_r_y_p_t_e_d_".encode("utf-8") + +def kdf_gen_key(password: str, salt: str, iters: int) -> bytes: + password = password.encode("utf-8") + salt = salt.encode("utf-8") + 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, args.kdf_salt, 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(MAGIC_STRING) + 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 + magicstring = ciphertext.read(len(MAGIC_STRING)) + if magicstring != MAGIC_STRING: + raise ValueError("Data is not encrypted") + 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.js b/sigal/plugins/encrypt/static/decrypt.js new file mode 100644 index 0000000..c3acfff --- /dev/null +++ b/sigal/plugins/encrypt/static/decrypt.js @@ -0,0 +1,601 @@ +/* + * copyright (c) 2020 Bowen Ding + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to + * deal in the Software without restriction, including without limitation the + * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or + * sell copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS + * IN THE SOFTWARE. +*/ + +"use strict" +class Decryptor { + constructor(config) { + this._jobCount = 0; + this._jobMap = new Map(); + this._workerReady = false; + + if (Decryptor.isServiceWorker()) { + this._role = "service_worker"; + } else if (!Decryptor.isWorker()) { + if (!Decryptor.featureTest()) { + alert("This page cannot function properly because your browser does not support some critical features or you are in private browsing mode. Please update your browser or exit private browsing mode."); + return; + } + this._role = "main"; + this._config = config; + const local_config = this._mGetLocalConfig(); + if (local_config) { + this._config = local_config; + } + window.addEventListener( + "load", + (e) => this._mSetupServiceWorker(), + { once: true, passive: true } + ); + } + + console.info("Decryptor initialized"); + } + + static init(config) { + if (Decryptor.isServiceWorker()) { + self.decryptor = new Decryptor(config); + } else { + window.decryptor = new Decryptor(config); + } + } + + static featureTest() { + let features = [ + typeof crypto, + typeof TextEncoder, + typeof navigator.serviceWorker, + typeof Proxy, + typeof fetch, + typeof Blob.prototype.arrayBuffer, + typeof Response.prototype.clone, + typeof caches + ]; + return features.every((e) => e !== "undefined"); + } + + async _swInitServiceWorker(config) { + const crypto = Decryptor._getCrypto(); + const encoder = new TextEncoder("utf-8"); + const salt = encoder.encode(config.kdf_salt); + const iters = config.kdf_iters; + const shared_key = encoder.encode(config.password); + const gcm_tag = encoder.encode(config.gcm_tag); + + const aes_key = await Decryptor._initAesKey(crypto, salt, iters, shared_key); + if (await this._swCheckAesKey(aes_key, gcm_tag)) { + this.workerReady = true; + this._decrypt = (encrypted_blob_arraybuffer) => + Decryptor.decrypt(crypto, encrypted_blob_arraybuffer, aes_key, gcm_tag); + this._swNotifyWorkerReady(); + } else { + this.workerReady = false; + this._swNotifyIncorrectPassword() + } + } + + async _swCheckAesKey(aes_key, gcm_tag) { + let response; + try { + response = await fetch(Decryptor.keyCheckURL); + } catch (error) { + throw new Error("Fetched failed when checking encryption key"); + } + try { + await Decryptor.decrypt( + Decryptor._getCrypto(), + await response.blob(), + aes_key, + gcm_tag, + true + ); + } catch (error) { + console.warn("Password is incorrect!"); + return false; + } + return true; + } + + async _swNotifyWorkerReady() { + const array_clients = await self.clients.matchAll({includeUncontrolled: true}); + for (let client of array_clients) { + this._proxyWrap(client)._mSetWorkerReady(); + } + } + + async _swNotifyIncorrectPassword() { + const array_clients = await self.clients.matchAll({includeUncontrolled: true}); + for (let client of array_clients) { + this._proxyWrap(client)._mUnsetWorkerReady(); + } + } + + static isInitialized() { + if (Decryptor.isServiceWorker()) { + return 'decryptor' in self && self.decryptor.workerReady; + } else { + return 'decryptor' in window && window.decryptor.workerReady; + } + } + + get workerReady() { + return this._workerReady; + } + + set workerReady(val) { + this._workerReady = (val ? true : false); + if (this._workerReady) { + const eventTarget = (Decryptor.isWorker() ? self : document); + Decryptor._sendEvent(eventTarget, "DecryptWorkerReady"); + } + } + + _mSetWorkerReady() { + this.workerReady = true; + const had_been_ready_before = localStorage.getItem(this._config.galleryId) !== null; + localStorage.setItem(this._config.galleryId, JSON.stringify(this._config)); + if (!had_been_ready_before) { + window.location.reload(); + } + } + + _mUnsetWorkerReady() { + this.workerReady = false; + localStorage.removeItem(this._config.galleryId); + } + + _mGetLocalConfig() { + const local_config = JSON.parse(localStorage.getItem(this._config.galleryId)); + if (local_config + && local_config.galleryId + && local_config.sw_script + && local_config.password + && local_config.gcm_tag + && local_config.kdf_salt + && local_config.kdf_iters) { + return local_config; + } else { + return null; + } + } + + async _mSetupServiceWorker() { + if (!('serviceWorker' in navigator)) { + console.error("Fatal: Your browser does not support service worker"); + throw new Error("no service worker support"); + } + + if (navigator.serviceWorker.controller) { + this.serviceWorker = navigator.serviceWorker.controller; + } else { + navigator.serviceWorker.register(this._config.sw_script); + const registration = await navigator.serviceWorker.ready; + this.serviceWorker = registration.active; + } + + navigator.serviceWorker.onmessage = + (e) => Decryptor.onMessage(this.serviceWorker, e); + this.serviceWorker = this._proxyWrap(this.serviceWorker); + + if (!(await this.serviceWorker.Decryptor.isInitialized())) { + if (!('password' in this._config && this._config.password)) { + this._config.password = await this._mAskPassword(); + } + this.serviceWorker._swInitServiceWorker(this._config); + } + } + + static isServiceWorker() { + return ('undefined' !== typeof ServiceWorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator); + } + + static isWorker() { + return ('undefined' !== typeof WorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator); + } + + static _getCrypto() { + if('undefined' !== typeof crypto && crypto.subtle) { + return crypto.subtle; + } else { + throw new Error("Fatal: Browser does not support Web Crypto"); + } + } + + /* main thread only */ + async _mAskPassword() { + const config = JSON.parse(localStorage.getItem(this._config.galleryId)); + if (config && config.password) { + return config.password; + } + const password = prompt("Input password to view this gallery:"); + if (password) { + this._config.password = password; + return password; + } else { + return "__wrong_password__"; + } + } + + static async _initAesKey(crypto, kdf_salt, kdf_iters, shared_key) { + const pbkdf2key = await crypto.importKey( + "raw", + shared_key, + "PBKDF2", + false, + ["deriveKey"] + ); + const pbkdf2params = { + name: "PBKDF2", + hash: "SHA-1", + salt: kdf_salt, + iterations: kdf_iters + }; + return await crypto.deriveKey( + pbkdf2params, + pbkdf2key, + { name: "AES-GCM", length: 128 }, + false, + ["decrypt"] + ); + } + + static _sendEvent(target, type, detail = null) { + const eventInit = { + detail: detail, + bubbles: true, + cancelable: true + }; + return target.dispatchEvent(new CustomEvent(type, eventInit)); + } + + static async checkMagicString(arraybuffer) { + const sample = new DataView( + arraybuffer, + 0, + Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength + ); + for (let i = 0; i < Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength; i++) { + if (Decryptor.MAGIC_STRING_ARRAYBUFFER[i] !== sample.getUint8(i)) { + return false; + } + } + return true; + } + + static async decrypt(crypto, blob_or_arraybuffer, aes_key, gcm_tag, check_magic_string=false) { + let arraybuffer, return_blob; + if (blob_or_arraybuffer instanceof Blob) { + arraybuffer = await blob_or_arraybuffer.arrayBuffer(); + return_blob = true; + } else if (blob_or_arraybuffer instanceof ArrayBuffer) { + arraybuffer = blob_or_arraybuffer + return_blob = false; + } else { + throw new TypeError("decrypt accepts either a Blob or an ArrayBuffer"); + } + + // make sure there is enough data to decrypt + // although 1 byte of data seems not acceptable for some browsers + // in which case crypto.decrypt will throw an error + // "The provided data is too small" + if (arraybuffer.byteLength < + Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength + + Decryptor.IV_LENGTH + + 1) { + throw new Error("not enough data to decrypt"); + } + + if (check_magic_string && !(await Decryptor.checkMagicString(arraybuffer))) { + // data is not encrypted + return blob_or_arraybuffer; + } + + const iv = new DataView( + arraybuffer, + Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength, + Decryptor.IV_LENGTH + ); + const ciphertext = new DataView( + arraybuffer, + Decryptor.MAGIC_STRING_ARRAYBUFFER.byteLength + Decryptor.IV_LENGTH + ); + const decrypted = await crypto.decrypt( + { + name: "AES-GCM", + iv: iv, + additionalData: gcm_tag + }, + aes_key, + ciphertext + ); + if (return_blob) { + return new Blob([decrypted], {type: blob_or_arraybuffer.type}); + } else { + return decrypted; + } + } + + _proxyWrap(target) { + const decryptor = this; + const handler = { + get: (wrappedObj, prop) => { + if (prop in wrappedObj) { + if (wrappedObj[prop] instanceof Function) { + return (...args) => wrappedObj[prop].apply(wrappedObj, args); + } else { + return wrappedObj[prop]; + } + } + if (prop === "Decryptor") { + return new Proxy(target, { + get: (wrappedObj, prop) => { + return decryptor._rpcCall(wrappedObj, prop, true); + } + }); + } + return decryptor._rpcCall(wrappedObj, prop, false); + } + } + return new Proxy(target, handler); + } + + _rpcCall(target, method, static_) { + const decryptor = this; + const dummyFunction = () => {}; + const handler = { + apply: (wrappedFunc, thisArg, args) => { + return new Promise((success, error) => { + const jobId = decryptor._jobCount++; + decryptor._jobMap.set(jobId, {success: success, error: error}); + Decryptor._rpcPostJob(jobId, target, method, args, static_); + }); + } + }; + return new Proxy(dummyFunction, handler); + } + + static _rpcPostJob(jobId, messagePort, method, args, static_=false) { + const job = { + type: "job", + id: jobId, + method: method, + args: args, + static: static_ + }; + messagePort.postMessage(job); + } + + static _asyncReturn(instance, method, args) { + if (!(instance instanceof Object)) { + return Promise.reject(new Error("calling method on a primitive")); + } + if (!(method in instance && instance[method] instanceof Function)) { + return Promise.reject(new Error(`no such method: ${method}`)) + } + + try { + let promise_or_value = instance[method].apply(instance, args); + if (promise_or_value instanceof Promise) { + return promise_or_value; + } else { + return Promise.resolve(promise_or_value); + } + } catch (e) { + return Promise.reject(e); + } + } + + static onMessage(replyPort, e) { + const type = e.data.type; + const id = e.data.id; + + if (type === "job") { + const method = e.data.method; + const args = e.data.args; + const instance = e.data.static ? Decryptor : + (Decryptor.isWorker() ? self : window).decryptor; + + Decryptor._asyncReturn(instance, method, args) + .then( + (result) => { return {type: "reply", success: true, result: result}; }, + (error) => { return {type: "reply", success: false, result: error.message}; } + ) + .then((reply) => { + reply.id = id; + replyPort.postMessage(reply); + }); + } else if (type === "reply") { + // if we are receiving replies, we must have been initialized + // so no need to check if "decryptor" exists here + const success = e.data.success; + const result = e.data.result; + const callbacks = decryptor._jobMap.get(id); + + if (success) { + if (callbacks.success) callbacks.success(result); + } else { + if (callbacks.error) callbacks.error(new Error(result)); + } + decryptor._jobMap.delete(id); + } + } + + static onServiceWorkerInstall(e) { + console.log("service worker on install: ", e); + e.waitUntil(self.skipWaiting()); + } + + static onServiceWorkerActivate(e) { + console.log("service worker on activate: ", e); + e.waitUntil(self.clients.claim()); + } + + static onServiceWorkerMesssage(e) { + return Decryptor.onMessage(e.source, e); + } + + static async _swHandleFetch(e) { + const request = e.request; + try { + const cached_response = await caches.match(request); + if (cached_response) { + // TODO: handle cache expiration + console.debug(`Found cached response for ${request.url}`); + return cached_response; + } + } catch (error) { + console.error("Caches.match error!"); + } + + let response; + try { + response = await fetch(request); + } catch (error) { + console.debug(`Fetch failed when trying for ${request.url}: ${error}`); + throw error; + } + + if (!response.ok) { + console.debug(`Fetch succeeded but server returned non-2xx: ${request.url}`); + return response; + } + + const is_image = [ + request.destination === "image", + (() => { + const content_type = response.headers.get("content-type"); + return content_type && content_type.startsWith("image"); + })() + ]; + + if (!is_image.some((e) => e)) { + console.debug(`Fetch succeeded but response is likely not an image ${request.url}`); + return response; + } + + const response_clone = response.clone(); + const encrypted_blob = await response.blob(); + const encrypted_arraybuffer = await encrypted_blob.arrayBuffer(); + if (!(await Decryptor.checkMagicString(encrypted_arraybuffer))) { + console.debug(`Response image is not encrypted: ${request.url}`); + return response_clone; + } + console.debug(`Fetch succeeded with encrypted image ${request.url}, trying to decrypt`); + + if (!Decryptor.isInitialized()) { + if ('decryptor' in self) { + try{ + const clients = await self.clients.matchAll({type: "window"}); + const races = Promise.race( + clients.map((client) => { + return self.decryptor._proxyWrap(client)._mGetLocalConfig(); + }) + ); + const config = await Promise.timeout(races, 100); + await self.decryptor._swInitServiceWorker(config); + } catch (error) { + // do nothing + } + } + if (!Decryptor.isInitialized()) { + console.debug(`Service worker not initialized on fetch event`); + return Decryptor.errorResponse.clone(); + } + } + + let decrypted_blob; + try { + decrypted_blob = new Blob( + [await self.decryptor._decrypt(encrypted_arraybuffer)], + {type: encrypted_blob.type} + ); + } catch (error) { + console.debug(`Decryption failed for ${request.url}: ${error.message}`); + console.error("Corrupted data??? This shouldn't occur."); + return Decryptor.errorResponse.clone(); + } + + const decrypted_response = new Response( + decrypted_blob, + { + status: response.status, + statusText: response.statusText, + headers: response.headers + } + ); + decrypted_response.headers.set("content-length", decrypted_blob.size); + + const decrypted_response_clone = decrypted_response.clone(); + const cache = await caches.open("v1"); + cache.put(request, decrypted_response_clone); + + console.debug(`Responding with decrypted response ${request.url}`); + return decrypted_response; + } + + static onServiceWorkerFetch(e) { + e.respondWith(Decryptor._swHandleFetch(e)); + } +} + +Decryptor.MAGIC_STRING = "_e_n_c_r_y_p_t_e_d_"; +Decryptor.MAGIC_STRING_ARRAYBUFFER = (new TextEncoder("utf-8")).encode(Decryptor.MAGIC_STRING); +Decryptor.IV_LENGTH = 12; +Decryptor.keyCheckURL = "static/keycheck.txt"; +Decryptor.imagePlaceholderBlob = new Blob([ +` + + + background + + + + + + + Layer 1 + Could not + load + image + +`], {type: "image/svg+xml"}); + +Decryptor.errorResponse = new Response( + Decryptor.imagePlaceholderBlob, + { + status: 200, + statusText: "OK", + headers: { + "content-type": "image/svg+xml" + } + } +); + +Promise.timeout = function(cb_or_pm, timeout) { + return Promise.race([ + cb_or_pm instanceof Function ? new Promise(cb) : cb_or_pm, + new Promise((resolve, reject) => { + setTimeout(() => { + reject('Timed out'); + }, timeout); + }) + ]); +} diff --git a/sigal/plugins/encrypt/static/keycheck.txt b/sigal/plugins/encrypt/static/keycheck.txt new file mode 100644 index 0000000..fddd417 --- /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 0000000..2fd3f42 --- /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({}); diff --git a/sigal/plugins/zip_gallery.py b/sigal/plugins/zip_gallery.py index 9d2f1f2..2695db6 100644 --- a/sigal/plugins/zip_gallery.py +++ b/sigal/plugins/zip_gallery.py @@ -27,6 +27,8 @@ To ignore a ZIP gallery generation for a particular album, put a ``.nozip_gallery`` file next to it in its parent folder. Only the existence of this ``.nozip_gallery`` file is tested. If no ``.nozip_gallery`` file is present, then make a ZIP archive with all media files. + +See :ref:`compatibility with the encrypt plugin `. """ import logging diff --git a/sigal/signals.py b/sigal/signals.py index a4a8cf6..9f1f0e4 100644 --- a/sigal/signals.py +++ b/sigal/signals.py @@ -8,3 +8,4 @@ gallery_build = signal('gallery_build') media_initialized = signal('media_initialized') albums_sorted = signal('albums_sorted') medias_sorted = signal('medias_sorted') +before_render = signal('before_render') diff --git a/sigal/templates/sigal.conf.py b/sigal/templates/sigal.conf.py index 480fbad..dd4ea4f 100644 --- a/sigal/templates/sigal.conf.py +++ b/sigal/templates/sigal.conf.py @@ -243,7 +243,8 @@ ignore_files = [] # 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 @@ ignore_files = [] # compress_assets_options = { # 'method': 'gzip' # Or 'zopfli' or 'brotli' # } + +# Settings for encryption plugin +# encrypt_options = { +# 'password': 'password', +# 'ask_password': False +# } diff --git a/sigal/themes/colorbox/templates/base.html b/sigal/themes/colorbox/templates/base.html index 27a8d59..1836260 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 0000000..0d7faf5 --- /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 05a970b..6e96933 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 ce556f4..70b4f88 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 e91c3b6..7e3a5c5 100644 --- a/sigal/writer.py +++ b/sigal/writer.py @@ -31,6 +31,7 @@ import jinja2 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,9 @@ class AbstractWriter: def write(self, album): """Generate the HTML page and save it.""" - - page = self.template.render(**self.generate_context(album)) + context = self.generate_context(album) + signals.before_render.send(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: diff --git a/tests/sample/pictures/encryptTest/21.jpg b/tests/sample/pictures/encryptTest/21.jpg new file mode 100644 index 0000000..0b60190 Binary files /dev/null and b/tests/sample/pictures/encryptTest/21.jpg differ diff --git a/tests/sample/pictures/encryptTest/22.jpg b/tests/sample/pictures/encryptTest/22.jpg new file mode 100644 index 0000000..e2bd3d3 Binary files /dev/null and b/tests/sample/pictures/encryptTest/22.jpg differ diff --git a/tests/test_encrypt.py b/tests/test_encrypt.py new file mode 100644 index 0000000..8a53e7b --- /dev/null +++ b/tests/test_encrypt.py @@ -0,0 +1,84 @@ +import os +import pickle +from io import BytesIO + +from sigal import init_plugins +from sigal.gallery import Gallery +from sigal.plugins.encrypt import endec +from sigal.plugins.encrypt.encrypt import cache_key + +CURRENT_DIR = os.path.dirname(__file__) + + +def get_key_tag(settings): + options = settings["encrypt_options"] + key = endec.kdf_gen_key( + options["password"], + options["kdf_salt"], + options["kdf_iters"] + ) + tag = options["gcm_tag"].encode("utf-8") + return (key, tag) + +def test_encrypt(settings, tmpdir, disconnect_signals): + settings['destination'] = str(tmpdir) + if "sigal.plugins.encrypt" not in settings["plugins"]: + settings['plugins'] += ["sigal.plugins.encrypt"] + + settings['encrypt_options'] = { + 'password': 'password', + 'ask_password': True, + 'gcm_tag': 'AuTheNTiCatIoNtAG', + 'kdf_salt': 'saltysaltsweetysweet', + 'kdf_iters': 10000, + 'encrypt_symlinked_originals': False + } + + init_plugins(settings) + gal = Gallery(settings) + gal.build() + + # check the encrypt cache exists + cachePath = os.path.join(settings["destination"], ".encryptCache") + assert os.path.isfile(cachePath) + + encryptCache = None + with open(cachePath, "rb") as cacheFile: + encryptCache = pickle.load(cacheFile) + assert isinstance(encryptCache, dict) + + testAlbum = gal.albums["encryptTest"] + key, tag = get_key_tag(settings) + + for media in testAlbum: + # check if sizes are stored in cache + assert cache_key(media) in encryptCache + assert "size" in encryptCache[cache_key(media)] + assert "thumb_size" in encryptCache[cache_key(media)] + assert "encrypted" in encryptCache[cache_key(media)] + + encryptedImages = [ + media.dst_path, + media.thumb_path + ] + if settings["keep_orig"]: + encryptedImages.append(os.path.join(settings["destination"], + media.path, media.big)) + + # check if images are encrypted by trying to decrypt + for image in encryptedImages: + with open(image, "rb") as infile: + with BytesIO() as outfile: + endec.decrypt(key, infile, outfile, tag) + + # check static files have been copied + static = os.path.join(settings["destination"], 'static') + assert os.path.isfile(os.path.join(static, "decrypt.js")) + assert os.path.isfile(os.path.join(static, "keycheck.txt")) + assert os.path.isfile(os.path.join(settings["destination"], "sw.js")) + + # check keycheck file + with open(os.path.join(settings["destination"], + 'static', "keycheck.txt"), "rb") as infile: + with BytesIO() as outfile: + endec.decrypt(key, infile, outfile, tag) diff --git a/tox.ini b/tox.ini index b9eef2d..57777b3 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,7 @@ commands = usedevelop = true deps = feedgenerator + cryptography commands = sigal build -c tests/sample/sigal.conf.py sigal serve tests/sample/_build