Source-Tag: v1.0.3 Manifest-SHA256: b7b09bf48f9e092ed8ad82f99c0319d6c3837d0a1549bd0cc4a374276c0f3896
327 lines
14 KiB
JavaScript
327 lines
14 KiB
JavaScript
(function(root, factory) {
|
|
'use strict';
|
|
var api = factory();
|
|
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
else root.NeoDropCrypto = api;
|
|
}(typeof self !== 'undefined' ? self : this, function() {
|
|
'use strict';
|
|
|
|
var CHUNK_SIZE = 4 * 1024 * 1024;
|
|
var SMALL_MAX = 50 * 1024 * 1024;
|
|
var LARGE_MAX = 1024 * 1024 * 1024;
|
|
var encoder = new TextEncoder();
|
|
var SHA256_K = new Uint32Array([
|
|
0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
|
|
0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
|
|
0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
|
|
0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
|
|
0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
|
|
0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
|
|
0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
|
|
0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
|
|
]);
|
|
|
|
function rotate(value, bits) {
|
|
return (value >>> bits) | (value << (32 - bits));
|
|
}
|
|
|
|
function Sha256() {
|
|
this.state = new Uint32Array([
|
|
0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
|
|
0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19
|
|
]);
|
|
this.buffer = new Uint8Array(64);
|
|
this.bufferLength = 0;
|
|
this.bytes = 0;
|
|
}
|
|
|
|
Sha256.prototype.transform = function(bytes, offset) {
|
|
var words = new Uint32Array(64);
|
|
var view = new DataView(bytes.buffer, bytes.byteOffset + offset, 64);
|
|
var i;
|
|
for (i = 0; i < 16; i++) words[i] = view.getUint32(i * 4, false);
|
|
for (i = 16; i < 64; i++) {
|
|
var x = words[i - 15];
|
|
var y = words[i - 2];
|
|
var s0 = rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3);
|
|
var s1 = rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10);
|
|
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0;
|
|
}
|
|
var a = this.state[0], b = this.state[1], c = this.state[2], d = this.state[3];
|
|
var e = this.state[4], f = this.state[5], g = this.state[6], h = this.state[7];
|
|
for (i = 0; i < 64; i++) {
|
|
var sum1 = rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25);
|
|
var choice = (e & f) ^ (~e & g);
|
|
var temporary1 = (h + sum1 + choice + SHA256_K[i] + words[i]) >>> 0;
|
|
var sum0 = rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22);
|
|
var majority = (a & b) ^ (a & c) ^ (b & c);
|
|
var temporary2 = (sum0 + majority) >>> 0;
|
|
h = g; g = f; f = e; e = (d + temporary1) >>> 0;
|
|
d = c; c = b; b = a; a = (temporary1 + temporary2) >>> 0;
|
|
}
|
|
this.state[0] = (this.state[0] + a) >>> 0;
|
|
this.state[1] = (this.state[1] + b) >>> 0;
|
|
this.state[2] = (this.state[2] + c) >>> 0;
|
|
this.state[3] = (this.state[3] + d) >>> 0;
|
|
this.state[4] = (this.state[4] + e) >>> 0;
|
|
this.state[5] = (this.state[5] + f) >>> 0;
|
|
this.state[6] = (this.state[6] + g) >>> 0;
|
|
this.state[7] = (this.state[7] + h) >>> 0;
|
|
};
|
|
|
|
Sha256.prototype.update = function(bytes) {
|
|
this.bytes += bytes.length;
|
|
var offset = 0;
|
|
if (this.bufferLength) {
|
|
var copied = Math.min(64 - this.bufferLength, bytes.length);
|
|
this.buffer.set(bytes.subarray(0, copied), this.bufferLength);
|
|
this.bufferLength += copied;
|
|
offset = copied;
|
|
if (this.bufferLength === 64) {
|
|
this.transform(this.buffer, 0);
|
|
this.bufferLength = 0;
|
|
}
|
|
}
|
|
while (offset + 64 <= bytes.length) {
|
|
this.transform(bytes, offset);
|
|
offset += 64;
|
|
}
|
|
if (offset < bytes.length) {
|
|
this.buffer.set(bytes.subarray(offset), 0);
|
|
this.bufferLength = bytes.length - offset;
|
|
}
|
|
};
|
|
|
|
Sha256.prototype.hex = function() {
|
|
var high = Math.floor(this.bytes / 0x20000000);
|
|
var low = (this.bytes * 8) >>> 0;
|
|
this.buffer[this.bufferLength++] = 0x80;
|
|
if (this.bufferLength > 56) {
|
|
this.buffer.fill(0, this.bufferLength);
|
|
this.transform(this.buffer, 0);
|
|
this.bufferLength = 0;
|
|
}
|
|
this.buffer.fill(0, this.bufferLength, 56);
|
|
var view = new DataView(this.buffer.buffer);
|
|
view.setUint32(56, high, false);
|
|
view.setUint32(60, low, false);
|
|
this.transform(this.buffer, 0);
|
|
var result = '';
|
|
for (var i = 0; i < this.state.length; i++) {
|
|
result += this.state[i].toString(16).padStart(8, '0');
|
|
}
|
|
return result;
|
|
};
|
|
|
|
function b64url(bytes) {
|
|
var binary = '';
|
|
for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
|
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
|
}
|
|
|
|
function derive(secret, info, length) {
|
|
return crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveBits']).then(function(key) {
|
|
return crypto.subtle.deriveBits({
|
|
name: 'HKDF', hash: 'SHA-256',
|
|
salt: encoder.encode('{{NEODROP_CRYPTO_CONTEXT}}'),
|
|
info: encoder.encode(info)
|
|
}, key, length * 8);
|
|
}).then(function(bits) { return new Uint8Array(bits); });
|
|
}
|
|
|
|
function importAes(raw) {
|
|
return crypto.subtle.importKey('raw', raw, {name: 'AES-GCM'}, false, ['encrypt']);
|
|
}
|
|
|
|
function setU32(bytes, offset, value) {
|
|
new DataView(bytes.buffer).setUint32(offset, value, false);
|
|
}
|
|
|
|
function setU64(bytes, offset, value) {
|
|
new DataView(bytes.buffer).setBigUint64(offset, BigInt(value), false);
|
|
}
|
|
|
|
function makeAad(type, profile, objectId, size, count, index, length) {
|
|
var bytes = new Uint8Array(60);
|
|
bytes.set(encoder.encode('NEODRP01'));
|
|
bytes[8] = type;
|
|
bytes[9] = profile;
|
|
bytes.set(objectId, 12);
|
|
setU64(bytes, 36, size);
|
|
setU32(bytes, 44, CHUNK_SIZE);
|
|
setU32(bytes, 48, count);
|
|
setU32(bytes, 52, index);
|
|
setU32(bytes, 56, length);
|
|
return bytes;
|
|
}
|
|
|
|
function makeIv(type, index) {
|
|
var bytes = new Uint8Array(12);
|
|
setU32(bytes, 0, type);
|
|
setU64(bytes, 4, index);
|
|
return bytes;
|
|
}
|
|
|
|
function encrypt(key, plaintext, nonce, additionalData) {
|
|
return crypto.subtle.encrypt({
|
|
name: 'AES-GCM', iv: nonce, additionalData: additionalData, tagLength: 128
|
|
}, key, plaintext);
|
|
}
|
|
|
|
function typeFor(type, name) {
|
|
var value = String(type || '').toLowerCase();
|
|
if (value && value !== 'application/octet-stream') return value;
|
|
var match = String(name || '').toLowerCase().match(/\.([a-z0-9]+)$/);
|
|
var types = {
|
|
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
|
webp: 'image/webp', avif: 'image/avif', flac: 'audio/flac', m4a: 'audio/mp4',
|
|
mp3: 'audio/mpeg', oga: 'audio/ogg', ogg: 'audio/ogg', opus: 'audio/opus',
|
|
wav: 'audio/wav', wave: 'audio/wav', m4v: 'video/mp4', mkv: 'video/x-matroska',
|
|
mov: 'video/quicktime', mp4: 'video/mp4', ogv: 'video/ogg', webm: 'video/webm'
|
|
};
|
|
return match && types[match[1]] ? types[match[1]] : 'application/octet-stream';
|
|
}
|
|
|
|
function kindFor(type) {
|
|
if (/^image\/(png|jpeg|gif|webp|avif)$/i.test(type)) return 'image';
|
|
if (/^audio\//i.test(type)) return 'audio';
|
|
if (/^video\//i.test(type)) return 'video';
|
|
if (/^text\//i.test(type) || /^(application\/(json|javascript|x-.*script))$/i.test(type)) return 'text';
|
|
return 'binary';
|
|
}
|
|
|
|
function safeName(name) {
|
|
var value = String(name || 'drop.bin').split(/[\\/]/).pop()
|
|
.replace(/[\u0000-\u001f\u007f]/g, '').replace(/^\.+|\.+$/g, '').slice(0, 240);
|
|
return value || 'drop.bin';
|
|
}
|
|
|
|
function request(base, path, options, jsonResponse) {
|
|
return fetch(base + path, options).then(function(response) {
|
|
if (!response.ok) throw new Error('NeoDrop request failed');
|
|
return jsonResponse ? response.json() : response;
|
|
}).catch(function(error) {
|
|
if (error && error.name === 'AbortError') throw error;
|
|
throw new Error('NeoDrop request failed');
|
|
});
|
|
}
|
|
|
|
async function upload(file, options) {
|
|
options = options || {};
|
|
if (!file || typeof file.size !== 'number') throw new Error('Select a file.');
|
|
var profile = Number(options.profile);
|
|
if (profile !== 0 && profile !== 1) throw new Error('Invalid retention profile.');
|
|
var maximum = profile === 0 ? SMALL_MAX : LARGE_MAX;
|
|
if (file.size > maximum) throw new Error('File exceeds the selected profile limit.');
|
|
|
|
var base = String(options.base || '');
|
|
var progress = typeof options.onProgress === 'function' ? options.onProgress : function() {};
|
|
var signal = options.signal;
|
|
var secret = crypto.getRandomValues(new Uint8Array(32));
|
|
var objectId;
|
|
var objectIdText;
|
|
var uploadSession;
|
|
var chunks = file.size ? Math.ceil(file.size / CHUNK_SIZE) : 0;
|
|
var totalSteps = chunks + 3;
|
|
var completed = 0;
|
|
|
|
try {
|
|
var keys = await Promise.all([
|
|
derive(secret, 'object-id', 24),
|
|
derive(secret, 'content-key', 32).then(importAes),
|
|
derive(secret, 'metadata-key', 32).then(importAes)
|
|
]);
|
|
objectId = keys[0];
|
|
objectIdText = b64url(objectId);
|
|
uploadSession = await request(base, '/api/v1/uploads', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({
|
|
v: 1, id: objectIdText, profile: profile, size: file.size,
|
|
chunkSize: CHUNK_SIZE, chunks: chunks
|
|
}),
|
|
signal: signal
|
|
}, true);
|
|
if (!uploadSession || !/^[A-Za-z0-9_-]{22}$/.test(uploadSession.uploadId || '') ||
|
|
!/^[A-Za-z0-9_-]{43}$/.test(uploadSession.uploadToken || '')) {
|
|
throw new Error('NeoDrop request failed');
|
|
}
|
|
progress(++completed / totalSteps);
|
|
|
|
var authorization = 'Bearer ' + uploadSession.uploadToken;
|
|
var hasher = new Sha256();
|
|
for (var index = 0; index < chunks; index++) {
|
|
var plaintext = new Uint8Array(await file.slice(
|
|
index * CHUNK_SIZE, Math.min(file.size, (index + 1) * CHUNK_SIZE)
|
|
).arrayBuffer());
|
|
hasher.update(plaintext);
|
|
var cipher = await encrypt(
|
|
keys[1], plaintext, makeIv(1, index),
|
|
makeAad(1, profile, objectId, file.size, chunks, index, plaintext.byteLength)
|
|
);
|
|
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/chunks/' + index, {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
|
|
body: cipher,
|
|
signal: signal
|
|
}, false);
|
|
progress(++completed / totalSteps);
|
|
}
|
|
|
|
var metadataName = safeName(file.name);
|
|
var metadataValue = {
|
|
v: 1,
|
|
name: metadataName,
|
|
type: typeFor(file.type, metadataName).slice(0, 255),
|
|
size: file.size,
|
|
lastModified: Number.isSafeInteger(file.lastModified) ? file.lastModified : null,
|
|
sha256: hasher.hex()
|
|
};
|
|
var metadata = encoder.encode(JSON.stringify(metadataValue));
|
|
if (metadata.length > 4096) throw new Error('File metadata is too large.');
|
|
var encryptedMetadata = await encrypt(
|
|
keys[2], metadata, makeIv(2, 0),
|
|
makeAad(0, profile, objectId, file.size, chunks, 0xffffffff, metadata.length)
|
|
);
|
|
await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/metadata', {
|
|
method: 'PUT',
|
|
headers: {'Content-Type': 'application/octet-stream', 'Authorization': authorization},
|
|
body: encryptedMetadata,
|
|
signal: signal
|
|
}, false);
|
|
progress(++completed / totalSteps);
|
|
|
|
var committed = await request(base, '/api/v1/uploads/' + uploadSession.uploadId + '/commit', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json', 'Authorization': authorization},
|
|
body: '{}',
|
|
signal: signal
|
|
}, true);
|
|
if (!committed || !Number.isSafeInteger(committed.expiresAt)) {
|
|
throw new Error('NeoDrop request failed');
|
|
}
|
|
progress(1);
|
|
return {
|
|
id: objectIdText,
|
|
readUrl: base + '/d/' + objectIdText + '?k=' + kindFor(metadataValue.type) + '#d1.' + b64url(secret),
|
|
deleteCapability: objectIdText + '.' + uploadSession.uploadToken,
|
|
name: metadataValue.name,
|
|
type: metadataValue.type,
|
|
size: file.size,
|
|
sha256: metadataValue.sha256,
|
|
profile: profile,
|
|
expiresAt: committed.expiresAt
|
|
};
|
|
} finally {
|
|
secret.fill(0);
|
|
}
|
|
}
|
|
|
|
return {
|
|
CHUNK_SIZE: CHUNK_SIZE,
|
|
SMALL_MAX: SMALL_MAX,
|
|
LARGE_MAX: LARGE_MAX,
|
|
upload: upload
|
|
};
|
|
}));
|