Browse Source

Add plugin encrypt

pull/385/head
Bowen Ding 6 years ago
parent
commit
87f29707b8
  1. 5
      docs/plugins.rst
  2. 2
      setup.cfg
  3. 1
      sigal/plugins/encrypt/__init__.py
  4. 297
      sigal/plugins/encrypt/encrypt.py
  5. 112
      sigal/plugins/encrypt/endec.py
  6. 26
      sigal/plugins/encrypt/static/decrypt-worker.js
  7. 395
      sigal/plugins/encrypt/static/decrypt.js
  8. 9
      tests/sample/sigal.conf.py
  9. 2
      tox.ini

5
docs/plugins.rst

@ -135,3 +135,8 @@ ZIP Gallery plugin
==================
.. automodule:: sigal.plugins.zip_gallery
Encrypt plugin
==============
.. automodule:: sigal.plugins.encrypt

2
setup.cfg

@ -37,7 +37,7 @@ install_requires =
pilkit
[options.extras_require]
all = boto; brotli; feedgenerator; zopfli
all = boto; brotli; feedgenerator; zopfli; cryptography; beautifulsoup4
tests = pytest; pytest-cov
docs = Sphinx; alabaster

1
sigal/plugins/encrypt/__init__.py

@ -0,0 +1 @@
from .encrypt import register

297
sigal/plugins/encrypt/encrypt.py

@ -0,0 +1,297 @@
# copyright (c) 2020 Bowen Ding
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
'''Plugin to protect gallery by encrypting image files using a password.
Options:
encrypt_options = {
'password': 'password',
'ask_password': False,
'gcm_tag': 'randomly_generated',
'kdf_salt': 'randomly_generated',
'kdf_iters': 10000,
'encrypt_symlinked_originals': False
}
- ``password``: The password used to encrypt the images on gallery build,
and decrypt them when viewers access the gallery. No default value. You must
specify a password.
- ``ask_password``: Whether or not viewers are asked for the password to view
the gallery. If set to ``False``, the password will be present in the HTML files
so the images are decrypted automatically. Defaults to ``False``.
- ``gcm_tag``, ``kdf_salt``, ``kdf_iters``: Cryptographic parameters used when
encrypting the files. ``gcm_tag``, ``kdf_salt`` are meant to be randomly generated,
``kdf_iters`` defaults to 10000. Do not specify them in the config file unless
you have good reasons to do so.
- ``encrypt_symlinked_originals``: Force encrypting original images even if they
are symlinked. If you don't know what it means, leave it to ``False``.
Note: The plugin caches the cryptographic parameters (but not the password) after
the first build, so that incremental builds can share the same credentials.
DO NOT CHANGE THE PASSWORD OR OTHER CRYPTOGRAPHIC PARAMETERS ONCE A GALLERY IS
BUILT, or there will be inconsistency in encrypted files and viewers will not be able
to see some of the images any more.
'''
import os, random, string, logging, pickle
from io import BytesIO
from itertools import chain
from sigal import signals
from sigal.utils import url_from_path, copy
from sigal.settings import get_thumb
from click import progressbar
from bs4 import BeautifulSoup
from .endec import encrypt, kdf_gen_key
ASSETS_PATH = os.path.normpath(os.path.join(
os.path.abspath(os.path.dirname(__file__)), 'static'))
class Abort(Exception):
pass
def gen_rand_string(length=16):
return "".join(random.SystemRandom().choices(string.ascii_letters + string.digits, k=length))
def get_options(gallery):
settings = gallery.settings
cache = gallery.encryptCache
if "encrypt_options" not in settings:
raise ValueError("Encrypt: no options in settings")
#try load credential from cache
try:
options = cache["credentials"]
except KeyError:
options = settings["encrypt_options"]
table = str.maketrans({'"': r'\"', '\\': r'\\'})
if "password" not in settings["encrypt_options"]:
raise ValueError("Encrypt: no password provided")
else:
options["password"] = settings["encrypt_options"]["password"]
options["escaped_password"] = options["password"].translate(table)
if "gcm_tag" not in options:
options["gcm_tag"] = gen_rand_string()
options["escaped_gcm_tag"] = options["gcm_tag"].translate(table)
if "kdf_salt" not in options:
options["kdf_salt"] = gen_rand_string()
options["escaped_kdf_salt"] = options["kdf_salt"].translate(table)
if "galleryId" not in options:
options["galleryId"] = gen_rand_string(6)
if "kdf_iters" not in options:
options["kdf_iters"] = 10000
if "ask_password" not in options:
options["ask_password"] = settings["encrypt_options"].get("ask_password", False)
gallery.encryptCache["credentials"] = {
"gcm_tag": options["gcm_tag"],
"kdf_salt": options["kdf_salt"],
"kdf_iters": options["kdf_iters"],
"galleryId": options["galleryId"]
}
if "encrypt_symlinked_originals" not in options:
options["encrypt_symlinked_originals"] = settings["encrypt_options"].get("encrypt_symlinked_originals", False)
return options
def cache_key(media):
return os.path.join(media.path, media.filename)
def save_property(cache, media):
key = cache_key(media)
if key not in cache:
cache[key] = {}
cache[key]["size"] = media.size
cache[key]["thumb_size"] = media.thumb_size
cache[key]["encrypted"] = set()
def get_encrypt_list(settings, media):
to_encrypt = []
to_encrypt.append(media.filename) #resized image
if settings["make_thumbs"]:
to_encrypt.append(get_thumb(settings, media.filename)) #thumbnail
if media.big is not None:
to_encrypt.append(media.big) #original image
to_encrypt = list(map(lambda path: os.path.join(media.path, path), to_encrypt))
return to_encrypt
def load_property(album):
if not hasattr(album.gallery, "encryptCache"):
load_cache(album.gallery)
cache = album.gallery.encryptCache
for media in album.medias:
if media.type == "image":
key = cache_key(media)
if key in cache:
media.size = cache[key]["size"]
media.thumb_size = cache[key]["thumb_size"]
def load_cache(gallery):
if hasattr(gallery, "encryptCache"):
return
logger = gallery.logger
settings = gallery.settings
cachePath = os.path.join(settings["destination"], ".encryptCache")
try:
with open(cachePath, "rb") as cacheFile:
gallery.encryptCache = pickle.load(cacheFile)
logger.debug("Loaded encryption cache with %d entries", len(gallery.encryptCache))
except FileNotFoundError:
gallery.encryptCache = {}
except Exception as e:
logger.error("Could not load encryption cache: %s", e)
logger.error("Giving up encryption. Please delete and rebuild the entire gallery.")
raise Abort
def save_cache(gallery):
if hasattr(gallery, "encryptCache"):
cache = gallery.encryptCache
else:
cache = gallery.encryptCache = {}
logger = gallery.logger
settings = gallery.settings
cachePath = os.path.join(settings["destination"], ".encryptCache")
try:
with open(cachePath, "wb") as cacheFile:
pickle.dump(cache, cacheFile)
logger.debug("Stored encryption cache with %d entries", len(cache))
except Exception as e:
logger.warning("Could not store encryption cache: %s", e)
logger.warning("Next build of the gallery is likely to fail!")
def encrypt_gallery(gallery):
logger = gallery.logger
albums = gallery.albums
settings = gallery.settings
try:
load_cache(gallery)
config = get_options(gallery)
logger.debug("encryption config: %s", config)
logger.info("starting encryption")
encrypt_files(gallery, settings, config, albums)
fix_html(gallery, settings, config, albums)
copy_assets(settings)
except Abort:
pass
save_cache(gallery)
def encrypt_files(gallery, settings, config, albums):
logger = gallery.logger
if settings["keep_orig"]:
if settings["orig_link"] and not config["encrypt_symlinked_originals"]:
logger.warning("Original files are symlinked! Set encrypt_options[\"encrypt_symlinked_originals\"] to True to force encrypting them, if this is what you want.")
raise Abort
key = kdf_gen_key(config["password"].encode("utf-8"), config["kdf_salt"].encode("utf-8"), config["kdf_iters"])
medias = list(chain.from_iterable(albums.values()))
with progressbar(medias, label="%16s" % "Encrypting files", file=gallery.progressbar_target, show_eta=True) as medias:
for media in medias:
if media.type != "image":
logger.info("Skipping non-image file %s", media.filename)
continue
save_property(gallery.encryptCache, media)
to_encrypt = get_encrypt_list(settings, media)
cacheEntry = gallery.encryptCache[cache_key(media)]["encrypted"]
for f in to_encrypt:
if f in cacheEntry:
logger.info("Skipping %s as it is already encrypted", f)
continue
full_path = os.path.join(settings["destination"], f)
with BytesIO() as outBuffer:
try:
with open(full_path, "rb") as infile:
encrypt(key, infile, outBuffer, config["gcm_tag"].encode("utf-8"))
except Exception as e:
logger.error("Encryption failed for %s: %s", f, e)
else:
logger.info("Encrypting %s...", f)
try:
with open(full_path, "wb") as outfile:
outfile.write(outBuffer.getbuffer())
cacheEntry.add(f)
except Exception as e:
logger.error("Could not write to file %s: %s", f, e)
def fix_html(gallery, settings, config, albums):
logger = gallery.logger
if gallery.settings["write_html"]:
decryptorConfigTemplate = """
Decryptor.init({{
password: "{filtered_password}",
worker_script: "{worker_script}",
galleryId: "{galleryId}",
gcm_tag: "{escaped_gcm_tag}",
kdf_salt: "{escaped_kdf_salt}",
kdf_iters: {kdf_iters}
}});
"""
config["filtered_password"] = "" if config.get("ask_password", False) else config["escaped_password"]
with progressbar(albums.values(), label="%16s" % "Fixing html files", file=gallery.progressbar_target, show_eta=True) as albums:
for album in albums:
index_file = os.path.join(album.dst_path, album.output_file)
contents = None
with open(index_file, "r", encoding="utf-8") as f:
contents = f.read()
root = BeautifulSoup(contents, "html.parser")
head = root.find("head")
if head.find(id="_decrypt_script"):
head.find(id="_decrypt_script").decompose()
if head.find(id="_decrypt_script_config"):
head.find(id="_decrypt_script_config").decompose()
theme_path = os.path.join(settings["destination"], 'static')
theme_url = url_from_path(os.path.relpath(theme_path, album.dst_path))
scriptNode = root.new_tag("script", id="_decrypt_script", src="{url}/decrypt.js".format(url=theme_url))
scriptConfig = root.new_tag("script", id="_decrypt_script_config")
config["worker_script"] = "{url}/decrypt-worker.js".format(url=theme_url)
decryptorConfig = decryptorConfigTemplate.format(**config)
scriptConfig.append(root.new_string(decryptorConfig))
head.append(scriptNode)
head.append(scriptConfig)
with open(index_file, "w", encoding="utf-8") as f:
f.write(root.prettify())
def copy_assets(settings):
theme_path = os.path.join(settings["destination"], 'static')
for root, dirs, files in os.walk(ASSETS_PATH):
for file in files:
copy(os.path.join(root, file), theme_path, symlink=False, rellink=False)
def register(settings):
signals.gallery_build.connect(encrypt_gallery)
signals.album_initialized.connect(load_property)

112
sigal/plugins/encrypt/endec.py

@ -0,0 +1,112 @@
#!/usr/bin/env python3
#coding: utf-8
# copyright (c) 2020 Bowen Ding
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
from pathlib import Path
import os, io
from base64 import b64decode
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.exceptions import InvalidTag
from typing import BinaryIO
backend = default_backend()
def kdf_gen_key(password: bytes, salt:bytes, iters: int) -> bytes:
kdf = PBKDF2HMAC(
algorithm=hashes.SHA1(),
length=16,
salt=salt,
iterations=iters,
backend=backend
)
key = kdf.derive(password)
return key
def dispatchargs(decorated):
def wrapper(args):
if args.key is not None:
key = b64decode(args.key.encode("utf-8"))
elif args.password is not None:
key = kdf_gen_key(
args.password.encode("utf-8"),
args.kdf_salt.encode("utf-8"),
args.kdf_iters
)
else:
raise ValueError("Neither password nor key is provided")
tag = args.gcm_tag.encode("utf-8")
outputBuffer = io.BytesIO()
with Path(args.infile).open("rb") as in_:
decorated(key, in_, outputBuffer, tag)
with Path(args.outfile).open("wb") as out:
out.write(outputBuffer.getbuffer())
return wrapper
def encrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes):
if len(key) != 128/8:
raise ValueError("Unsupported key length: %d" % len(key))
aesgcm = AESGCM(key)
iv = os.urandom(12)
plaintext = infile
ciphertext = outfile
rawbytes = plaintext.read()
encrypted = aesgcm.encrypt(iv, rawbytes, tag)
ciphertext.write(iv)
ciphertext.write(encrypted)
def decrypt(key: bytes, infile: BinaryIO, outfile: BinaryIO, tag: bytes):
if len(key) != 128/8:
raise ValueError("Unsupported key length: %d" % len(key))
aesgcm = AESGCM(key)
ciphertext = infile
plaintext = outfile
iv = ciphertext.read(12)
rawbytes = ciphertext.read()
try:
decrypted = aesgcm.decrypt(iv, rawbytes, tag)
except InvalidTag:
raise ValueError("Incorrect tag, iv, or corrupted ciphertext")
plaintext.write(decrypted)
if __name__ == "__main__":
import argparse as ap
parser = ap.ArgumentParser(description="Encrypt or decrypt using AES-128-GCM")
parser.add_argument("-k", "--key", help="Base64-encoded key")
parser.add_argument("-p", "--password", help="Password in plaintext")
parser.add_argument("--kdf-salt", help="PBKDF2 salt", default="saltysaltsweetysweet")
parser.add_argument("--kdf-iters", type=int, help="PBKDF2 iterations", default=10000)
parser.add_argument("--gcm-tag", help="AES-GCM tag", default="AuTheNTiCatIoNtAG")
parser.add_argument("-i", "--infile", help="Input file")
parser.add_argument("-o", "--outfile", help="Output file")
subparsers = parser.add_subparsers(title="commands", dest="action")
parser_enc = subparsers.add_parser("enc", help="Encrypt")
parser_enc.set_defaults(execute=dispatchargs(encrypt))
parser_dec = subparsers.add_parser("dec", help="Decrypt")
parser_dec.set_defaults(execute=dispatchargs(decrypt))
args = parser.parse_args()
args.execute(args)

26
sigal/plugins/encrypt/static/decrypt-worker.js

@ -0,0 +1,26 @@
/*
* copyright (c) 2020 Bowen Ding
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
"use strict"
importScripts("decrypt.js");
onmessage = Decryptor.onWorkerMessage;

395
sigal/plugins/encrypt/static/decrypt.js

@ -0,0 +1,395 @@
/*
* copyright (c) 2020 Bowen Ding
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to
* deal in the Software without restriction, including without limitation the
* rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
* sell copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*/
"use strict"
class Decryptor {
constructor(config) {
const c = Decryptor._getCrypto();
if (Decryptor.isWorker()) {
this._role = "worker";
const encoder = new TextEncoder("utf-8");
const salt = encoder.encode(config.kdf_salt);
const iters = config.kdf_iters;
const shared_key = encoder.encode(config.password);
const gcm_tag = encoder.encode(config.gcm_tag);
Decryptor
._initAesKey(c, salt, iters, shared_key)
.then((aes_key) => {
this._decrypt = (encrypted_blob_arraybuffer) =>
Decryptor.decrypt(c, encrypted_blob_arraybuffer,
aes_key, gcm_tag);
})
.then(() => {
Decryptor._sendEvent(self, "DecryptWorkerReady");
});
} else {
this._role = "main";
this._jobCount = 0;
this._numWorkers = Math.min(4, navigator.hardwareConcurrency);
this._jobMap = new Map();
this._workerReady = false;
this._galleryId = config.galleryId;
Decryptor._initPage();
if (!("password" in config && config.password)) {
this._askPassword()
.then((password) => {
config.password = password;
this._createWorkerPool(config);
})
} else {
this._createWorkerPool(config);
}
}
console.info("Decryptor initialized");
}
/* main thread only */
static init(config) {
if (Decryptor.isWorker()) return;
window.decryptor = new Decryptor(config);
}
static isWorker() {
return ('undefined' !== typeof WorkerGlobalScope) && ("function" === typeof importScripts) && (navigator instanceof WorkerNavigator);
}
static _getCrypto() {
if(crypto && crypto.subtle) {
return crypto.subtle;
} else {
throw new Error("Fatal: Browser does not support Web Crypto");
}
}
/* main thread only */
async _askPassword() {
let password = sessionStorage.getItem(this._galleryId);
if (!password) {
return new Promise((s, e) => {
window.addEventListener(
"load",
s,
{ once: true, passive: true }
);
}).then((e) => {
const password = prompt("Input password to view this gallery:");
if (password) {
sessionStorage.setItem(this._galleryId, password);
return password;
} else {
return "__wrong_password__";
}
});
} else {
return password;
}
}
static async _initAesKey(crypto, kdf_salt, kdf_iters, shared_key) {
const pbkdf2key = await crypto.importKey(
"raw",
shared_key,
"PBKDF2",
false,
["deriveKey"]
);
const pbkdf2params = {
name: "PBKDF2",
hash: "SHA-1",
salt: kdf_salt,
iterations: kdf_iters
};
return await crypto.deriveKey(
pbkdf2params,
pbkdf2key,
{ name: "AES-GCM", length: 128 },
false,
["decrypt"]
);
}
async _doReload(url, img) {
const proceed = Decryptor._sendEvent(img, "DecryptImageBeforeLoad", {oldSrc: url});
if (proceed) {
let old_src = url;
try {
const blobUrl = await this.dispatchJob("reloadImage", [old_src, null]);
img.addEventListener(
"load",
(e) => Decryptor._sendEvent(e.target, "DecryptImageLoaded", {oldSrc: old_src}),
{once: true, passive: true}
);
img.src = blobUrl;
} catch (error) {
img.addEventListener(
"load",
(e) => Decryptor._sendEvent(e.target, "DecryptImageError", {oldSrc: old_src, error: error}),
{once: true, passive: true}
);
img.src = Decryptor.imagePlaceholderURL;
// password is incorrect
if (error.message.indexOf("decryption failed") >= 0) {
sessionStorage.removeItem(this._galleryId);
}
throw new Error(`Image reload failed: ${error.message}`);
}
}
}
async reloadImage(url, img) {
if (this._role === "main") {
const full_url = (new URL(url, window.location)).toString();
if (!this.isWorkerReady()) {
document.addEventListener(
"DecryptWorkerReady",
(e) => { this._doReload(full_url, img); },
{once: true, passive: true}
);
} else {
this._doReload(full_url, img);
}
} else if (this._role === "worker") {
let r;
try {
r = await fetch(url);
} catch (e) {
throw new Error("fetch failed");
}
if (r && r.ok) {
const encrypted_blob = await r.blob();
try {
const decrypted_blob = await this._decrypt(encrypted_blob);
return URL.createObjectURL(decrypted_blob);
} catch (e) {
throw new Error(`decryption failed: ${e.message}`);
}
} else {
throw new Error("fetch failed");
}
}
}
/* main thread only */
static onNewImageError(e) {
if (e.target.src.startsWith("blob")) return;
if (!window.decryptor) return;
window.decryptor.reloadImage(e.target.src, e.target);
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
static _sendEvent(target, type, detail = null) {
const eventInit = {
detail: detail,
bubbles: true,
cancelable: true
};
return target.dispatchEvent(new CustomEvent(type, eventInit));
}
/* main thread only */
static _initPage() {
document.addEventListener(
"error",
e => {
if (e.target instanceof HTMLImageElement) {
Decryptor.onNewImageError(e);
}
},
{capture: true}
);
Image = (function (oldImage) {
function Image(...args) {
let img = new oldImage(...args);
img.addEventListener(
"error",
Decryptor.onNewImageError
);
return img;
}
Image.prototype = oldImage.prototype;
Image.prototype.constructor = Image;
return Image;
})(Image);
document.createElement = (function(create) {
return function() {
let ret = create.apply(this, arguments);
if (ret.tagName.toLowerCase() === "img") {
ret.addEventListener(
"error",
Decryptor.onNewImageError
);
}
return ret;
};
})(document.createElement);
}
static async decrypt(crypto, blob, aes_key, gcm_tag) {
const iv = await blob.slice(0, 12).arrayBuffer();
const ciphertext = await blob.slice(12).arrayBuffer();
const decrypted = await crypto.decrypt(
{
name: "AES-GCM",
iv: iv,
additionalData: gcm_tag
},
aes_key,
ciphertext
);
return new Blob([decrypted], {type: blob.type});
}
isWorkerReady() {
return this._workerReady;
}
_createWorkerPool(config) {
if (this._role !== "main") return;
if (this._workerReady) return;
let callback = (e) => {
const callbacks = this._jobMap.get(e.data.id);
if (e.data.success) {
if (callbacks.success) callbacks.success(e.data.result);
} else {
if (callbacks.error) callbacks.error(new Error(e.data.result));
}
this._jobMap.delete(e.data.id);
};
let pool = Array();
for (let i = 0; i < this._numWorkers; i++) {
let worker = new Worker(config.worker_script);
worker.onmessage = callback;
pool.push(worker);
}
this._workerPool = pool;
let notReadyWorkers = this._numWorkers;
for (let i = 0; i < this._numWorkers; i++) {
this.dispatchJob("new", [config])
.then(() => {
if (--notReadyWorkers <= 0) {
this._workerReady = true;
Decryptor._sendEvent(document, "DecryptWorkerReady");
}
});
}
}
/*
* method: string
* args: Array
*/
dispatchJob(method, args) {
if (this._role === "main") {
return new Promise((success, error) => {
const jobId = this._jobCount++;
const worker = this._workerPool[jobId % this._numWorkers];
this._jobMap.set(jobId, {success: success, error: error});
Decryptor._postJobToWorker(jobId, worker, method, args);
});
} else if (this._role === "worker") {
return Decryptor._asyncReturn(this, method, args)
.then(
(result) => { return {success: true, result: result}; },
(error) => { return {success: false, result: error.message}; }
);
}
}
static _asyncReturn(instance, method, args) {
if (method in instance && instance[method] instanceof Function) {
try {
let promise_or_value = instance[method].apply(instance, args);
if (promise_or_value instanceof Promise) {
return promise_or_value;
} else {
return Promise.resolve(promise_or_value);
}
} catch (e) {
return Promise.reject(e);
}
} else {
return Promise.reject(new Error(`no such method: ${method}`))
}
}
static _postJobToWorker(jobId, worker, method, args) {
const job = {
id: jobId,
method: method,
args: args
};
worker.postMessage(job);
}
/* worker thread only */
static onWorkerMessage(e) {
const id = e.data.id;
const method = e.data.method;
const args = e.data.args;
if (method === "new") {
self.decryptor = new Decryptor(...args);
self.addEventListener(
"DecryptWorkerReady",
(e) => self.postMessage({id: id, success: true, result: "worker ready"}),
{once: true, passive: true}
);
} else {
self.decryptor
.dispatchJob(method, args)
.then((reply) => {
reply.id = id;
self.postMessage(reply);
});
}
}
}
Decryptor.imagePlaceholderURL = URL.createObjectURL(new Blob([
`<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"}));

9
tests/sample/sigal.conf.py

@ -17,7 +17,16 @@ plugins = [
'sigal.plugins.nomedia',
'sigal.plugins.watermark',
'sigal.plugins.zip_gallery',
'sigal.plugins.encrypt'
]
encrypt_options = {
'password': 'password',
'ask_password': True,
'gcm_tag': 'AuTheNTiCatIoNtAG',
'kdf_salt': 'saltysaltsweetysweet',
'kdf_iters': 10000,
'encrypt_symlinked_originals': False
}
copyright = '© An example copyright message'
adjust_options = {'color': 0.9, 'brightness': 1.0,
'contrast': 1.0, 'sharpness': 0.0}

2
tox.ini

@ -38,6 +38,8 @@ commands =
usedevelop = true
deps =
feedgenerator
cryptography
beautifulsoup4
commands =
sigal build -c tests/sample/sigal.conf.py
sigal serve tests/sample/_build

Loading…
Cancel
Save