Add plugin encrypt
This commit is contained in:
@@ -135,3 +135,8 @@ ZIP Gallery plugin
|
||||
==================
|
||||
|
||||
.. automodule:: sigal.plugins.zip_gallery
|
||||
|
||||
Encrypt plugin
|
||||
==============
|
||||
|
||||
.. automodule:: sigal.plugins.encrypt
|
||||
|
||||
@@ -37,7 +37,7 @@ install_requires =
|
||||
pilkit
|
||||
|
||||
[options.extras_require]
|
||||
all = boto; brotli; feedgenerator; zopfli
|
||||
all = boto; brotli; feedgenerator; zopfli; cryptography; beautifulsoup4
|
||||
tests = pytest; pytest-cov
|
||||
docs = Sphinx; alabaster
|
||||
|
||||
|
||||
1
sigal/plugins/encrypt/__init__.py
Normal file
1
sigal/plugins/encrypt/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .encrypt import register
|
||||
297
sigal/plugins/encrypt/encrypt.py
Normal file
297
sigal/plugins/encrypt/encrypt.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# copyright (c) 2020 Bowen Ding
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
'''Plugin to protect gallery by encrypting image files using a password.
|
||||
|
||||
Options:
|
||||
encrypt_options = {
|
||||
'password': 'password',
|
||||
'ask_password': False,
|
||||
'gcm_tag': 'randomly_generated',
|
||||
'kdf_salt': 'randomly_generated',
|
||||
'kdf_iters': 10000,
|
||||
'encrypt_symlinked_originals': False
|
||||
}
|
||||
|
||||
- ``password``: The password used to encrypt the images on gallery build,
|
||||
and decrypt them when viewers access the gallery. No default value. You must
|
||||
specify a password.
|
||||
- ``ask_password``: Whether or not viewers are asked for the password to view
|
||||
the gallery. If set to ``False``, the password will be present in the HTML files
|
||||
so the images are decrypted automatically. Defaults to ``False``.
|
||||
- ``gcm_tag``, ``kdf_salt``, ``kdf_iters``: Cryptographic parameters used when
|
||||
encrypting the files. ``gcm_tag``, ``kdf_salt`` are meant to be randomly generated,
|
||||
``kdf_iters`` defaults to 10000. Do not specify them in the config file unless
|
||||
you have good reasons to do so.
|
||||
- ``encrypt_symlinked_originals``: Force encrypting original images even if they
|
||||
are symlinked. If you don't know what it means, leave it to ``False``.
|
||||
|
||||
Note: The plugin caches the cryptographic parameters (but not the password) after
|
||||
the first build, so that incremental builds can share the same credentials.
|
||||
DO NOT CHANGE THE PASSWORD OR OTHER CRYPTOGRAPHIC PARAMETERS ONCE A GALLERY IS
|
||||
BUILT, or there will be inconsistency in encrypted files and viewers will not be able
|
||||
to see some of the images any more.
|
||||
'''
|
||||
|
||||
import os, random, string, logging, pickle
|
||||
from io import BytesIO
|
||||
from itertools import chain
|
||||
|
||||
from sigal import signals
|
||||
from sigal.utils import url_from_path, copy
|
||||
from sigal.settings import get_thumb
|
||||
from click import progressbar
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from .endec import encrypt, kdf_gen_key
|
||||
|
||||
ASSETS_PATH = os.path.normpath(os.path.join(
|
||||
os.path.abspath(os.path.dirname(__file__)), 'static'))
|
||||
|
||||
class Abort(Exception):
|
||||
pass
|
||||
|
||||
def gen_rand_string(length=16):
|
||||
return "".join(random.SystemRandom().choices(string.ascii_letters + string.digits, k=length))
|
||||
|
||||
def get_options(gallery):
|
||||
settings = gallery.settings
|
||||
cache = gallery.encryptCache
|
||||
if "encrypt_options" not in settings:
|
||||
raise ValueError("Encrypt: no options in settings")
|
||||
|
||||
#try load credential from cache
|
||||
try:
|
||||
options = cache["credentials"]
|
||||
except KeyError:
|
||||
options = settings["encrypt_options"]
|
||||
|
||||
table = str.maketrans({'"': r'\"', '\\': r'\\'})
|
||||
if "password" not in settings["encrypt_options"]:
|
||||
raise ValueError("Encrypt: no password provided")
|
||||
else:
|
||||
options["password"] = settings["encrypt_options"]["password"]
|
||||
options["escaped_password"] = options["password"].translate(table)
|
||||
|
||||
if "gcm_tag" not in options:
|
||||
options["gcm_tag"] = gen_rand_string()
|
||||
options["escaped_gcm_tag"] = options["gcm_tag"].translate(table)
|
||||
|
||||
if "kdf_salt" not in options:
|
||||
options["kdf_salt"] = gen_rand_string()
|
||||
options["escaped_kdf_salt"] = options["kdf_salt"].translate(table)
|
||||
|
||||
if "galleryId" not in options:
|
||||
options["galleryId"] = gen_rand_string(6)
|
||||
|
||||
if "kdf_iters" not in options:
|
||||
options["kdf_iters"] = 10000
|
||||
|
||||
if "ask_password" not in options:
|
||||
options["ask_password"] = settings["encrypt_options"].get("ask_password", False)
|
||||
|
||||
gallery.encryptCache["credentials"] = {
|
||||
"gcm_tag": options["gcm_tag"],
|
||||
"kdf_salt": options["kdf_salt"],
|
||||
"kdf_iters": options["kdf_iters"],
|
||||
"galleryId": options["galleryId"]
|
||||
}
|
||||
|
||||
if "encrypt_symlinked_originals" not in options:
|
||||
options["encrypt_symlinked_originals"] = settings["encrypt_options"].get("encrypt_symlinked_originals", False)
|
||||
|
||||
return options
|
||||
|
||||
def cache_key(media):
|
||||
return os.path.join(media.path, media.filename)
|
||||
|
||||
def save_property(cache, media):
|
||||
key = cache_key(media)
|
||||
if key not in cache:
|
||||
cache[key] = {}
|
||||
cache[key]["size"] = media.size
|
||||
cache[key]["thumb_size"] = media.thumb_size
|
||||
cache[key]["encrypted"] = set()
|
||||
|
||||
def get_encrypt_list(settings, media):
|
||||
to_encrypt = []
|
||||
to_encrypt.append(media.filename) #resized image
|
||||
if settings["make_thumbs"]:
|
||||
to_encrypt.append(get_thumb(settings, media.filename)) #thumbnail
|
||||
if media.big is not None:
|
||||
to_encrypt.append(media.big) #original image
|
||||
to_encrypt = list(map(lambda path: os.path.join(media.path, path), to_encrypt))
|
||||
return to_encrypt
|
||||
|
||||
def load_property(album):
|
||||
if not hasattr(album.gallery, "encryptCache"):
|
||||
load_cache(album.gallery)
|
||||
cache = album.gallery.encryptCache
|
||||
|
||||
for media in album.medias:
|
||||
if media.type == "image":
|
||||
key = cache_key(media)
|
||||
if key in cache:
|
||||
media.size = cache[key]["size"]
|
||||
media.thumb_size = cache[key]["thumb_size"]
|
||||
|
||||
def load_cache(gallery):
|
||||
if hasattr(gallery, "encryptCache"):
|
||||
return
|
||||
logger = gallery.logger
|
||||
settings = gallery.settings
|
||||
cachePath = os.path.join(settings["destination"], ".encryptCache")
|
||||
|
||||
try:
|
||||
with open(cachePath, "rb") as cacheFile:
|
||||
gallery.encryptCache = pickle.load(cacheFile)
|
||||
logger.debug("Loaded encryption cache with %d entries", len(gallery.encryptCache))
|
||||
except FileNotFoundError:
|
||||
gallery.encryptCache = {}
|
||||
except Exception as e:
|
||||
logger.error("Could not load encryption cache: %s", e)
|
||||
logger.error("Giving up encryption. Please delete and rebuild the entire gallery.")
|
||||
raise Abort
|
||||
|
||||
def save_cache(gallery):
|
||||
if hasattr(gallery, "encryptCache"):
|
||||
cache = gallery.encryptCache
|
||||
else:
|
||||
cache = gallery.encryptCache = {}
|
||||
|
||||
logger = gallery.logger
|
||||
settings = gallery.settings
|
||||
cachePath = os.path.join(settings["destination"], ".encryptCache")
|
||||
try:
|
||||
with open(cachePath, "wb") as cacheFile:
|
||||
pickle.dump(cache, cacheFile)
|
||||
logger.debug("Stored encryption cache with %d entries", len(cache))
|
||||
except Exception as e:
|
||||
logger.warning("Could not store encryption cache: %s", e)
|
||||
logger.warning("Next build of the gallery is likely to fail!")
|
||||
|
||||
def encrypt_gallery(gallery):
|
||||
logger = gallery.logger
|
||||
albums = gallery.albums
|
||||
settings = gallery.settings
|
||||
|
||||
try:
|
||||
load_cache(gallery)
|
||||
config = get_options(gallery)
|
||||
logger.debug("encryption config: %s", config)
|
||||
logger.info("starting encryption")
|
||||
encrypt_files(gallery, settings, config, albums)
|
||||
fix_html(gallery, settings, config, albums)
|
||||
copy_assets(settings)
|
||||
except Abort:
|
||||
pass
|
||||
|
||||
save_cache(gallery)
|
||||
|
||||
def encrypt_files(gallery, settings, config, albums):
|
||||
logger = gallery.logger
|
||||
if settings["keep_orig"]:
|
||||
if settings["orig_link"] and not config["encrypt_symlinked_originals"]:
|
||||
logger.warning("Original files are symlinked! Set encrypt_options[\"encrypt_symlinked_originals\"] to True to force encrypting them, if this is what you want.")
|
||||
raise Abort
|
||||
|
||||
key = kdf_gen_key(config["password"].encode("utf-8"), config["kdf_salt"].encode("utf-8"), config["kdf_iters"])
|
||||
medias = list(chain.from_iterable(albums.values()))
|
||||
with progressbar(medias, label="%16s" % "Encrypting files", file=gallery.progressbar_target, show_eta=True) as medias:
|
||||
for media in medias:
|
||||
if media.type != "image":
|
||||
logger.info("Skipping non-image file %s", media.filename)
|
||||
continue
|
||||
|
||||
save_property(gallery.encryptCache, media)
|
||||
to_encrypt = get_encrypt_list(settings, media)
|
||||
|
||||
cacheEntry = gallery.encryptCache[cache_key(media)]["encrypted"]
|
||||
for f in to_encrypt:
|
||||
if f in cacheEntry:
|
||||
logger.info("Skipping %s as it is already encrypted", f)
|
||||
continue
|
||||
|
||||
full_path = os.path.join(settings["destination"], f)
|
||||
with BytesIO() as outBuffer:
|
||||
try:
|
||||
with open(full_path, "rb") as infile:
|
||||
encrypt(key, infile, outBuffer, config["gcm_tag"].encode("utf-8"))
|
||||
except Exception as e:
|
||||
logger.error("Encryption failed for %s: %s", f, e)
|
||||
else:
|
||||
logger.info("Encrypting %s...", f)
|
||||
try:
|
||||
with open(full_path, "wb") as outfile:
|
||||
outfile.write(outBuffer.getbuffer())
|
||||
cacheEntry.add(f)
|
||||
except Exception as e:
|
||||
logger.error("Could not write to file %s: %s", f, e)
|
||||
|
||||
def fix_html(gallery, settings, config, albums):
|
||||
logger = gallery.logger
|
||||
if gallery.settings["write_html"]:
|
||||
decryptorConfigTemplate = """
|
||||
Decryptor.init({{
|
||||
password: "{filtered_password}",
|
||||
worker_script: "{worker_script}",
|
||||
galleryId: "{galleryId}",
|
||||
gcm_tag: "{escaped_gcm_tag}",
|
||||
kdf_salt: "{escaped_kdf_salt}",
|
||||
kdf_iters: {kdf_iters}
|
||||
}});
|
||||
"""
|
||||
config["filtered_password"] = "" if config.get("ask_password", False) else config["escaped_password"]
|
||||
|
||||
with progressbar(albums.values(), label="%16s" % "Fixing html files", file=gallery.progressbar_target, show_eta=True) as albums:
|
||||
for album in albums:
|
||||
index_file = os.path.join(album.dst_path, album.output_file)
|
||||
contents = None
|
||||
with open(index_file, "r", encoding="utf-8") as f:
|
||||
contents = f.read()
|
||||
root = BeautifulSoup(contents, "html.parser")
|
||||
head = root.find("head")
|
||||
if head.find(id="_decrypt_script"):
|
||||
head.find(id="_decrypt_script").decompose()
|
||||
if head.find(id="_decrypt_script_config"):
|
||||
head.find(id="_decrypt_script_config").decompose()
|
||||
theme_path = os.path.join(settings["destination"], 'static')
|
||||
theme_url = url_from_path(os.path.relpath(theme_path, album.dst_path))
|
||||
scriptNode = root.new_tag("script", id="_decrypt_script", src="{url}/decrypt.js".format(url=theme_url))
|
||||
scriptConfig = root.new_tag("script", id="_decrypt_script_config")
|
||||
config["worker_script"] = "{url}/decrypt-worker.js".format(url=theme_url)
|
||||
decryptorConfig = decryptorConfigTemplate.format(**config)
|
||||
scriptConfig.append(root.new_string(decryptorConfig))
|
||||
head.append(scriptNode)
|
||||
head.append(scriptConfig)
|
||||
with open(index_file, "w", encoding="utf-8") as f:
|
||||
f.write(root.prettify())
|
||||
|
||||
def copy_assets(settings):
|
||||
theme_path = os.path.join(settings["destination"], 'static')
|
||||
for root, dirs, files in os.walk(ASSETS_PATH):
|
||||
for file in files:
|
||||
copy(os.path.join(root, file), theme_path, symlink=False, rellink=False)
|
||||
|
||||
|
||||
def register(settings):
|
||||
signals.gallery_build.connect(encrypt_gallery)
|
||||
signals.album_initialized.connect(load_property)
|
||||
|
||||
112
sigal/plugins/encrypt/endec.py
Normal file
112
sigal/plugins/encrypt/endec.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
#coding: utf-8
|
||||
|
||||
# copyright (c) 2020 Bowen Ding
|
||||
|
||||
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
# of this software and associated documentation files (the "Software"), to
|
||||
# deal in the Software without restriction, including without limitation the
|
||||
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
# sell copies of the Software, and to permit persons to whom the Software is
|
||||
# furnished to do so, subject to the following conditions:
|
||||
|
||||
# The above copyright notice and this permission notice shall be included in
|
||||
# all copies or substantial portions of the Software.
|
||||
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
# IN THE SOFTWARE.
|
||||
|
||||
from pathlib import Path
|
||||
import os, io
|
||||
from base64 import b64decode
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from typing import BinaryIO
|
||||
|
||||
backend = default_backend()
|
||||
|
||||
def kdf_gen_key(password: bytes, salt:bytes, iters: int) -> bytes:
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA1(),
|
||||
length=16,
|
||||
salt=salt,
|
||||
iterations=iters,
|
||||
backend=backend
|
||||
)
|
||||
key = kdf.derive(password)
|
||||
return key
|
||||
|
||||
def dispatchargs(decorated):
|
||||
def wrapper(args):
|
||||
if args.key is not None:
|
||||
key = b64decode(args.key.encode("utf-8"))
|
||||
elif args.password is not None:
|
||||
key = kdf_gen_key(
|
||||
args.password.encode("utf-8"),
|
||||
args.kdf_salt.encode("utf-8"),
|
||||
args.kdf_iters
|
||||
)
|
||||
else:
|
||||
raise ValueError("Neither password nor key is provided")
|
||||
tag = args.gcm_tag.encode("utf-8")
|
||||
outputBuffer = io.BytesIO()
|
||||
with Path(args.infile).open("rb") as in_:
|
||||
decorated(key, in_, outputBuffer, tag)
|
||||
with Path(args.outfile).open("wb") as out:
|
||||
out.write(outputBuffer.getbuffer())
|
||||
|
||||
return wrapper
|
||||
|
||||
def encrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes):
|
||||
if len(key) != 128/8:
|
||||
raise ValueError("Unsupported key length: %d" % len(key))
|
||||
aesgcm = AESGCM(key)
|
||||
iv = os.urandom(12)
|
||||
plaintext = infile
|
||||
ciphertext = outfile
|
||||
rawbytes = plaintext.read()
|
||||
encrypted = aesgcm.encrypt(iv, rawbytes, tag)
|
||||
ciphertext.write(iv)
|
||||
ciphertext.write(encrypted)
|
||||
|
||||
def decrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes):
|
||||
if len(key) != 128/8:
|
||||
raise ValueError("Unsupported key length: %d" % len(key))
|
||||
aesgcm = AESGCM(key)
|
||||
ciphertext = infile
|
||||
plaintext = outfile
|
||||
iv = ciphertext.read(12)
|
||||
rawbytes = ciphertext.read()
|
||||
try:
|
||||
decrypted = aesgcm.decrypt(iv, rawbytes, tag)
|
||||
except InvalidTag:
|
||||
raise ValueError("Incorrect tag, iv, or corrupted ciphertext")
|
||||
plaintext.write(decrypted)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse as ap
|
||||
parser = ap.ArgumentParser(description="Encrypt or decrypt using AES-128-GCM")
|
||||
parser.add_argument("-k", "--key", help="Base64-encoded key")
|
||||
parser.add_argument("-p", "--password", help="Password in plaintext")
|
||||
parser.add_argument("--kdf-salt", help="PBKDF2 salt", default="saltysaltsweetysweet")
|
||||
parser.add_argument("--kdf-iters", type=int, help="PBKDF2 iterations", default=10000)
|
||||
parser.add_argument("--gcm-tag", help="AES-GCM tag", default="AuTheNTiCatIoNtAG")
|
||||
parser.add_argument("-i", "--infile", help="Input file")
|
||||
parser.add_argument("-o", "--outfile", help="Output file")
|
||||
subparsers = parser.add_subparsers(title="commands", dest="action")
|
||||
parser_enc = subparsers.add_parser("enc", help="Encrypt")
|
||||
parser_enc.set_defaults(execute=dispatchargs(encrypt))
|
||||
parser_dec = subparsers.add_parser("dec", help="Decrypt")
|
||||
parser_dec.set_defaults(execute=dispatchargs(decrypt))
|
||||
|
||||
args = parser.parse_args()
|
||||
args.execute(args)
|
||||
|
||||
26
sigal/plugins/encrypt/static/decrypt-worker.js
Normal file
26
sigal/plugins/encrypt/static/decrypt-worker.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* copyright (c) 2020 Bowen Ding
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
"use strict"
|
||||
importScripts("decrypt.js");
|
||||
|
||||
onmessage = Decryptor.onWorkerMessage;
|
||||
395
sigal/plugins/encrypt/static/decrypt.js
Normal file
395
sigal/plugins/encrypt/static/decrypt.js
Normal file
@@ -0,0 +1,395 @@
|
||||
/*
|
||||
* copyright (c) 2020 Bowen Ding
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to
|
||||
* deal in the Software without restriction, including without limitation the
|
||||
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
||||
* sell copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
||||
* IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
"use strict"
|
||||
class Decryptor {
|
||||
constructor(config) {
|
||||
const c = Decryptor._getCrypto();
|
||||
if (Decryptor.isWorker()) {
|
||||
this._role = "worker";
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const salt = encoder.encode(config.kdf_salt);
|
||||
const iters = config.kdf_iters;
|
||||
const shared_key = encoder.encode(config.password);
|
||||
const gcm_tag = encoder.encode(config.gcm_tag);
|
||||
|
||||
Decryptor
|
||||
._initAesKey(c, salt, iters, shared_key)
|
||||
.then((aes_key) => {
|
||||
this._decrypt = (encrypted_blob_arraybuffer) =>
|
||||
Decryptor.decrypt(c, encrypted_blob_arraybuffer,
|
||||
aes_key, gcm_tag);
|
||||
})
|
||||
.then(() => {
|
||||
Decryptor._sendEvent(self, "DecryptWorkerReady");
|
||||
});
|
||||
} else {
|
||||
this._role = "main";
|
||||
this._jobCount = 0;
|
||||
this._numWorkers = Math.min(4, navigator.hardwareConcurrency);
|
||||
this._jobMap = new Map();
|
||||
this._workerReady = false;
|
||||
this._galleryId = config.galleryId;
|
||||
Decryptor._initPage();
|
||||
if (!("password" in config && config.password)) {
|
||||
this._askPassword()
|
||||
.then((password) => {
|
||||
config.password = password;
|
||||
this._createWorkerPool(config);
|
||||
})
|
||||
} else {
|
||||
this._createWorkerPool(config);
|
||||
}
|
||||
}
|
||||
|
||||
console.info("Decryptor initialized");
|
||||
}
|
||||
|
||||
/* main thread only */
|
||||
static init(config) {
|
||||
if (Decryptor.isWorker()) return;
|
||||
window.decryptor = new Decryptor(config);
|
||||
}
|
||||
|
||||
static isWorker() {
|
||||
return ('undefined' !== typeof WorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator);
|
||||
}
|
||||
|
||||
static _getCrypto() {
|
||||
if(crypto && crypto.subtle) {
|
||||
return crypto.subtle;
|
||||
} else {
|
||||
throw new Error("Fatal: Browser does not support Web Crypto");
|
||||
}
|
||||
}
|
||||
|
||||
/* main thread only */
|
||||
async _askPassword() {
|
||||
let password = sessionStorage.getItem(this._galleryId);
|
||||
if (!password) {
|
||||
return new Promise((s, e) => {
|
||||
window.addEventListener(
|
||||
"load",
|
||||
s,
|
||||
{ once: true, passive: true }
|
||||
);
|
||||
}).then((e) => {
|
||||
const password = prompt("Input password to view this gallery:");
|
||||
if (password) {
|
||||
sessionStorage.setItem(this._galleryId, password);
|
||||
return password;
|
||||
} else {
|
||||
return "__wrong_password__";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return password;
|
||||
}
|
||||
}
|
||||
|
||||
static async _initAesKey(crypto, kdf_salt, kdf_iters, shared_key) {
|
||||
const pbkdf2key = await crypto.importKey(
|
||||
"raw",
|
||||
shared_key,
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveKey"]
|
||||
);
|
||||
const pbkdf2params = {
|
||||
name: "PBKDF2",
|
||||
hash: "SHA-1",
|
||||
salt: kdf_salt,
|
||||
iterations: kdf_iters
|
||||
};
|
||||
return await crypto.deriveKey(
|
||||
pbkdf2params,
|
||||
pbkdf2key,
|
||||
{ name: "AES-GCM", length: 128 },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
async _doReload(url, img) {
|
||||
const proceed = Decryptor._sendEvent(img, "DecryptImageBeforeLoad", {oldSrc: url});
|
||||
if (proceed) {
|
||||
let old_src = url;
|
||||
try {
|
||||
const blobUrl = await this.dispatchJob("reloadImage", [old_src, null]);
|
||||
img.addEventListener(
|
||||
"load",
|
||||
(e) => Decryptor._sendEvent(e.target, "DecryptImageLoaded", {oldSrc: old_src}),
|
||||
{once: true, passive: true}
|
||||
);
|
||||
img.src = blobUrl;
|
||||
} catch (error) {
|
||||
img.addEventListener(
|
||||
"load",
|
||||
(e) => Decryptor._sendEvent(e.target, "DecryptImageError", {oldSrc: old_src, error: error}),
|
||||
{once: true, passive: true}
|
||||
);
|
||||
img.src = Decryptor.imagePlaceholderURL;
|
||||
// password is incorrect
|
||||
if (error.message.indexOf("decryption failed") >= 0) {
|
||||
sessionStorage.removeItem(this._galleryId);
|
||||
}
|
||||
throw new Error(`Image reload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reloadImage(url, img) {
|
||||
if (this._role === "main") {
|
||||
const full_url = (new URL(url, window.location)).toString();
|
||||
if (!this.isWorkerReady()) {
|
||||
document.addEventListener(
|
||||
"DecryptWorkerReady",
|
||||
(e) => { this._doReload(full_url, img); },
|
||||
{once: true, passive: true}
|
||||
);
|
||||
} else {
|
||||
this._doReload(full_url, img);
|
||||
}
|
||||
} else if (this._role === "worker") {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(url);
|
||||
} catch (e) {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
if (r && r.ok) {
|
||||
const encrypted_blob = await r.blob();
|
||||
try {
|
||||
const decrypted_blob = await this._decrypt(encrypted_blob);
|
||||
return URL.createObjectURL(decrypted_blob);
|
||||
} catch (e) {
|
||||
throw new Error(`decryption failed: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* main thread only */
|
||||
static onNewImageError(e) {
|
||||
if (e.target.src.startsWith("blob")) return;
|
||||
if (!window.decryptor) return;
|
||||
|
||||
window.decryptor.reloadImage(e.target.src, e.target);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
static _sendEvent(target, type, detail = null) {
|
||||
const eventInit = {
|
||||
detail: detail,
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
};
|
||||
return target.dispatchEvent(new CustomEvent(type, eventInit));
|
||||
}
|
||||
|
||||
/* main thread only */
|
||||
static _initPage() {
|
||||
document.addEventListener(
|
||||
"error",
|
||||
e => {
|
||||
if (e.target instanceof HTMLImageElement) {
|
||||
Decryptor.onNewImageError(e);
|
||||
}
|
||||
},
|
||||
{capture: true}
|
||||
);
|
||||
|
||||
Image = (function (oldImage) {
|
||||
function Image(...args) {
|
||||
let img = new oldImage(...args);
|
||||
img.addEventListener(
|
||||
"error",
|
||||
Decryptor.onNewImageError
|
||||
);
|
||||
return img;
|
||||
}
|
||||
Image.prototype = oldImage.prototype;
|
||||
Image.prototype.constructor = Image;
|
||||
return Image;
|
||||
})(Image);
|
||||
|
||||
document.createElement = (function(create) {
|
||||
return function() {
|
||||
let ret = create.apply(this, arguments);
|
||||
if (ret.tagName.toLowerCase() === "img") {
|
||||
ret.addEventListener(
|
||||
"error",
|
||||
Decryptor.onNewImageError
|
||||
);
|
||||
}
|
||||
return ret;
|
||||
};
|
||||
})(document.createElement);
|
||||
}
|
||||
|
||||
static async decrypt(crypto, blob, aes_key, gcm_tag) {
|
||||
const iv = await blob.slice(0, 12).arrayBuffer();
|
||||
const ciphertext = await blob.slice(12).arrayBuffer();
|
||||
const decrypted = await crypto.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
additionalData: gcm_tag
|
||||
},
|
||||
aes_key,
|
||||
ciphertext
|
||||
);
|
||||
return new Blob([decrypted], {type: blob.type});
|
||||
}
|
||||
|
||||
isWorkerReady() {
|
||||
return this._workerReady;
|
||||
}
|
||||
|
||||
_createWorkerPool(config) {
|
||||
if (this._role !== "main") return;
|
||||
if (this._workerReady) return;
|
||||
|
||||
let callback = (e) => {
|
||||
const callbacks = this._jobMap.get(e.data.id);
|
||||
if (e.data.success) {
|
||||
if (callbacks.success) callbacks.success(e.data.result);
|
||||
} else {
|
||||
if (callbacks.error) callbacks.error(new Error(e.data.result));
|
||||
}
|
||||
this._jobMap.delete(e.data.id);
|
||||
};
|
||||
|
||||
let pool = Array();
|
||||
|
||||
for (let i = 0; i < this._numWorkers; i++) {
|
||||
let worker = new Worker(config.worker_script);
|
||||
worker.onmessage = callback;
|
||||
pool.push(worker);
|
||||
}
|
||||
this._workerPool = pool;
|
||||
|
||||
let notReadyWorkers = this._numWorkers;
|
||||
for (let i = 0; i < this._numWorkers; i++) {
|
||||
this.dispatchJob("new", [config])
|
||||
.then(() => {
|
||||
if (--notReadyWorkers <= 0) {
|
||||
this._workerReady = true;
|
||||
Decryptor._sendEvent(document, "DecryptWorkerReady");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* method: string
|
||||
* args: Array
|
||||
*/
|
||||
dispatchJob(method, args) {
|
||||
if (this._role === "main") {
|
||||
return new Promise((success, error) => {
|
||||
const jobId = this._jobCount++;
|
||||
const worker = this._workerPool[jobId % this._numWorkers];
|
||||
this._jobMap.set(jobId, {success: success, error: error});
|
||||
Decryptor._postJobToWorker(jobId, worker, method, args);
|
||||
});
|
||||
} else if (this._role === "worker") {
|
||||
return Decryptor._asyncReturn(this, method, args)
|
||||
.then(
|
||||
(result) => { return {success: true, result: result}; },
|
||||
(error) => { return {success: false, result: error.message}; }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static _asyncReturn(instance, method, args) {
|
||||
if (method in instance && instance[method] instanceof Function) {
|
||||
try {
|
||||
let promise_or_value = instance[method].apply(instance, args);
|
||||
if (promise_or_value instanceof Promise) {
|
||||
return promise_or_value;
|
||||
} else {
|
||||
return Promise.resolve(promise_or_value);
|
||||
}
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
} else {
|
||||
return Promise.reject(new Error(`no such method: ${method}`))
|
||||
}
|
||||
}
|
||||
|
||||
static _postJobToWorker(jobId, worker, method, args) {
|
||||
const job = {
|
||||
id: jobId,
|
||||
method: method,
|
||||
args: args
|
||||
};
|
||||
worker.postMessage(job);
|
||||
}
|
||||
|
||||
/* worker thread only */
|
||||
static onWorkerMessage(e) {
|
||||
const id = e.data.id;
|
||||
const method = e.data.method;
|
||||
const args = e.data.args;
|
||||
|
||||
if (method === "new") {
|
||||
self.decryptor = new Decryptor(...args);
|
||||
self.addEventListener(
|
||||
"DecryptWorkerReady",
|
||||
(e) => self.postMessage({id: id, success: true, result: "worker ready"}),
|
||||
{once: true, passive: true}
|
||||
);
|
||||
} else {
|
||||
self.decryptor
|
||||
.dispatchJob(method, args)
|
||||
.then((reply) => {
|
||||
reply.id = id;
|
||||
self.postMessage(reply);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Decryptor.imagePlaceholderURL = URL.createObjectURL(new Blob([
|
||||
`<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"}));
|
||||
|
||||
@@ -17,7 +17,16 @@ plugins = [
|
||||
'sigal.plugins.nomedia',
|
||||
'sigal.plugins.watermark',
|
||||
'sigal.plugins.zip_gallery',
|
||||
'sigal.plugins.encrypt'
|
||||
]
|
||||
encrypt_options = {
|
||||
'password': 'password',
|
||||
'ask_password': True,
|
||||
'gcm_tag': 'AuTheNTiCatIoNtAG',
|
||||
'kdf_salt': 'saltysaltsweetysweet',
|
||||
'kdf_iters': 10000,
|
||||
'encrypt_symlinked_originals': False
|
||||
}
|
||||
copyright = '© An example copyright message'
|
||||
adjust_options = {'color': 0.9, 'brightness': 1.0,
|
||||
'contrast': 1.0, 'sharpness': 0.0}
|
||||
|
||||
Reference in New Issue
Block a user