1
AUTHORS
1
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
1
sigal/plugins/encrypt/__init__.py
Normal file
1
sigal/plugins/encrypt/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .encrypt import register
|
||||
271
sigal/plugins/encrypt/encrypt.py
Normal file
271
sigal/plugins/encrypt/encrypt.py
Normal file
@@ -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)
|
||||
|
||||
117
sigal/plugins/encrypt/endec.py
Normal file
117
sigal/plugins/encrypt/endec.py
Normal file
@@ -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)
|
||||
|
||||
601
sigal/plugins/encrypt/static/decrypt.js
Normal file
601
sigal/plugins/encrypt/static/decrypt.js
Normal file
@@ -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([
|
||||
`<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Created with Method Draw - http://github.com/duopixel/Method-Draw/ -->
|
||||
<g>
|
||||
<title>background</title>
|
||||
<rect fill="#ffffff" id="canvas_background" height="202" width="202" y="-1" x="-1"/>
|
||||
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
|
||||
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
<title>Layer 1</title>
|
||||
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_1" y="61.949997" x="22.958336" stroke-width="0" stroke="#000" fill="#7f7f7f">Could not</text>
|
||||
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_4" y="112.600002" x="65.974998" stroke-width="0" stroke="#000" fill="#7f7f7f">load</text>
|
||||
<text xml:space="preserve" text-anchor="start" font-family="Helvetica, Arial, sans-serif" font-size="36" id="svg_5" y="162.949997" x="50.983334" stroke-width="0" stroke="#000" fill="#7f7f7f">image</text>
|
||||
</g>
|
||||
</svg>`], {type: "image/svg+xml"});
|
||||
|
||||
Decryptor.errorResponse = new Response(
|
||||
Decryptor.imagePlaceholderBlob,
|
||||
{
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
"content-type": "image/svg+xml"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
Promise.timeout = function(cb_or_pm, timeout) {
|
||||
return Promise.race([
|
||||
cb_or_pm instanceof Function ? new Promise(cb) : cb_or_pm,
|
||||
new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
reject('Timed out');
|
||||
}, timeout);
|
||||
})
|
||||
]);
|
||||
}
|
||||
1
sigal/plugins/encrypt/static/keycheck.txt
Normal file
1
sigal/plugins/encrypt/static/keycheck.txt
Normal file
@@ -0,0 +1 @@
|
||||
This file will be decrypted to test if the password supplied by the user is correct.
|
||||
7
sigal/plugins/encrypt/static/sw.js
Normal file
7
sigal/plugins/encrypt/static/sw.js
Normal file
@@ -0,0 +1,7 @@
|
||||
"use strict"
|
||||
importScripts("static/decrypt.js");
|
||||
oninstall = Decryptor.onServiceWorkerInstall;
|
||||
onactivate = Decryptor.onServiceWorkerActivate;
|
||||
onfetch = Decryptor.onServiceWorkerFetch;
|
||||
onmessage = Decryptor.onServiceWorkerMesssage;
|
||||
Decryptor.init({});
|
||||
@@ -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 <compatibility-with-encrypt>`.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
# }
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<link rel="stylesheet" href="{{ theme.url }}/css/style.css">
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
{% include 'analytics.html' %}
|
||||
{% include 'decrypt.html' %}
|
||||
</head>
|
||||
<body>
|
||||
{% include 'gtm.html' %}
|
||||
|
||||
13
sigal/themes/default/templates/decrypt.html
Normal file
13
sigal/themes/default/templates/decrypt.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% if 'sigal.plugins.encrypt' is in settings.plugins %}
|
||||
<script src="{{ theme.url }}/decrypt.js"></script>
|
||||
<script>
|
||||
Decryptor.init({
|
||||
password: "{{ encrypt_options.filtered_password }}",
|
||||
sw_script: "{{ theme.url }}/../sw.js",
|
||||
galleryId: "{{ encrypt_options.galleryId }}",
|
||||
gcm_tag: "{{ encrypt_options.escaped_gcm_tag }}",
|
||||
kdf_salt: "{{ encrypt_options.escaped_kdf_salt }}",
|
||||
kdf_iters: {{ encrypt_options.kdf_iters }}
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -14,6 +14,7 @@
|
||||
<link rel="stylesheet" href="{{ theme.url }}/css/style.css">
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
{% include 'analytics.html' %}
|
||||
{% include 'decrypt.html' %}
|
||||
</head>
|
||||
<body>
|
||||
{% include 'gtm.html' %}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{% block extra_head %}{% endblock extra_head %}
|
||||
<link rel="stylesheet" href="{{ theme.url }}/styles.css">
|
||||
{% include 'analytics.html' %}
|
||||
{% include 'decrypt.html' %}
|
||||
</head>
|
||||
<body>
|
||||
{% include 'gtm.html' %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
BIN
tests/sample/pictures/encryptTest/21.jpg
Normal file
BIN
tests/sample/pictures/encryptTest/21.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
BIN
tests/sample/pictures/encryptTest/22.jpg
Normal file
BIN
tests/sample/pictures/encryptTest/22.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
84
tests/test_encrypt.py
Normal file
84
tests/test_encrypt.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user