├── .gitignore ├── LICENSE ├── README.md ├── assets └── demo.gif ├── babel.config.js ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── App.vue ├── assets │ └── logo.png ├── components │ ├── Browser.vue │ ├── Confirm.vue │ ├── Item.ts │ ├── List.vue │ ├── Move.vue │ ├── Toolbar.vue │ └── Tree.vue ├── cryptomator │ ├── node.ts │ ├── storage-adapter.ts │ ├── storage-adapters │ │ └── s3.ts │ └── vault.ts ├── main.ts ├── plugins │ ├── store │ │ ├── modules │ │ │ └── browser.ts │ │ └── store.ts │ └── vuetify.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts └── shims-vuetify.d.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Marc Boeker 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cryptomator.js 2 | 3 | ⚠️ _This project is in a prototype state. Please use Chrome as some required APIs are not yet 4 | polyfilled for other browsers._ 5 | 6 | Cryptomator.js is a [Cryptomator](https://github.com/cryptomator/cryptomator) implementation in 7 | JavaScript/Typescript with a Vuetify front end. No dedicated back end is needed as the complete 8 | application runs natively in the browser. 9 | 10 | ![demo](https://github.com/marcboeker/cryptomator/raw/master/assets/demo.gif) 11 | 12 | There is currently one storage adapter available, which is AWS S3. But any other storage provider 13 | can be added easily. You can even write a custom storage adapter to connect to your own back end. 14 | 15 | As all cryptographic operations are handled in the browser and only encrypted data is ever sent and 16 | retrieved from the storage provider, no passwords and keys are passed to any third party. 17 | 18 | The project is divided into two components: 19 | 20 | - The native JavaScript/Typescript Cryptomator implementation (located under `src/cryptomator`) 21 | - A front end based on Vue.js and Vuetify (located under `src/`) to browse your vault. 22 | 23 | The Cryptomator.js lib currently contains the following functionalities: 24 | 25 | - Open vault with your password 26 | - Browse vault 27 | - Read/Write files 28 | - Create directories 29 | - Delete files/directories (recursive) 30 | - Move/rename files/directories 31 | 32 | Part of the front end is based on 33 | [vuetify-file-browser](https://github.com/semeniuk/vuetify-file-browser). 34 | 35 | ## Getting started using AWS S3 36 | 37 | 0. Copy an existing vault to a S3 bucket. Make sure, that the vault is in the root of the bucket. 38 | 1. Clone the repository `git clone https://github.com/marcboeker/cryptomator.git`. 39 | 2. Install all dependencies with `yarn install` 40 | 3. Run the development server with `yarn serve` 41 | 4. Open your browser (Chrome) and navigate to [http://localhost:8080](http://localhost:8080). The 42 | latest Chrome browser is needed, as not all required APIs are yet polyfilled for other browsers. 43 | 5. Enter your AWS credentials and S3 bucket that point to your vault. If you store your credentials 44 | in the local storage, please be aware that they are stored as plaintext. 45 | 46 | To compile the app for production, run `yarn build`. 47 | 48 | ## ToDo 49 | 50 | There are still some things to do: 51 | 52 | - [ ] Support for long filenames (> 220 chars) 53 | - [ ] Support for symlinks 54 | - [ ] Different storage backends (Google Storage, Dropbox, ...) 55 | - [ ] Parse vault.cryptomator file before retrieving masterkey.cryptomator 56 | - [ ] Speed up scrypt KDF. 57 | 58 | ## Feedback & Contributions 59 | 60 | If you have feedback or want to contribute, please use GitHub issues and file a pull request. 61 | -------------------------------------------------------------------------------- /assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcboeker/cryptomator/a6e66a150dbb6cfefbc988c5eac45863161100e8/assets/demo.gif -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cryptomator", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "serve": "vue-cli-service serve", 6 | "build": "vue-cli-service build", 7 | "lint": "vue-cli-service lint" 8 | }, 9 | "dependencies": { 10 | "@stablelib/aes": "^1.0.1", 11 | "@stablelib/base64": "^1.0.1", 12 | "@stablelib/siv": "^1.0.1", 13 | "@types/filesystem": "^0.0.32", 14 | "@types/uuid": "^8.3.4", 15 | "aws": "^0.0.3-2", 16 | "aws-sdk": "^2.1060.0", 17 | "base32-encode": "^2.0.0", 18 | "base64-js": "^1.5.1", 19 | "bech32": "^2.0.0", 20 | "core-js": "^3.6.5", 21 | "miscreant": "^0.3.2", 22 | "scrypt-js": "^3.0.1", 23 | "uuid": "^8.3.2", 24 | "vue": "^2.6.11", 25 | "vue-class-component": "^7.2.3", 26 | "vue-property-decorator": "^9.1.2", 27 | "vuetify": "^2.4.0", 28 | "vuex": "^3.6.2", 29 | "vuex-class": "^0.3.2", 30 | "vuex-module-decorators": "^2.0.0" 31 | }, 32 | "devDependencies": { 33 | "@typescript-eslint/eslint-plugin": "^5.10.0", 34 | "@typescript-eslint/parser": "^5.10.0", 35 | "@vue/cli-plugin-babel": "~4.5.0", 36 | "@vue/cli-plugin-eslint": "~4.5.0", 37 | "@vue/cli-plugin-typescript": "~4.5.0", 38 | "@vue/cli-service": "~4.5.0", 39 | "@vue/eslint-config-typescript": "^7.0.0", 40 | "babel-eslint": "^10.1.0", 41 | "eslint": "^7.1.0", 42 | "eslint-plugin-vue": "^6.2.2", 43 | "node-sass": "^7.0.1", 44 | "postcss-loader": "^6.2.1", 45 | "prettier-airbnb-config": "^1.0.0", 46 | "sass": "~1.32.0", 47 | "sass-loader": "^10.1.1", 48 | "ts-loader": "^9.2.6", 49 | "typescript": "~4.1.5", 50 | "vue-cli-plugin-vuetify": "~2.4.5", 51 | "vue-template-compiler": "^2.6.11", 52 | "vuetify-loader": "^1.7.0" 53 | }, 54 | "eslintConfig": { 55 | "root": true, 56 | "env": { 57 | "node": true 58 | }, 59 | "extends": [ 60 | "plugin:vue/essential", 61 | "eslint:recommended", 62 | "@vue/eslint-config-typescript/recommended" 63 | ], 64 | "parserOptions": { 65 | "parser": "@typescript-eslint/parser" 66 | }, 67 | "rules": {} 68 | }, 69 | "browserslist": [ 70 | "> 1%", 71 | "last 2 versions", 72 | "not dead" 73 | ], 74 | "prettier": "prettier-airbnb-config" 75 | } 76 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcboeker/cryptomator/a6e66a150dbb6cfefbc988c5eac45863161100e8/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Cryptomator Browser 9 | 13 | 14 | 15 | 22 |
23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 22 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcboeker/cryptomator/a6e66a150dbb6cfefbc988c5eac45863161100e8/src/assets/logo.png -------------------------------------------------------------------------------- /src/components/Browser.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 177 | -------------------------------------------------------------------------------- /src/components/Confirm.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 66 | -------------------------------------------------------------------------------- /src/components/Item.ts: -------------------------------------------------------------------------------- 1 | import Node from '../cryptomator/node'; 2 | 3 | export enum ItemType { 4 | Directory = 1, 5 | File, 6 | } 7 | 8 | export interface Item { 9 | Id: string; 10 | Type: ItemType; 11 | Name: string; 12 | Extension?: string; 13 | ParentDirId: string; 14 | DirId?: string; 15 | Node?: Node; 16 | Parent: Item | null; 17 | Children: Item[]; 18 | } 19 | -------------------------------------------------------------------------------- /src/components/List.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 189 | 190 | 195 | -------------------------------------------------------------------------------- /src/components/Move.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 111 | -------------------------------------------------------------------------------- /src/components/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 108 | -------------------------------------------------------------------------------- /src/components/Tree.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 65 | 66 | 92 | -------------------------------------------------------------------------------- /src/cryptomator/node.ts: -------------------------------------------------------------------------------- 1 | import {StorageAdapter} from './storage-adapter'; 2 | import Vault from './vault'; 3 | 4 | class Node { 5 | private vault: Vault; 6 | public path: string; 7 | public isDir: boolean; 8 | public parentDirId: string; 9 | public name: string | null; 10 | public filename: string; 11 | 12 | constructor(vault: Vault, path: string, parentDirId: string) { 13 | this.vault = vault; 14 | this.path = path; 15 | this.isDir = this.path.indexOf('/dir.c9r') > -1; 16 | this.parentDirId = parentDirId; 17 | this.filename = this.extractFilename(); 18 | this.name = this.decryptName(); 19 | } 20 | 21 | private decryptName(): string | null { 22 | return this.vault.decryptFilename(this); 23 | } 24 | 25 | private extractFilename(): string { 26 | const chunks = this.path.split(/\//); 27 | let filename!: string; 28 | if (this.isDir) { 29 | filename = chunks.slice(-2, -1)[0]; 30 | } else { 31 | filename = chunks.pop()!; 32 | } 33 | return filename.replace('.c9r', ''); 34 | } 35 | 36 | public async dirId(): Promise { 37 | return await this.vault.dirId(this); 38 | } 39 | 40 | public async decrypt(): Promise { 41 | // let filename = this.path.split(/\//).pop(); 42 | // let contentPath = this.path 43 | // if (filename.length > 220) { 44 | // contentPath = this.path + "/contents.c9r" 45 | // } 46 | return this.vault.decryptFile(this); 47 | } 48 | } 49 | 50 | export default Node; 51 | -------------------------------------------------------------------------------- /src/cryptomator/storage-adapter.ts: -------------------------------------------------------------------------------- 1 | export interface StorageObject { 2 | Key: string; 3 | Size?: number; 4 | LastModified?: Date; 5 | Data?: Uint8Array; 6 | } 7 | 8 | export interface StorageAdapter { 9 | list(prefix: string): Promise; 10 | readFile(key: string): Promise; 11 | readFileAsText(key: string): Promise; 12 | createDirectory(path: string, dirId: string): Promise; 13 | writeFile(path: string, contents: Uint8Array | Blob | string): Promise; 14 | delete(path: string): Promise; 15 | move(oldPath: string, newPath: string): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/cryptomator/storage-adapters/s3.ts: -------------------------------------------------------------------------------- 1 | import {StorageObject, StorageAdapter} from '../storage-adapter'; 2 | import AWS from 'aws-sdk'; 3 | import { 4 | GetObjectRequest, 5 | ListObjectsRequest, 6 | PutObjectRequest, 7 | DeleteObjectRequest, 8 | CopyObjectRequest, 9 | } from 'aws-sdk/clients/s3'; 10 | 11 | export default class S3 implements StorageAdapter { 12 | s3: AWS.S3; 13 | bucket: string; 14 | 15 | constructor(accessKeyId: string, secretAccessKey: string, region: string, bucket: string) { 16 | this.bucket = bucket; 17 | 18 | AWS.config.region = region; 19 | AWS.config.credentials = new AWS.Credentials(accessKeyId, secretAccessKey, undefined); 20 | 21 | this.s3 = new AWS.S3({apiVersion: '2006-03-01'}); 22 | } 23 | 24 | public async list(prefix: string): Promise { 25 | try { 26 | const params: ListObjectsRequest = { 27 | Bucket: this.bucket, 28 | Prefix: prefix, 29 | }; 30 | const result = await this.s3.listObjects(params).promise(); 31 | const objs: StorageObject[] = []; 32 | for (const o of result.Contents!) { 33 | objs.push({Key: o.Key!, Size: o.Size, LastModified: o.LastModified}); 34 | } 35 | return objs; 36 | } catch (error) { 37 | throw new Error('Could not list directory.'); 38 | } 39 | } 40 | 41 | public async readFile(key: string): Promise { 42 | try { 43 | const params: GetObjectRequest = {Bucket: this.bucket, Key: key}; 44 | const res = await this.s3.getObject(params).promise(); 45 | return {Key: key, Data: res.Body}; 46 | } catch (error) { 47 | throw new Error('Could not retrieve object.'); 48 | } 49 | } 50 | 51 | public async readFileAsText(key: string): Promise { 52 | try { 53 | const obj = await this.readFile(key); 54 | return new TextDecoder('utf8').decode(obj.Data!); 55 | } catch (error) { 56 | throw new Error('Could not retrieve object as text.'); 57 | } 58 | } 59 | 60 | public async createDirectory(path: string, dirId: string): Promise { 61 | try { 62 | const params: PutObjectRequest = { 63 | Bucket: this.bucket, 64 | Key: path, 65 | Body: dirId, 66 | }; 67 | await this.s3.putObject(params).promise(); 68 | } catch (error) { 69 | throw new Error('Could not create directory.'); 70 | } 71 | } 72 | 73 | public async writeFile(path: string, contents: Uint8Array | Blob | string): Promise { 74 | try { 75 | const params: PutObjectRequest = { 76 | Bucket: this.bucket, 77 | Key: path, 78 | Body: contents, 79 | }; 80 | await this.s3.putObject(params).promise(); 81 | } catch (error) { 82 | throw new Error('Could not write file.'); 83 | } 84 | } 85 | 86 | public async delete(path: string): Promise { 87 | try { 88 | const params: DeleteObjectRequest = { 89 | Bucket: this.bucket, 90 | Key: path, 91 | }; 92 | const res = await this.s3.deleteObject(params).promise(); 93 | } catch (error) { 94 | throw new Error('Could not delete file.'); 95 | } 96 | } 97 | 98 | public async move(oldPath: string, newPath: string): Promise { 99 | try { 100 | const params: CopyObjectRequest = { 101 | Bucket: this.bucket, 102 | CopySource: `/${this.bucket}/${oldPath}`, 103 | Key: newPath, 104 | }; 105 | await this.s3.copyObject(params).promise(); 106 | await this.delete(oldPath); 107 | } catch (error) { 108 | throw new Error('Could not move file.'); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/cryptomator/vault.ts: -------------------------------------------------------------------------------- 1 | import Node from './node'; 2 | import {StorageAdapter} from './storage-adapter'; 3 | import scrypt from 'scrypt-js'; 4 | import {SIV} from '@stablelib/siv'; 5 | import {AES} from '@stablelib/aes'; 6 | import {v4 as uuidv4} from 'uuid'; 7 | import base32Encode from 'base32-encode'; 8 | import * as base64 from 'base64-js'; 9 | 10 | const CHUNK_SIZE = 32 * 1024; 11 | const HEADER_SIZE = 88; 12 | const CHUNK_HEADER_SIZE = 48; 13 | const FULL_CHUNK_SIZE = CHUNK_SIZE + CHUNK_HEADER_SIZE; 14 | 15 | export default class Vault { 16 | storageAdapter: StorageAdapter; 17 | encryptionKey?: CryptoKey; 18 | macKey?: CryptoKey; 19 | sivKey?: SIV; 20 | 21 | constructor(storageAdapter: StorageAdapter) { 22 | this.storageAdapter = storageAdapter; 23 | } 24 | 25 | public async open(key: string): Promise { 26 | const config = JSON.parse(await this.storageAdapter.readFileAsText('masterkey.cryptomator')); 27 | 28 | const derivedKey = await scrypt.scrypt( 29 | new TextEncoder().encode(key), 30 | base64.toByteArray(config.scryptSalt), 31 | config.scryptCostParam, 32 | config.scryptBlockSize, 33 | 1, 34 | 32 35 | ); 36 | 37 | const unwrappedKey = await window.crypto.subtle.importKey('raw', derivedKey, 'AES-KW', true, [ 38 | 'unwrapKey', 39 | ]); 40 | 41 | this.encryptionKey = await window.crypto.subtle.unwrapKey( 42 | 'raw', 43 | base64.toByteArray(config.primaryMasterKey), 44 | unwrappedKey, 45 | 'AES-KW', 46 | 'AES-CTR', 47 | true, 48 | ['encrypt', 'decrypt'] 49 | ); 50 | 51 | this.macKey = await window.crypto.subtle.unwrapKey( 52 | 'raw', 53 | base64.toByteArray(config.hmacMasterKey), 54 | unwrappedKey, 55 | 'AES-KW', 56 | { 57 | name: 'HMAC', 58 | hash: {name: 'SHA-256'}, 59 | }, 60 | true, 61 | ['sign', 'verify'] 62 | ); 63 | 64 | const exportedEncryptionKey = await crypto.subtle.exportKey('raw', this.encryptionKey); 65 | const exportedMacKey = await crypto.subtle.exportKey('raw', this.macKey); 66 | 67 | const sivKey = new Uint8Array(64); 68 | sivKey.set(new Uint8Array(exportedMacKey), 0); 69 | sivKey.set(new Uint8Array(exportedEncryptionKey), 32); 70 | this.sivKey = new SIV(AES, sivKey); 71 | } 72 | 73 | public async list(dirId: string): Promise { 74 | const path = await this.dirIdPath(dirId); 75 | const nodes = await this.storageAdapter.list(path); 76 | 77 | return nodes.map((n: any) => { 78 | return new Node(this, n.Key, dirId); 79 | }); 80 | } 81 | 82 | public async createDirectory(name: string, parentDirId: string): Promise { 83 | const dirId = uuidv4(); 84 | const path = await this.dirPath(name, parentDirId); 85 | await this.storageAdapter.createDirectory(path, dirId); 86 | return dirId; 87 | } 88 | 89 | public async createFile(name: string, parentDirId: string, file: ArrayBuffer): Promise { 90 | const path = await this.filePath(name, parentDirId); 91 | const payload = await this.encryptFile(new Uint8Array(file)); 92 | await this.storageAdapter.writeFile(path, payload); 93 | } 94 | 95 | public async deleteDirectory(node: Node): Promise { 96 | const dirId = await this.dirId(node); 97 | const nodes = await this.walk(dirId); 98 | for (const n of nodes.reverse()) { 99 | await this.storageAdapter.delete(n.path); 100 | } 101 | await this.storageAdapter.delete(node.path); 102 | } 103 | 104 | public async deleteFile(node: Node): Promise { 105 | await this.storageAdapter.delete(node.path); 106 | } 107 | 108 | public async moveDirectory(node: Node, name: string, parentDirId: string): Promise { 109 | const path = await this.dirPath(name, parentDirId); 110 | await this.storageAdapter.move(node.path, path); 111 | } 112 | 113 | public async moveFile(node: Node, name: string, parentDirId: string): Promise { 114 | const path = await this.filePath(name, parentDirId); 115 | await this.storageAdapter.move(node.path, path); 116 | } 117 | 118 | public decryptFilename(node: Node): string | null { 119 | const fn = this.sivKey!.open( 120 | [new TextEncoder().encode(node.parentDirId)], 121 | base64.toByteArray(node.filename) 122 | ); 123 | if (fn === null) { 124 | return null; 125 | } 126 | return new TextDecoder('utf8').decode(fn!); 127 | } 128 | 129 | public async dirId(node: Node): Promise { 130 | return await this.storageAdapter.readFileAsText(node.path); 131 | } 132 | 133 | private async dirPath(name: string, parentDirId: string): Promise { 134 | return ( 135 | (await this.dirIdPath(parentDirId)) + 136 | '/' + 137 | (await this.encryptFilename(name, parentDirId)) + 138 | '/dir.c9r' 139 | ); 140 | } 141 | 142 | async filePath(name: string, parentDirId: string): Promise { 143 | return ( 144 | (await this.dirIdPath(parentDirId)) + '/' + (await this.encryptFilename(name, parentDirId)) 145 | ); 146 | } 147 | 148 | private async dirIdPath(dirId: string): Promise { 149 | const ciphertext = this.sivKey!.seal([], new TextEncoder().encode(dirId)); 150 | const hash = await crypto.subtle.digest('SHA-1', ciphertext); 151 | const encoded = base32Encode(hash, 'RFC4648', {padding: false}); 152 | return 'd/' + encoded.slice(0, 2) + '/' + encoded.slice(2); 153 | } 154 | 155 | private encryptFilename(filename: string, dirId: string): string | null { 156 | const ciphertext = this.sivKey!.seal( 157 | [new TextEncoder().encode(dirId)], 158 | new TextEncoder().encode(filename) 159 | ); 160 | if (ciphertext === undefined) { 161 | return null; 162 | } 163 | 164 | const encodedPath = base64.fromByteArray(ciphertext).replaceAll('+', '-').replaceAll('/', '_'); 165 | 166 | return encodedPath + '.c9r'; 167 | } 168 | 169 | private async walk(dirId: string, nodes: Node[] = []): Promise { 170 | const path = await this.dirIdPath(dirId); 171 | const currentNodes = await this.storageAdapter.list(path); 172 | 173 | for (const n of currentNodes) { 174 | const node = new Node(this, n.Key, dirId); 175 | nodes.push(node); 176 | if (node.isDir) { 177 | const dirId = await node.dirId(); 178 | await this.walk(dirId, nodes); 179 | } 180 | } 181 | return nodes; 182 | } 183 | 184 | public async decryptFile(node: Node): Promise { 185 | const obj = await this.storageAdapter.readFile(node.path); 186 | const data = obj.Data!; 187 | 188 | const headerNonce = data.slice(0, 16); 189 | const headerPayload = data.slice(16, 56); 190 | const headerMac = data.slice(56, 88); 191 | 192 | // Validate header mac 193 | const isVerified = await window.crypto.subtle.verify( 194 | {name: 'HMAC', hash: 'SHA-256'}, 195 | this.macKey!, 196 | headerMac, 197 | data.slice(0, 56) 198 | ); 199 | 200 | if (!isVerified) { 201 | throw new Error('Could not verify header.'); 202 | } 203 | 204 | const wrapped = await window.crypto.subtle.decrypt( 205 | {name: 'AES-CTR', length: 32, counter: headerNonce}, 206 | this.encryptionKey!, 207 | headerPayload 208 | ); 209 | 210 | const contentKey = await window.crypto.subtle.importKey( 211 | 'raw', 212 | wrapped.slice(8, 40), 213 | 'AES-CTR', 214 | true, 215 | ['decrypt'] 216 | ); 217 | 218 | const len = data.length; 219 | let decryptedSize = len - HEADER_SIZE; 220 | 221 | // Calc result size 222 | const lastChunkSize = decryptedSize % FULL_CHUNK_SIZE; 223 | let chunkCount = 0; 224 | if (lastChunkSize === 0) { 225 | chunkCount = decryptedSize / FULL_CHUNK_SIZE; 226 | } else { 227 | chunkCount = 1 + decryptedSize / FULL_CHUNK_SIZE; 228 | } 229 | 230 | chunkCount = Math.floor(chunkCount); 231 | decryptedSize -= chunkCount * CHUNK_HEADER_SIZE; 232 | const decryptedData = new Uint8Array(decryptedSize); 233 | 234 | for (let i = 0; i < chunkCount; i++) { 235 | const start = HEADER_SIZE + i * FULL_CHUNK_SIZE; 236 | let end = start + FULL_CHUNK_SIZE; 237 | if (end > decryptedSize) { 238 | end = len; 239 | } 240 | 241 | const chunk = data.slice(start, end); 242 | 243 | const chunkNonce = chunk.slice(0, 16); 244 | const chunkData = chunk.slice(16, -32); 245 | const chunkMac = chunk.slice(-32); 246 | 247 | const mac = new Uint8Array(16 + 8 + 16 + chunkData.length); 248 | mac.set(headerNonce); 249 | mac.set(new Uint8Array(this.toBytes(i)), 16); 250 | mac.set(chunkNonce, 24); 251 | mac.set(chunkData, 40); 252 | const isVerified = await window.crypto.subtle.verify( 253 | {name: 'HMAC', hash: 'SHA-256'}, 254 | this.macKey!, 255 | chunkMac, 256 | mac 257 | ); 258 | 259 | if (!isVerified) { 260 | throw new Error('Could not verify chunk.'); 261 | } 262 | 263 | const plaintext = await window.crypto.subtle.decrypt( 264 | {name: 'AES-CTR', length: 32, counter: chunkNonce}, 265 | contentKey, 266 | chunkData 267 | ); 268 | 269 | decryptedData.set(new Uint8Array(plaintext), i * CHUNK_SIZE); 270 | } 271 | 272 | return decryptedData; 273 | } 274 | 275 | public async encryptFile(data: Uint8Array): Promise { 276 | const len = data.length; 277 | const chunkCount = Math.ceil(len / CHUNK_SIZE); 278 | const file = new Uint8Array(HEADER_SIZE + chunkCount * FULL_CHUNK_SIZE); 279 | 280 | const headerNonce = window.crypto.getRandomValues(new Uint8Array(16)); 281 | const headerPayload = new Uint8Array(40); 282 | headerPayload.set(new Uint8Array([255, 255, 255, 255, 255, 255, 255, 255]), 0); 283 | const contentKey = await window.crypto.subtle.generateKey( 284 | { 285 | name: 'AES-CTR', 286 | length: 256, 287 | }, 288 | true, 289 | ['encrypt'] 290 | ); 291 | const exportedContentKey = await crypto.subtle.exportKey('raw', contentKey); 292 | headerPayload.set(new Uint8Array(exportedContentKey), 8); 293 | 294 | const headerCiphertext = await window.crypto.subtle.encrypt( 295 | {name: 'AES-CTR', length: 32, counter: headerNonce}, 296 | this.encryptionKey!, 297 | headerPayload 298 | ); 299 | 300 | const mac = new Uint8Array(56); 301 | mac.set(headerNonce, 0); 302 | mac.set(new Uint8Array(headerCiphertext), 16); 303 | const headerMac = await window.crypto.subtle.sign( 304 | {name: 'HMAC', hash: 'SHA-256'}, 305 | this.macKey!, 306 | mac 307 | ); 308 | 309 | file.set(headerNonce, 0); 310 | file.set(new Uint8Array(headerCiphertext), 16); 311 | file.set(new Uint8Array(headerMac), 56); 312 | 313 | let pos = HEADER_SIZE; 314 | for (let i = 0; i < chunkCount; i++) { 315 | const fileStart = i * CHUNK_SIZE; 316 | const fileEnd = fileStart + CHUNK_SIZE; 317 | const chunkNonce = window.crypto.getRandomValues(new Uint8Array(16)); 318 | const chunkCiphertext = await window.crypto.subtle.encrypt( 319 | {name: 'AES-CTR', length: 32, counter: chunkNonce}, 320 | contentKey, 321 | data.slice(fileStart, fileEnd) 322 | ); 323 | 324 | const mac = new Uint8Array(16 + 8 + 16 + chunkCiphertext.byteLength); 325 | mac.set(headerNonce, 0); 326 | mac.set(new Uint8Array(this.toBytes(i)), 16); 327 | mac.set(chunkNonce, 24); 328 | mac.set(new Uint8Array(chunkCiphertext), 40); 329 | const chunkMac = await window.crypto.subtle.sign( 330 | {name: 'HMAC', hash: 'SHA-256'}, 331 | this.macKey!, 332 | mac 333 | ); 334 | 335 | file.set(chunkNonce, pos); 336 | file.set(new Uint8Array(chunkCiphertext), pos + 16); 337 | file.set(new Uint8Array(chunkMac), pos + 16 + chunkCiphertext.byteLength); 338 | 339 | pos += 16 + chunkCiphertext.byteLength + 32; 340 | } 341 | 342 | return file.slice(0, pos); 343 | } 344 | 345 | private toBytes(num: number): ArrayBuffer { 346 | const arr = new ArrayBuffer(8); 347 | const view = new DataView(arr); 348 | view.setBigUint64(0, window.BigInt(num)); 349 | return arr; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import vuetify from './plugins/vuetify'; 4 | 5 | Vue.config.productionTip = false; 6 | 7 | new Vue({ 8 | vuetify, 9 | render: h => h(App), 10 | }).$mount('#app'); 11 | -------------------------------------------------------------------------------- /src/plugins/store/modules/browser.ts: -------------------------------------------------------------------------------- 1 | import {VuexModule, Module, Mutation, Action, MutationAction} from 'vuex-module-decorators'; 2 | import {Item, ItemType} from '../../../components/Item'; 3 | import Vault from '../../../cryptomator/vault'; 4 | import Node from '../../../cryptomator/node'; 5 | import {v4 as uuidv4} from 'uuid'; 6 | 7 | @Module({namespaced: true}) 8 | export default class Browser extends VuexModule { 9 | vault!: Vault; 10 | openDirectories: Item[] = []; 11 | activeDirectories: Item[] = []; 12 | currentItem: Item | null = null; 13 | currentParentDirId: string | null = null; 14 | directories: Item[] = []; 15 | items: Item[] = []; 16 | 17 | @Mutation 18 | public openDirectory(item: Item): void { 19 | this.openDirectories.push(item); 20 | this.activeDirectories = [item]; 21 | } 22 | 23 | @Mutation 24 | public setVault(vault: Vault): void { 25 | this.vault = vault; 26 | } 27 | 28 | @Mutation 29 | public setCurrentItem(item: Item): void { 30 | this.currentItem = item; 31 | } 32 | 33 | @Mutation 34 | public setCurrentParentDirId(parentDirId: string): void { 35 | this.currentParentDirId = parentDirId; 36 | } 37 | 38 | @Action 39 | public async remove(item: Item): Promise { 40 | if (item.Type === ItemType.Directory) { 41 | await this.vault.deleteDirectory(item.Node!); 42 | const currentParentDirId = item.Node?.parentDirId; 43 | 44 | if (item.Parent) { 45 | const index = item.Parent!.Children.indexOf(item); 46 | if (index > -1) { 47 | item.Parent!.Children.splice(index, 1); 48 | } 49 | } 50 | 51 | return {currentParentDirId}; 52 | } else { 53 | await this.vault.deleteFile(item.Node!); 54 | return {}; 55 | } 56 | } 57 | 58 | @Action 59 | public async createDirectory(name: string): Promise { 60 | if (name) { 61 | const dirId = await this.vault.createDirectory(name, this.currentParentDirId!); 62 | } 63 | return; 64 | } 65 | 66 | @Action 67 | public async upload(files: any): Promise { 68 | if (files.length === 0) { 69 | return; 70 | } 71 | for (const f of files) { 72 | const file = await f.getFile(); 73 | await this.vault.createFile(file.name, this.currentParentDirId!, await file.arrayBuffer()); 74 | } 75 | } 76 | 77 | @Action 78 | public async download(node: Node): Promise { 79 | return await node.decrypt(); 80 | } 81 | 82 | @Action({rawError: true}) 83 | public async move({ 84 | node, 85 | parentDirId, 86 | name, 87 | }: { 88 | node: Node; 89 | parentDirId: string; 90 | name: string; 91 | }): Promise { 92 | if (node.isDir) { 93 | await this.vault.moveDirectory(node, name, parentDirId); 94 | } else { 95 | await this.vault.moveFile(node, name, parentDirId); 96 | } 97 | } 98 | 99 | @Action 100 | public setRoot(): void { 101 | this.directories.push({ 102 | Id: '', 103 | Type: ItemType.Directory, 104 | Name: 'Root', 105 | ParentDirId: '', 106 | DirId: '', 107 | Parent: null, 108 | Children: [], 109 | }); 110 | } 111 | 112 | @MutationAction({mutate: ['items']}) 113 | async reload() { 114 | const items = []; 115 | 116 | if (this.currentItem === null || this.currentParentDirId === null) { 117 | return null; 118 | } 119 | 120 | this.currentItem!.Children = []; 121 | const nodes = await this.vault.list(this.currentParentDirId!); 122 | for (const n of nodes) { 123 | const itm = { 124 | Id: uuidv4(), 125 | Type: n.isDir ? ItemType.Directory : ItemType.File, 126 | Name: n.name || '', 127 | ParentDirId: n.parentDirId, 128 | Node: n, 129 | Parent: this.currentItem, 130 | Children: [], 131 | }; 132 | 133 | if (n.isDir && this.currentItem !== null) { 134 | this.currentItem.Children.push(itm); 135 | } 136 | 137 | items.push(itm); 138 | } 139 | 140 | return {items: items}; 141 | } 142 | 143 | @MutationAction({ 144 | mutate: ['currentItem', 'currentParentDirId', 'items'], 145 | }) 146 | async loadDir(item: Item) { 147 | let parentDirId = item.DirId; 148 | if (item.DirId !== '') { 149 | parentDirId = await item.Node?.dirId(); 150 | } 151 | 152 | const currentItem = item; 153 | const currentParentDirId = parentDirId!; 154 | 155 | const items = []; 156 | item.Children = []; 157 | const nodes = await this.vault.list(parentDirId!); 158 | for (const n of nodes) { 159 | const itm = { 160 | Id: uuidv4(), 161 | Type: n.isDir ? ItemType.Directory : ItemType.File, 162 | Name: n.name || '', 163 | ParentDirId: n.parentDirId, 164 | Node: n, 165 | Parent: null, 166 | Children: [], 167 | }; 168 | 169 | if (n.isDir) { 170 | item.Children.push(itm); 171 | } 172 | 173 | items.push(itm); 174 | } 175 | 176 | return { 177 | currentItem: currentItem, 178 | currentParentDirId: currentParentDirId, 179 | items: items, 180 | }; 181 | } 182 | 183 | @Action 184 | async openDir(item: Item): Promise { 185 | let parentDirId = item.DirId; 186 | if (item.DirId !== '') { 187 | parentDirId = await item.Node?.dirId(); 188 | } 189 | 190 | item.Children = []; 191 | const nodes = await this.vault.list(parentDirId!); 192 | for (const n of nodes) { 193 | const itm = { 194 | Id: uuidv4(), 195 | Type: n.isDir ? ItemType.Directory : ItemType.File, 196 | Name: n.name || '', 197 | ParentDirId: n.parentDirId, 198 | Node: n, 199 | Parent: null, 200 | Children: [], 201 | }; 202 | 203 | if (n.isDir) { 204 | item.Children.push(itm); 205 | } 206 | } 207 | 208 | return; 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/plugins/store/store.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | import Browser from './modules/browser'; 4 | 5 | Vue.use(Vuex); 6 | 7 | export default new Vuex.Store({ 8 | state: { 9 | vault: '', 10 | }, 11 | modules: { 12 | Browser, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuetify from 'vuetify/lib/framework'; 3 | 4 | Vue.use(Vuetify); 5 | 6 | export default new Vuetify({}); 7 | -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, {VNode} from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /src/shims-vuetify.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vuetify/lib/framework' { 2 | import Vuetify from 'vuetify'; 3 | export default Vuetify; 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": ["webpack-env"], 17 | "paths": { 18 | "@/*": ["src/*"] 19 | }, 20 | "lib": ["esnext", "dom", "dom.iterable", "scripthost"] 21 | }, 22 | "include": [ 23 | "src/**/*.ts", 24 | "src/**/*.tsx", 25 | "src/**/*.vue", 26 | "tests/**/*.ts", 27 | "tests/**/*.tsx" 28 | ], 29 | "exclude": ["node_modules"] 30 | } 31 | --------------------------------------------------------------------------------