Commit 2dec9b9f authored by Romain Courteaud's avatar Romain Courteaud

Merge branch 'lucas_crypt' into 'master'

Lucas crypt

added cryptstorage with tests and scenario.


See merge request !6
parents 13b0594c e88a41e1
......@@ -180,6 +180,7 @@ module.exports = function (grunt) {
'src/jio.storage/memorystorage.js',
'src/jio.storage/localstorage.js',
'src/jio.storage/zipstorage.js',
'src/jio.storage/cryptstorage.js',
'src/jio.storage/dropboxstorage.js',
'src/jio.storage/davstorage.js',
'src/jio.storage/unionstorage.js',
......
......@@ -135,6 +135,27 @@
// }
// });
///////////////////////////
// Crypt storage
///////////////////////////
// return g.run({
// type: "query",
// sub_storage: {
// type: "uuid",
// sub_storage: {
// type: "crypt",
// key: {"alg": "A256GCM", "ext": true,
// "k": "seeaLzpu8dHG07bO2ANH2GywbTqs_zrs4Vq8zmtYeE4",
// "key_ops": ["encrypt", "decrypt"], "kty": "oct"},
// sub_storage: {
// type: "indexeddb",
// database: "test427"
// }
// }
// }
// });
})
.declareMethod('run', function (jio_options) {
......
/*jslint indent: 2, maxlen: 80, sloppy: true, nomen: true */
/*global jIO: true, sjcl: true, $: true, setTimeout: true */
jIO.addStorage('crypt', function (spec, my) {
/*jslint todo: true*/
spec = spec || {};
var that = my.basicStorage(spec, my),
priv = {},
is_valid_storage = (spec.storage ? true : false),
super_serialized = that.serialized;
/*
* Copyright 2015, Nexedi SA
* Released under the LGPL license.
* http://www.gnu.org/licenses/lgpl.html
*/
priv.username = spec.username || '';
priv.password = spec.password || '';
priv.sub_storage_spec = spec.storage || {
type: 'base'
};
priv.sub_storage_string = JSON.stringify(priv.sub_storage_string);
/*jslint nomen: true*/
/*global jIO, RSVP, DOMException, Blob, crypto, Uint8Array, ArrayBuffer*/
that.serialized = function () {
var o = super_serialized();
o.username = priv.username;
o.password = priv.password; // TODO : unsecured !!!
o.storage = priv.sub_storage_string;
return o;
};
(function (jIO, RSVP, DOMException, Blob, crypto, Uint8Array, ArrayBuffer) {
"use strict";
that.validateState = function () {
if (priv.username && is_valid_storage) {
return '';
}
return 'Need at least two parameters: "username" and "storage".';
};
// TODO : IT IS NOT SECURE AT ALL!
// WE MUST REWORK CRYPTED STORAGE!
priv.encrypt_param_object = {
"iv": "kaprWwY/Ucr7pumXoTHbpA",
"v": 1,
"iter": 1000,
"ks": 256,
"ts": 128,
"mode": "ccm",
"adata": "",
"cipher": "aes",
"salt": "K4bmZG9d704"
// you the cryptography system used by this storage is AES-GCM.
// here is an example of how to generate a key to the json format.
// var key,
// jsonKey;
// crypto.subtle.generateKey({name: "AES-GCM",length: 256},
// (true), ["encrypt", "decrypt"])
// .then(function(res){key = res;});
//
// window.crypto.subtle.exportKey("jwk", key)
// .then(function(res){jsonKey = val})
//
//var storage = jIO.createJIO({type: "crypt", key: jsonKey,
// sub_storage: {...}});
// find more informations about this cryptography system on
// https://github.com/diafygi/webcrypto-examples#aes-gcm
/**
* The JIO Cryptography Storage extension
*
* @class CryptStorage
* @constructor
*/
var MIME_TYPE = "application/x-jio-aes-gcm-encryption";
function CryptStorage(spec) {
this._key = spec.key;
this._jsonKey = true;
this._sub_storage = jIO.createJIO(spec.sub_storage);
}
function convertKey(that) {
return new RSVP.Queue()
.push(function () {
return crypto.subtle.importKey("jwk", that._key,
"AES-GCM", false,
["encrypt", "decrypt"]);
})
.push(function (res) {
that._key = res;
that._jsonKey = false;
return;
}, function () {
throw new TypeError(
"'key' must be a CryptoKey to JSON Web Key format"
);
});
}
CryptStorage.prototype.get = function () {
return this._sub_storage.get.apply(this._sub_storage,
arguments);
};
priv.decrypt_param_object = {
"iv": "kaprWwY/Ucr7pumXoTHbpA",
"ks": 256,
"ts": 128,
"salt": "K4bmZG9d704"
CryptStorage.prototype.post = function () {
return this._sub_storage.post.apply(this._sub_storage,
arguments);
};
priv.encrypt = function (data, callback) {
// end with a callback in order to improve encrypt to an
// asynchronous encryption.
var tmp = sjcl.encrypt(priv.username + ':' + priv.password, data,
priv.encrypt_param_object);
callback(JSON.parse(tmp).ct);
CryptStorage.prototype.put = function () {
return this._sub_storage.put.apply(this._sub_storage,
arguments);
};
priv.decrypt = function (data, callback) {
var tmp, param = $.extend(true, {}, priv.decrypt_param_object);
param.ct = data || '';
param = JSON.stringify(param);
try {
tmp = sjcl.decrypt(priv.username + ':' + priv.password, param);
} catch (e) {
callback({
status: 403,
statusText: 'Forbidden',
error: 'forbidden',
message: 'Unable to decrypt.',
reason: 'unable to decrypt'
});
return;
}
callback(undefined, tmp);
CryptStorage.prototype.remove = function () {
return this._sub_storage.remove.apply(this._sub_storage,
arguments);
};
priv.newAsyncModule = function () {
var async = {};
async.call = function (obj, function_name, arglist) {
obj._wait = obj._wait || {};
if (obj._wait[function_name]) {
obj._wait[function_name] -= 1;
return function () {
return;
};
}
// ok if undef or 0
arglist = arglist || [];
setTimeout(function () {
obj[function_name].apply(obj[function_name], arglist);
});
};
async.neverCall = function (obj, function_name) {
obj._wait = obj._wait || {};
obj._wait[function_name] = -1;
};
async.wait = function (obj, function_name, times) {
obj._wait = obj._wait || {};
obj._wait[function_name] = times;
};
async.end = function () {
async.call = function () {
return;
};
};
return async;
CryptStorage.prototype.hasCapacity = function () {
return this._sub_storage.hasCapacity.apply(this._sub_storage,
arguments);
};
that.post = function (command) {
that.put(command);
CryptStorage.prototype.buildQuery = function () {
return this._sub_storage.buildQuery.apply(this._sub_storage,
arguments);
};
/**
* Saves a document.
* @method put
*/
that.put = function (command) {
var new_file_name, new_file_content, am = priv.newAsyncModule(),
o = {};
o.encryptFilePath = function () {
priv.encrypt(command.getDocId(), function (res) {
new_file_name = res;
am.call(o, 'save');
});
};
o.encryptFileContent = function () {
priv.encrypt(command.getDocContent(), function (res) {
new_file_content = res;
am.call(o, 'save');
});
};
o.save = function () {
var success = function (val) {
val.id = command.getDocId();
that.success(val);
},
error = function (err) {
that.error(err);
},
cloned_doc = command.cloneDoc();
cloned_doc._id = new_file_name;
cloned_doc.content = new_file_content;
that.addJob('put', priv.sub_storage_spec, cloned_doc,
command.cloneOption(), success, error);
};
am.wait(o, 'save', 1);
am.call(o, 'encryptFilePath');
am.call(o, 'encryptFileContent');
}; // end put
CryptStorage.prototype.putAttachment = function (id, name, blob) {
var initializaton_vector = crypto.getRandomValues(new Uint8Array(12)),
that = this;
/**
* Loads a document.
* @method get
*/
that.get = function (command) {
var new_file_name, am = priv.newAsyncModule(),
o = {};
o.encryptFilePath = function () {
priv.encrypt(command.getDocId(), function (res) {
new_file_name = res;
am.call(o, 'get');
return new RSVP.Queue()
.push(function () {
if (that._jsonKey === true) {
return convertKey(that);
}
return;
})
.push(function () {
return jIO.util.readBlobAsDataURL(blob);
})
.push(function (dataURL) {
//string->arraybuffer
var strLen = dataURL.currentTarget.result.length,
buf = new ArrayBuffer(strLen),
bufView = new Uint8Array(buf),
i;
dataURL = dataURL.currentTarget.result;
for (i = 0; i < strLen; i += 1) {
bufView[i] = dataURL.charCodeAt(i);
}
return crypto.subtle.encrypt({
name : "AES-GCM",
iv : initializaton_vector
},
that._key, buf);
})
.push(function (coded) {
var blob = new Blob([initializaton_vector, coded], {type: MIME_TYPE});
return that._sub_storage.putAttachment(id, name, blob);
});
};
o.get = function () {
that.addJob('get', priv.sub_storage_spec, new_file_name,
command.cloneOption(), o.success, o.error);
};
o.success = function (val) {
val._id = command.getDocId();
if (command.getOption('metadata_only')) {
that.success(val);
} else {
priv.decrypt(val.content, function (err, res) {
if (err) {
that.error(err);
} else {
val.content = res;
that.success(val);
}
});
}
};
o.error = function (error) {
that.error(error);
};
am.call(o, 'encryptFilePath');
}; // end get
};
/**
* Gets a document list.
* @method allDocs
*/
that.allDocs = function (command) {
var result_array = [],
am = priv.newAsyncModule(),
o = {};
o.allDocs = function () {
that.addJob('allDocs', priv.sub_storage_spec, null,
command.cloneOption(), o.onSuccess, o.error);
};
o.onSuccess = function (val) {
if (val.total_rows === 0) {
return am.call(o, 'success');
}
result_array = val.rows;
var i, decrypt = function (c) {
priv.decrypt(result_array[c].id, function (err, res) {
if (err) {
am.call(o, 'error', [err]);
} else {
result_array[c].id = res;
result_array[c].key = res;
am.call(o, 'success');
}
});
if (!command.getOption('metadata_only')) {
priv.decrypt(
result_array[c].value.content,
CryptStorage.prototype.getAttachment = function (id, name) {
var that = this;
function (err, res) {
if (err) {
am.call(o, 'error', [err]);
} else {
result_array[c].value.content = res;
am.call(o, 'success');
return that._sub_storage.getAttachment(id, name)
.push(function (blob) {
if (blob.type !== MIME_TYPE) {
return blob;
}
return new RSVP.Queue()
.push(function () {
if (that._jsonKey === true) {
return convertKey(that);
}
return;
})
.push(function () {
return jIO.util.readBlobAsArrayBuffer(blob);
})
.push(function (coded) {
var initializaton_vector;
coded = coded.currentTarget.result;
initializaton_vector = new Uint8Array(coded.slice(0, 12));
return crypto.subtle.decrypt({
name : "AES-GCM",
iv : initializaton_vector
},
that._key, coded.slice(12));
})
.push(function (arr) {
//arraybuffer->string
arr = String.fromCharCode.apply(null, new Uint8Array(arr));
try {
return jIO.util.dataURItoBlob(arr);
} catch (error) {
if (error instanceof DOMException) {
return blob;
}
throw error;
}
);
}
};
if (command.getOption('metadata_only')) {
am.wait(o, 'success', val.total_rows - 1);
} else {
am.wait(o, 'success', val.total_rows * 2 - 1);
}
for (i = 0; i < result_array.length; i += 1) {
decrypt(i);
}
};
o.error = function (error) {
am.end();
that.error(error);
};
o.success = function () {
am.end();
that.success({
total_rows: result_array.length,
rows: result_array
}, function () { return blob; });
});
};
am.call(o, 'allDocs');
}; // end allDocs
};
/**
* Removes a document.
* @method remove
*/
that.remove = function (command) {
var new_file_name, o = {};
o.encryptDocId = function () {
priv.encrypt(command.getDocId(), function (res) {
new_file_name = res;
o.removeDocument();
});
};
o.removeDocument = function () {
var cloned_doc = command.cloneDoc();
cloned_doc._id = new_file_name;
that.addJob('remove', priv.sub_storage_spec, cloned_doc,
command.cloneOption(), o.success, that.error);
};
o.success = function (val) {
val.id = command.getDocId();
that.success(val);
};
o.encryptDocId();
}; // end remove
CryptStorage.prototype.removeAttachment = function () {
return this._sub_storage.removeAttachment.apply(this._sub_storage,
arguments);
};
CryptStorage.prototype.allAttachments = function () {
return this._sub_storage.allAttachments.apply(this._sub_storage,
arguments);
};
jIO.addStorage('crypt', CryptStorage);
return that;
});
}(jIO, RSVP, DOMException, Blob, crypto, Uint8Array, ArrayBuffer));
/*jslint nomen: true*/
/*global Blob, crypto, Uint8Array, ArrayBuffer*/
(function (jIO, QUnit, Blob) {
"use strict";
var test = QUnit.test,
stop = QUnit.stop,
start = QUnit.start,
ok = QUnit.ok,
expect = QUnit.expect,
deepEqual = QUnit.deepEqual,
equal = QUnit.equal,
throws = QUnit.throws,
module = QUnit.module,
key = {"alg": "A256GCM", "ext": true,
"k": "seeaLzpu8dHG07bO2ANH2GywbTqs_zrs4Vq8zmtYeE4",
"key_ops": ["encrypt", "decrypt"], "kty": "oct"};
/////////////////////////////////////////////////////////////////
// Custom test substorage definition
/////////////////////////////////////////////////////////////////
function Storage200() {
return this;
}
jIO.addStorage('cryptstorage200', Storage200);
/////////////////////////////////////////////////////////////////
// CryptStorage.constructor
/////////////////////////////////////////////////////////////////
module("CryptStorage.constructor");
test("create substorage", function () {
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
equal(jio.__type, "crypt");
equal(jio.__storage._sub_storage.__type, "cryptstorage200");
});
/////////////////////////////////////////////////////////////////
// CryptStorage.get
/////////////////////////////////////////////////////////////////
module("CryptStorage.get");
test("get called substorage get", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.get = function (id) {
equal(id, "bar", "get 200 called");
return {title: "foo"};
};
jio.get("bar")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.post
/////////////////////////////////////////////////////////////////
module("CryptStorage.post");
test("post called substorage post", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.post = function (id) {
equal(id, "bar", "post 200 called");
return {title: "foo"};
};
jio.post("bar")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.put
/////////////////////////////////////////////////////////////////
module("CryptStorage.put");
test("put called substorage put", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.put = function (id) {
equal(id, "bar", "put 200 called");
return {title: "foo"};
};
jio.put("bar")
.then(function (result) {
equal(result, "bar");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.remove
/////////////////////////////////////////////////////////////////
module("CryptStorage.remove");
test("remove called substorage remove", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.remove = function (id) {
equal(id, "bar", "remove 200 called");
return {title: "foo"};
};
jio.remove("bar")
.then(function (result) {
equal(result, "bar");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.hasCapacity
/////////////////////////////////////////////////////////////////
module("CryptStorage.hasCapacity");
test("hasCapacity return substorage value", function () {
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
delete Storage200.prototype.hasCapacity;
throws(
function () {
jio.hasCapacity("foo");
},
function (error) {
ok(error instanceof jIO.util.jIOError);
equal(error.status_code, 501);
equal(error.message,
"Capacity 'foo' is not implemented on 'cryptstorage200'");
return true;
}
);
});
/////////////////////////////////////////////////////////////////
// CryptStorage.buildQuery
/////////////////////////////////////////////////////////////////
module("CryptStorage.buildQuery");
test("buildQuery called substorage buildQuery", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.buildQuery = function (id) {
equal(id, "bar", "buildQuery 200 called");
return {title: "foo"};
};
jio.buildQuery("bar")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.removeAttachment
/////////////////////////////////////////////////////////////////
module("CryptStorage.removeAttachment");
test("removeAttachment called substorage removeAttachment", function () {
stop();
expect(3);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.removeAttachment = function (id, name) {
equal(id, "bar", "removeAttachment 200 called");
equal(name, "foo", "removeAttachment 200 called");
return {title: "foo"};
};
jio.removeAttachment("bar", "foo")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.allAttachments
/////////////////////////////////////////////////////////////////
module("CryptStorage.allAttachments");
test("allAttachments called substorage allAttachments", function () {
stop();
expect(2);
var jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
Storage200.prototype.allAttachments = function (id) {
equal(id, "bar", "allAttachments 200 called");
return {title: "foo"};
};
jio.allAttachments("bar")
.then(function (result) {
deepEqual(result, {
"title": "foo"
}, "Check document");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.getAttachment
/////////////////////////////////////////////////////////////////
module("CryptStorage.getAttachment", {
setup: function () {
this.jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
}
});
test("return substorage getattachment", function () {
var id = "/",
attachment = "stringattachment",
blob = new Blob(['foo']);
Storage200.prototype.getAttachment = function (arg1, arg2) {
equal(arg1, id, "getAttachment 200 called");
equal(arg2, attachment, "getAttachment 200 called");
return blob;
};
stop();
expect(3);
this.jio.getAttachment(id, attachment)
.then(function (result) {
equal(result, blob, "Return substorage result");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("return substorage getattachment if decrypt fails", function () {
var id = "/",
attachment = "stringattachment",
blob = new Blob(['foo'], {type: 'application/x-jio-aes-gcm-encryption'});
Storage200.prototype.getAttachment = function (arg1, arg2) {
equal(arg1, id, "getAttachment 200 called");
equal(arg2, attachment, "getAttachment 200 called");
return blob;
};
stop();
expect(3);
this.jio.getAttachment(id, attachment)
.then(function (result) {
equal(result, blob, "Return substorage result");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("return substorage getattachment if not data url", function () {
var id = "/",
attachment = "stringattachment",
blob = new Blob(['foo'],
{type: 'application/x-jio-aes-gcm-encryption'});
Storage200.prototype.getAttachment = function (arg1, arg2) {
equal(arg1, id, "getAttachment 200 called");
equal(arg2, attachment, "getAttachment 200 called");
return blob;
};
stop();
expect(3);
this.jio.getAttachment(id, attachment)
.then(function (result) {
equal(result, blob, "Return substorage result");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
test("decrypt blob from aes-gcm", function () {
var id = "/",
attachment = "stringattachment",
value = "azertyuio\npàç_è-('é&",
tocheck = "data:application/x-jio-aes-gcm-encryption;base64" +
",+p/Ho+KgGHZC2zDLMbQQS2tXcsy0g+Ho41VZnlPEkXdmG9zm36c8iLCkv" +
"lanyWCN510NK4hj1EgWQ6WrLS5pCmA/yeAWh+HyfPkYKDRHVBl6+Hxd53I" +
"TmiWQ6Vix2jaIQg==",
blob = jIO.util.dataURItoBlob(tocheck);
Storage200.prototype.getAttachment = function (arg1, arg2) {
equal(arg1, id, "getAttachment 200 called");
equal(arg2, attachment, "getAttachment 200 called");
return blob;
};
stop();
expect(6);
this.jio.getAttachment(id, attachment)
.then(function (result) {
ok(result !== blob, "Does not return substorage result");
ok(result instanceof Blob, "Data is Blob");
deepEqual(result.type, "text/plain;charset=utf-8",
"Check mimetype");
return jIO.util.readBlobAsText(result);
})
.then(function (result) {
equal(result.target.result, value, "Attachment correctly fetched");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
/////////////////////////////////////////////////////////////////
// CryptStorage.putAttachment
/////////////////////////////////////////////////////////////////
module("CryptStorage.putAttachment", {
setup: function () {
this.jio = jIO.createJIO({
type: "crypt",
key: key,
sub_storage: {type : "cryptstorage200"}
});
}
});
function decodeAES(blob) {
var decryptKey;
return new RSVP.Queue()
.push(function () {
return crypto.subtle.importKey("jwk", key,
"AES-GCM", false, ["decrypt"]);
})
.push(function (res) {
decryptKey = res;
return;
})
.push(function () {
return jIO.util.readBlobAsArrayBuffer(blob);
})
.push(function (coded) {
var iv;
coded = coded.currentTarget.result;
iv = new Uint8Array(coded.slice(0, 12));
return crypto.subtle.decrypt({name : "AES-GCM", iv : iv},
decryptKey, coded.slice(12));
})
.push(function (arr) {
arr = String.fromCharCode.apply(null, new Uint8Array(arr));
equal(
arr,
"data:text/foo;base64,YXplcnR5dWlvCnDDoMOnX8OoLSgnw6km",
"Attachment correctly crypted"
);
return "ok";
});
}
test("crypt blob to aes-gcm", function () {
var id = "/",
attachment = "stringattachment",
value = "azertyuio\npàç_è-('é&",
blob = new Blob([value],
{type: 'text/foo'});
Storage200.prototype.putAttachment = function (arg1, arg2, arg3) {
equal(arg1, id, "putAttachment 200 called");
equal(arg2, attachment, "putAttachment 200 called");
ok(true, arg3 !== blob, "putAttachment 200 called");
ok(arg3 instanceof Blob, "Data is Blob");
equal(arg3.type, "application/x-jio-aes-gcm-encryption",
"Check mimetype");
return decodeAES(arg3);
};
stop();
expect(7);
this.jio.putAttachment(id, attachment, blob)
.then(function (result) {
equal(result, "ok", "Return substorage result");
})
.fail(function (error) {
ok(false, error);
})
.always(function () {
start();
});
});
}(jIO, QUnit, Blob));
......@@ -42,6 +42,7 @@
<script src="jio.storage/shastorage.tests.js"></script>
<!--script src="jio.storage/indexstorage.tests.js"></script-->
<script src="jio.storage/cryptstorage.tests.js"></script>
<script src="jio.storage/dropboxstorage.tests.js"></script>
<script src="jio.storage/zipstorage.tests.js"></script>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment