├── src ├── constants.ts ├── index.ts ├── UppyDecrypt.ts └── UppyEncrypt.ts ├── .prettierrc ├── .prettierignore ├── .gitignore ├── tsup.config.ts ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const CHUNK_SIZE = 64 * 1024 * 1024; 2 | export const SIGNATURE = 'uppyencrypt'; 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "bracketSpacing": true, 7 | "plugins": [], 8 | "overrides": [], 9 | "printWidth": 160 10 | } 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /package 5 | .env 6 | .env.* 7 | !.env.example 8 | 9 | # Ignore files for PNPM, NPM and YARN 10 | pnpm-lock.yaml 11 | package-lock.json 12 | yarn.lock 13 | static -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | 5 | # Log files 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | pnpm-debug.log* 10 | 11 | # Editor directories and files 12 | .idea 13 | .vscode 14 | *.suo 15 | *.ntvs* 16 | *.njsproj 17 | *.sln 18 | *.sw? 19 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | export default defineConfig({ 4 | entry: ['src/index.ts'], 5 | format: ['cjs', 'esm'], // Build for commonJS and ESmodules 6 | dts: true, // Generate declaration file (.d.ts) 7 | splitting: false, 8 | sourcemap: true, 9 | clean: true, 10 | }); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "ESNext", 4 | "moduleResolution": "Node", 5 | "target": "ES2015", 6 | "sourceMap": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "outDir": "dist", 11 | "removeComments": true, 12 | "declaration": true 13 | }, 14 | "include": ["types/**/*", "src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 0sum Co 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "uppy-encrypt", 3 | "version": "1.0.2", 4 | "description": "Uppy plugin to encrypt and decrypt files in the browser before upload using libsodium-wrappers", 5 | "type": "module", 6 | "main": "./dist/index.cjs", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "require": "./dist/index.cjs", 12 | "import": "./dist/index.js", 13 | "types": "./dist/index.d.ts" 14 | } 15 | }, 16 | "files": [ 17 | "dist" 18 | ], 19 | "scripts": { 20 | "build": "tsup", 21 | "test": "echo \"Error: no test specified\" && exit 1" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/0sumcode/uppy-encrypt.git" 26 | }, 27 | "keywords": [ 28 | "uppy", 29 | "encrypt", 30 | "decrypt", 31 | "libsodium" 32 | ], 33 | "author": "Dan Stevens", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/0sumcode/uppy-encrypt/issues" 37 | }, 38 | "homepage": "https://github.com/0sumcode/uppy-encrypt#readme", 39 | "devDependencies": { 40 | "ts-node": "^10.9.1", 41 | "tsup": "^8.0.1", 42 | "typescript": "^5.3.2" 43 | }, 44 | "dependencies": { 45 | "@types/libsodium-wrappers-sumo": "^0.7.8", 46 | "@uppy/core": "^3.7.1", 47 | "libsodium-wrappers-sumo": "^0.7.13" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import UppyEncrypt from './UppyEncrypt'; 2 | import UppyDecrypt, { type DecryptedMetaData } from './UppyDecrypt'; 3 | import _sodium from 'libsodium-wrappers-sumo'; 4 | import { BasePlugin, type DefaultPluginOptions, Uppy } from '@uppy/core'; 5 | 6 | interface UppyEncryptPluginOptions extends DefaultPluginOptions { 7 | password: string | null; 8 | } 9 | 10 | // Sodium is initialized automatically within UppyEncrypt / UppyDecrypt 11 | // Optionally call this to ensure initialization 12 | let sodiumIsReady = false; 13 | export const uppyEncryptReady = async () => { 14 | if (!sodiumIsReady) { 15 | await _sodium.ready; 16 | sodiumIsReady = true; 17 | } 18 | }; 19 | 20 | export class UppyEncryptPlugin extends BasePlugin { 21 | opts: UppyEncryptPluginOptions; 22 | 23 | constructor(uppy: Uppy, opts?: UppyEncryptPluginOptions | undefined) { 24 | super(uppy, opts); 25 | this.id = opts?.id ?? 'UppyEncryptPlugin'; 26 | this.type = 'modifier'; 27 | 28 | const defaultOptions = { 29 | password: null, 30 | }; 31 | this.opts = { ...defaultOptions, ...opts }; 32 | 33 | this.encryptFiles = this.encryptFiles.bind(this); 34 | } 35 | 36 | async encryptFiles(fileIds: string[]) { 37 | // Generate a password here if none is already set 38 | this.opts.password = this.opts.password || UppyEncrypt.generatePassword(); 39 | 40 | // Add password to meta data so it can be referenced externally 41 | this.uppy.setMeta({ password: this.opts.password }); 42 | 43 | for (const fileId of fileIds) { 44 | const file = this.uppy.getFile(fileId); 45 | const enc = new UppyEncrypt(this.uppy, file, this.opts.password); 46 | if (await enc.encryptFile()) { 47 | this.uppy.emit('preprocess-complete', file); 48 | let blob = await enc.getEncryptedFile(); 49 | this.uppy.setFileState(fileId, { 50 | type: 'application/octet-stream', 51 | data: blob, 52 | size: blob.size, 53 | }); 54 | 55 | this.uppy.setFileMeta(fileId, { 56 | name: `${file.name}.enc`, 57 | type: 'application/octet-stream', 58 | encryption: { 59 | salt: enc.getSalt(), 60 | header: enc.getHeader(), 61 | hash: enc.getPasswordHash(), 62 | meta: enc.getEncryptMetaData(), 63 | }, 64 | }); 65 | } 66 | } 67 | } 68 | 69 | install() { 70 | this.uppy.addPreProcessor(this.encryptFiles); 71 | } 72 | 73 | uninstall() { 74 | this.uppy.removePreProcessor(this.encryptFiles); 75 | } 76 | } 77 | 78 | export { UppyEncrypt, UppyDecrypt, DecryptedMetaData }; 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Uppy Encrypt 2 | 3 | An [Uppy](https://uppy.io/) Plugin to encrypt files via the browser before they're uploaded. Decryption is handled browser-side as well. 4 | 5 | Oh, also, it's fast AF. 🚀 6 | 7 | Uppy Encrypt uses [libsodium.js](https://github.com/jedisct1/libsodium.js) for all the cryptographical magic. 8 | 9 | A live implementation of Uppy Encrypt can be seen on [0up.io](https://0up.io) [[Source Code]](https://github.com/0sumcode/0up) 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm i uppy-encrypt 15 | ``` 16 | 17 | ## Encryption Example 18 | ```javascript 19 | import { Uppy } from '@uppy/core'; 20 | import UppyEncryptPlugin from 'uppy-encrypt'; 21 | 22 | const uppy = new Uppy(); 23 | uppy.use(UppyEncryptPlugin); 24 | 25 | // Optional: Set password manually, or disregard and a random password will be auto-generated 26 | // uppy.setMeta({ password: '$upers3cret!' }); 27 | 28 | uppy.on('complete', async (result) => { 29 | for (const file of result.successful) { 30 | const salt = file.meta.encryption.salt; // Salt value used to increase security 31 | const header = file.meta.encryption.header; // Header encryption data to kick off the decryption process 32 | const hash = file.meta.encryption.hash; // Secure 1-way hash of the password 33 | const meta = file.meta.encryption.meta; // Encrypted file meta data (file name, type) 34 | // ^ These are all safe to store in a database 35 | } 36 | }); 37 | ``` 38 | 39 | ## Decryption Example 40 | ```javascript 41 | import { UppyDecrypt, uppyEncryptReady } from 'uppy-encrypt'; 42 | 43 | // Use the values generated from the encryption process 44 | // Usually, these would be stored/retrieved from a database 45 | const decrypt = async (hash, password, salt, header, meta, encryptedFileUrl) => { 46 | // Ensure required libraries are loaded 47 | await uppyEncryptReady(); 48 | 49 | // Verify provided password against the stored hash value 50 | if (!UppyDecrypt.verifyPassword(hash, password)) { 51 | // Invalid password 52 | return; 53 | } 54 | 55 | // Decrypt Metadata 56 | const decryptor = new UppyDecrypt(password, salt, header); 57 | const decryptedMeta = decryptor.getDecryptedMetaData(meta.header, meta.data); 58 | 59 | // Fetch & Decrypt the encrypted file 60 | const file = await fetch(encryptedFileUrl); 61 | const blob = await file.blob(); 62 | const decrypted = await decryptor.decryptFile(blob); 63 | 64 | // Do something with the decrypted file, like download it 65 | if (decrypted) { 66 | const aElement = document.createElement('a'); 67 | aElement.setAttribute('download', decryptedMeta.name); 68 | const href = URL.createObjectURL(decrypted); 69 | aElement.href = href; 70 | aElement.setAttribute('target', '_blank'); 71 | aElement.click(); 72 | URL.revokeObjectURL(href); 73 | } 74 | } 75 | ``` 76 | -------------------------------------------------------------------------------- /src/UppyDecrypt.ts: -------------------------------------------------------------------------------- 1 | import _sodium from 'libsodium-wrappers-sumo'; 2 | import { CHUNK_SIZE, SIGNATURE } from './constants'; 3 | 4 | export interface DecryptedMetaData { 5 | name: string; 6 | type?: string; 7 | } 8 | 9 | // Init Sodium 10 | let sodium: typeof _sodium; 11 | (async () => { 12 | await _sodium.ready; 13 | sodium = _sodium; 14 | })(); 15 | 16 | export default class UppyDecrypt { 17 | private key: Uint8Array; 18 | private state: _sodium.StateAddress; 19 | private stream: ReadableStream; 20 | private streamController: ReadableStreamDefaultController | undefined; 21 | private contentType: string; 22 | 23 | private index = 0; 24 | 25 | constructor(password: string, salt: string, header: string) { 26 | const saltUint = sodium.from_base64(salt, sodium.base64_variants.URLSAFE_NO_PADDING); 27 | const headerUint = sodium.from_base64(header, sodium.base64_variants.URLSAFE_NO_PADDING); 28 | 29 | this.streamController; 30 | this.stream = new ReadableStream({ 31 | start: (controller) => { 32 | this.streamController = controller; 33 | }, 34 | }); 35 | this.contentType = ''; // Defined if/when meta-data is decrypted 36 | 37 | this.key = sodium.crypto_pwhash( 38 | sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES, 39 | password, 40 | saltUint, 41 | sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, 42 | sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, 43 | sodium.crypto_pwhash_ALG_ARGON2ID13 44 | ); 45 | 46 | this.state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(headerUint, this.key); 47 | 48 | this.index = SIGNATURE.length + saltUint.length + headerUint.length; 49 | } 50 | 51 | /** 52 | * Validates that the provided password is correct 53 | * @param hash The hash value of the password created during UppyEncrypt 54 | * @param password The user-provided password 55 | * @returns {bool} true if correct password 56 | */ 57 | static verifyPassword(hash: string, password: string) { 58 | return sodium.crypto_pwhash_str_verify(hash, password); 59 | } 60 | 61 | /** 62 | * Decrypts the provided file 63 | * @param file Blob of encryptyed file 64 | * @returns Decrypted file as a blob 65 | */ 66 | async decryptFile(file: Blob) { 67 | if (!this.streamController) { 68 | throw new Error('Encryption stream does not exist'); 69 | } 70 | 71 | while (this.index < file.size) { 72 | const chunk = await file.slice(this.index, this.index + CHUNK_SIZE + sodium.crypto_secretstream_xchacha20poly1305_ABYTES).arrayBuffer(); 73 | const decryptedChunk = sodium.crypto_secretstream_xchacha20poly1305_pull(this.state, new Uint8Array(chunk)); 74 | 75 | this.streamController.enqueue(decryptedChunk.message); 76 | 77 | this.index += CHUNK_SIZE + sodium.crypto_secretstream_xchacha20poly1305_ABYTES; 78 | } 79 | 80 | this.streamController.close(); 81 | 82 | const response = new Response(this.stream, { headers: { 'Content-Type': this.contentType } }); 83 | return response.blob(); 84 | } 85 | 86 | /** 87 | * 88 | * @param header Header created during encryption of the meta data 89 | * @param meta Encrypted meta data string 90 | * @returns object of the decrypted meta data 91 | */ 92 | getDecryptedMetaData(header: string, meta: string) { 93 | // Init fresh state 94 | const state = sodium.crypto_secretstream_xchacha20poly1305_init_pull(sodium.from_base64(header, sodium.base64_variants.URLSAFE_NO_PADDING), this.key); 95 | const decryptedChunk = sodium.crypto_secretstream_xchacha20poly1305_pull(state, sodium.from_base64(meta, sodium.base64_variants.URLSAFE_NO_PADDING)); 96 | 97 | if (!decryptedChunk) throw new Error('Unable to decrypt meta data'); 98 | const decryptedMeta = JSON.parse(new TextDecoder().decode(decryptedChunk.message)) as DecryptedMetaData; 99 | if (decryptedMeta.type) this.contentType = decryptedMeta.type; 100 | return decryptedMeta; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/UppyEncrypt.ts: -------------------------------------------------------------------------------- 1 | import type { Uppy, UppyFile } from '@uppy/core'; 2 | import _sodium from 'libsodium-wrappers-sumo'; 3 | import { CHUNK_SIZE, SIGNATURE } from './constants'; 4 | 5 | // Init Sodium 6 | let sodium: typeof _sodium; 7 | (async () => { 8 | await _sodium.ready; 9 | sodium = _sodium; 10 | })(); 11 | 12 | export default class UppyEncrypt { 13 | private uppy: Uppy; 14 | private password: string; 15 | private salt: Uint8Array; 16 | private key: Uint8Array; 17 | private state: _sodium.StateAddress; 18 | private header: Uint8Array; 19 | private file: UppyFile, Record>; 20 | private stream: ReadableStream; 21 | private streamController: ReadableStreamDefaultController | undefined; 22 | private streamCanceled = false; 23 | 24 | private index = 0; 25 | 26 | constructor(uppy: Uppy, file: UppyFile, Record>, password: string) { 27 | this.uppy = uppy; 28 | this.file = file; 29 | this.password = password; 30 | 31 | // Set Uppy event handlers that effect the encryption process 32 | uppy.on('cancel-all', () => { 33 | this.streamCanceled = true; 34 | }); 35 | 36 | this.streamController; 37 | this.stream = new ReadableStream({ 38 | start: (controller) => { 39 | this.streamController = controller; 40 | }, 41 | }); 42 | 43 | this.salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES); 44 | this.key = sodium.crypto_pwhash( 45 | sodium.crypto_secretstream_xchacha20poly1305_KEYBYTES, 46 | password, 47 | this.salt, 48 | sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, 49 | sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE, 50 | sodium.crypto_pwhash_ALG_ARGON2ID13 51 | ); 52 | 53 | const res = sodium.crypto_secretstream_xchacha20poly1305_init_push(this.key); 54 | this.state = res.state; 55 | this.header = res.header; 56 | } 57 | 58 | /** 59 | * Helper function that generate a random password 60 | */ 61 | static generatePassword() { 62 | return sodium.to_base64(sodium.randombytes_buf(16), sodium.base64_variants.URLSAFE_NO_PADDING); 63 | } 64 | 65 | /** 66 | * Encrypts the file 67 | */ 68 | async encryptFile() { 69 | if (!this.streamController) { 70 | throw new Error('Encryption stream does not exist'); 71 | } 72 | 73 | while (this.index < this.file.size) { 74 | if (this.streamCanceled) { 75 | await this.stream.cancel(); 76 | return false; 77 | } 78 | 79 | // If first chunk 80 | if (this.index === 0) { 81 | this.streamController.enqueue(new Uint8Array(new TextEncoder().encode(SIGNATURE))); 82 | this.streamController.enqueue(this.salt); 83 | this.streamController.enqueue(this.header); 84 | } 85 | 86 | const tag = 87 | this.index + CHUNK_SIZE < this.file.size 88 | ? sodium.crypto_secretstream_xchacha20poly1305_TAG_MESSAGE 89 | : sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL; 90 | 91 | const chunk = await this.file.data.slice(this.index, this.index + CHUNK_SIZE).arrayBuffer(); 92 | const encryptedChunk = sodium.crypto_secretstream_xchacha20poly1305_push(this.state, new Uint8Array(chunk), null, tag); 93 | 94 | this.streamController.enqueue(new Uint8Array(encryptedChunk)); 95 | 96 | this.uppy.emit('preprocess-progress', this.file, { 97 | mode: 'determinate', 98 | message: `Encrypting ${this.file.name}...`, 99 | value: this.index / this.file.size, 100 | }); 101 | 102 | this.index += CHUNK_SIZE; 103 | } 104 | 105 | this.uppy.emit('preprocess-progress', this.file, { 106 | mode: 'determinate', 107 | message: `Encrypting ${this.file.name}...`, 108 | value: 1, 109 | }); 110 | 111 | this.streamController.close(); 112 | 113 | return true; 114 | } 115 | 116 | /** 117 | * Creates and returns a Blob of the encrypted file 118 | */ 119 | async getEncryptedFile() { 120 | const response = new Response(this.stream); 121 | return response.blob(); 122 | } 123 | 124 | /** 125 | * Returns an encrypted representation of the file's metadata (name, content-type) 126 | * header: base64-encoded header data 127 | * meta: Encrypted JSON string of the file's metadata, base64-encoded 128 | */ 129 | getEncryptMetaData() { 130 | // Init fresh state 131 | const res = sodium.crypto_secretstream_xchacha20poly1305_init_push(this.key); 132 | 133 | const metaJson = JSON.stringify({ name: this.file.meta.name, type: this.file.meta.type || null }); 134 | const encryptedChunk = sodium.crypto_secretstream_xchacha20poly1305_push( 135 | res.state, 136 | new Uint8Array(new TextEncoder().encode(metaJson)), 137 | null, 138 | sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL 139 | ); 140 | 141 | return { 142 | header: sodium.to_base64(res.header, sodium.base64_variants.URLSAFE_NO_PADDING), 143 | data: sodium.to_base64(encryptedChunk, sodium.base64_variants.URLSAFE_NO_PADDING), 144 | }; 145 | } 146 | 147 | /** 148 | * Returns a hash of the password base64-encoded 149 | * This data is safe to store in a database, etc 150 | */ 151 | getPasswordHash() { 152 | return sodium.crypto_pwhash_str(this.password, sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE, sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE); 153 | } 154 | 155 | /** 156 | * Returns the header base64-encoded 157 | * This data is safe to store in a database, etc 158 | */ 159 | getHeader() { 160 | return sodium.to_base64(this.header, sodium.base64_variants.URLSAFE_NO_PADDING); 161 | } 162 | 163 | /** 164 | * Returns the salt base64-encoded 165 | * This data is safe to store in a database, etc 166 | */ 167 | getSalt() { 168 | return sodium.to_base64(this.salt, sodium.base64_variants.URLSAFE_NO_PADDING); 169 | } 170 | } 171 | --------------------------------------------------------------------------------