mirror of https://github.com/saimn/sigal.git
9 changed files with 848 additions and 1 deletions
@ -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) |
||||
|
||||
@ -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) |
||||
|
||||
@ -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; |
||||
@ -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"})); |
||||
|
||||
Loading…
Reference in new issue