├── .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 | 
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 |
16 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
18 | properly without JavaScript enabled. Please enable it to
19 | continue.
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/App.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
22 |
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/marcboeker/cryptomator/a6e66a150dbb6cfefbc988c5eac45863161100e8/src/assets/logo.png
--------------------------------------------------------------------------------
/src/components/Browser.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 | Vault Details
17 |
18 |
19 |
20 |
21 |
22 |
23 |
29 |
30 |
31 |
32 |
33 |
38 |
39 |
40 |
41 |
42 |
48 |
49 |
50 |
51 |
52 |
57 |
58 |
59 |
64 |
65 |
66 |
67 |
68 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | Save
83 |
84 |
85 |
86 |
87 |
88 |
89 |
177 |
--------------------------------------------------------------------------------
/src/components/Confirm.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ title }}
6 |
7 |
8 |
9 |
10 | Cancel
11 | Yes
12 |
13 |
14 |
15 |
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 |
2 |
3 |
4 |
5 |
6 |
7 | Directories
8 |
9 |
10 | mdi-folder-outline
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | mdi-folder-move
20 |
21 |
22 |
23 |
24 | mdi-delete-outline
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | Files
33 |
34 |
35 | {{ fileIcons[item.Extension] || fileIcons['other'] }}
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | mdi-file-move
45 |
46 |
47 |
48 |
49 | mdi-delete-outline
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | No files and directories available.
58 |
59 |
60 |
61 |
62 |
63 |
189 |
190 |
195 |
--------------------------------------------------------------------------------
/src/components/Move.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Move directory/file
6 |
7 | Wehere do you want to move {{ item.Name }} to?
10 |
13 |
26 |
27 |
28 | Cancel
29 | Move
30 |
31 |
32 |
33 |
34 |
35 |
111 |
--------------------------------------------------------------------------------
/src/components/Toolbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Cryptomator
5 |
6 |
7 |
8 |
14 |
15 |
16 | mdi-folder-plus-outline
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | Cancel
26 | Create Directory
29 |
30 |
31 |
32 |
33 | mdi-upload
34 |
35 |
36 | mdi-refresh
37 |
38 |
39 | mdi-delete-outline
40 |
41 |
42 |
43 |
44 |
45 |
46 |
108 |
--------------------------------------------------------------------------------
/src/components/Tree.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
18 |
19 | {{ open ? 'mdi-folder-open-outline' : 'mdi-folder-outline' }}
20 |
21 |
22 | {{ item.Name }}
23 |
24 |
25 |
26 |
27 |
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 |
--------------------------------------------------------------------------------