Merge pull request #385 from dbw9580/plugin_encrypt

Plugin encrypt
This commit is contained in:
Simon Conseil
2020-04-20 09:30:47 -04:00
committed by GitHub
21 changed files with 1122 additions and 4 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

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

View 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)

View 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)

View 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);
})
]);
}

View File

@@ -0,0 +1 @@
This file will be decrypted to test if the password supplied by the user is correct.

View 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({});

View File

@@ -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

View File

@@ -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')

View File

@@ -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
# }

View File

@@ -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' %}

View 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 %}

View File

@@ -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' %}

View File

@@ -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' %}

View File

@@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

84
tests/test_encrypt.py Normal file
View 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)

View File

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