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