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([
+``], {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