commit
9bfdf86bec
20 changed files with 774 additions and 116 deletions
142
app/api.js
142
app/api.js
|
@ -91,47 +91,107 @@ export async function setPassword(id, owner_token, keychain) {
|
|||
return response.ok;
|
||||
}
|
||||
|
||||
export function uploadFile(
|
||||
encrypted,
|
||||
metadata,
|
||||
verifierB64,
|
||||
keychain,
|
||||
onprogress
|
||||
) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
const upload = {
|
||||
cancel: function() {
|
||||
xhr.abort();
|
||||
},
|
||||
result: new Promise(function(resolve, reject) {
|
||||
xhr.addEventListener('loadend', function() {
|
||||
const authHeader = xhr.getResponseHeader('WWW-Authenticate');
|
||||
if (authHeader) {
|
||||
keychain.nonce = parseNonce(authHeader);
|
||||
}
|
||||
if (xhr.status === 200) {
|
||||
const responseObj = JSON.parse(xhr.responseText);
|
||||
return resolve({
|
||||
url: responseObj.url,
|
||||
id: responseObj.id,
|
||||
ownerToken: responseObj.owner
|
||||
function asyncInitWebSocket(server) {
|
||||
return new Promise(resolve => {
|
||||
const ws = new WebSocket(server);
|
||||
ws.onopen = () => {
|
||||
resolve(ws);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function listenForResponse(ws, canceller) {
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.addEventListener('message', function(msg) {
|
||||
try {
|
||||
const response = JSON.parse(msg.data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
} else {
|
||||
resolve({
|
||||
url: response.url,
|
||||
id: response.id,
|
||||
ownerToken: response.owner
|
||||
});
|
||||
}
|
||||
reject(new Error(xhr.status));
|
||||
});
|
||||
})
|
||||
};
|
||||
const blob = new Blob([encrypted], { type: 'application/octet-stream' });
|
||||
xhr.upload.addEventListener('progress', function(event) {
|
||||
if (event.lengthComputable) {
|
||||
onprogress([event.loaded, event.total]);
|
||||
}
|
||||
} catch (e) {
|
||||
ws.close();
|
||||
canceller.cancelled = true;
|
||||
canceller.error = e;
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
xhr.open('post', '/api/upload', true);
|
||||
xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata)));
|
||||
xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`);
|
||||
xhr.send(blob);
|
||||
return upload;
|
||||
}
|
||||
|
||||
async function upload(
|
||||
stream,
|
||||
streamInfo,
|
||||
metadata,
|
||||
verifierB64,
|
||||
onprogress,
|
||||
canceller
|
||||
) {
|
||||
const host = window.location.hostname;
|
||||
const port = window.location.port;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = await asyncInitWebSocket(`${protocol}//${host}:${port}/api/ws`);
|
||||
|
||||
try {
|
||||
const metadataHeader = arrayToB64(new Uint8Array(metadata));
|
||||
const fileMeta = {
|
||||
fileMetadata: metadataHeader,
|
||||
authorization: `send-v1 ${verifierB64}`
|
||||
};
|
||||
|
||||
const responsePromise = listenForResponse(ws, canceller);
|
||||
|
||||
ws.send(JSON.stringify(fileMeta));
|
||||
|
||||
const reader = stream.getReader();
|
||||
let state = await reader.read();
|
||||
let size = 0;
|
||||
while (!state.done) {
|
||||
const buf = state.value;
|
||||
if (canceller.cancelled) {
|
||||
throw canceller.error;
|
||||
}
|
||||
|
||||
ws.send(buf);
|
||||
|
||||
onprogress([Math.min(streamInfo.fileSize, size), streamInfo.fileSize]);
|
||||
size += streamInfo.recordSize;
|
||||
state = await reader.read();
|
||||
}
|
||||
const footer = new Uint8Array([0]);
|
||||
ws.send(footer);
|
||||
|
||||
const response = await responsePromise; //promise only fufills if response is good
|
||||
ws.close();
|
||||
return response;
|
||||
} catch (e) {
|
||||
ws.close(4000);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadWs(encrypted, info, metadata, verifierB64, onprogress) {
|
||||
const canceller = { cancelled: false };
|
||||
|
||||
return {
|
||||
cancel: function() {
|
||||
canceller.error = new Error(0);
|
||||
canceller.cancelled = true;
|
||||
},
|
||||
result: upload(
|
||||
encrypted,
|
||||
info,
|
||||
metadata,
|
||||
verifierB64,
|
||||
onprogress,
|
||||
canceller
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function download(id, keychain, onprogress, canceller) {
|
||||
|
@ -151,11 +211,7 @@ function download(id, keychain, onprogress, canceller) {
|
|||
}
|
||||
|
||||
const blob = new Blob([xhr.response]);
|
||||
const fileReader = new FileReader();
|
||||
fileReader.readAsArrayBuffer(blob);
|
||||
fileReader.onload = function() {
|
||||
resolve(this.result);
|
||||
};
|
||||
resolve(blob);
|
||||
});
|
||||
xhr.addEventListener('progress', function(event) {
|
||||
if (event.lengthComputable && event.target.status === 200) {
|
||||
|
|
292
app/ece.js
Normal file
292
app/ece.js
Normal file
|
@ -0,0 +1,292 @@
|
|||
require('buffer');
|
||||
import { ReadableStream, TransformStream } from 'web-streams-polyfill';
|
||||
|
||||
const NONCE_LENGTH = 12;
|
||||
const TAG_LENGTH = 16;
|
||||
const KEY_LENGTH = 16;
|
||||
const MODE_ENCRYPT = 'encrypt';
|
||||
const MODE_DECRYPT = 'decrypt';
|
||||
const RS = 1048576;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function generateSalt(len) {
|
||||
const randSalt = new Uint8Array(len);
|
||||
window.crypto.getRandomValues(randSalt);
|
||||
return randSalt.buffer;
|
||||
}
|
||||
|
||||
class ECETransformer {
|
||||
constructor(mode, ikm, rs, salt) {
|
||||
this.mode = mode;
|
||||
this.prevChunk;
|
||||
this.seq = 0;
|
||||
this.firstchunk = true;
|
||||
this.rs = rs;
|
||||
this.ikm = ikm.buffer;
|
||||
this.salt = salt;
|
||||
}
|
||||
|
||||
async generateKey() {
|
||||
const inputKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
this.ikm,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
return window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
salt: this.salt,
|
||||
info: encoder.encode('Content-Encoding: aes128gcm\0'),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
inputKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 128
|
||||
},
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
async generateNonceBase() {
|
||||
const inputKey = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
this.ikm,
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
|
||||
const base = await window.crypto.subtle.exportKey(
|
||||
'raw',
|
||||
await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'HKDF',
|
||||
salt: this.salt,
|
||||
info: encoder.encode('Content-Encoding: nonce\0'),
|
||||
hash: 'SHA-256'
|
||||
},
|
||||
inputKey,
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
length: 128
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
)
|
||||
);
|
||||
|
||||
return Buffer.from(base.slice(0, NONCE_LENGTH));
|
||||
}
|
||||
|
||||
generateNonce(seq) {
|
||||
if (seq > 0xffffffff) {
|
||||
throw new Error('record sequence number exceeds limit');
|
||||
}
|
||||
const nonce = Buffer.from(this.nonceBase);
|
||||
const m = nonce.readUIntBE(nonce.length - 4, 4);
|
||||
const xor = (m ^ seq) >>> 0; //forces unsigned int xor
|
||||
nonce.writeUIntBE(xor, nonce.length - 4, 4);
|
||||
|
||||
return nonce;
|
||||
}
|
||||
|
||||
pad(data, isLast) {
|
||||
const len = data.length;
|
||||
if (len + TAG_LENGTH >= this.rs) {
|
||||
throw new Error('data too large for record size');
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
const padding = Buffer.alloc(1);
|
||||
padding.writeUInt8(2, 0);
|
||||
return Buffer.concat([data, padding]);
|
||||
} else {
|
||||
const padding = Buffer.alloc(this.rs - len - TAG_LENGTH);
|
||||
padding.fill(0);
|
||||
padding.writeUInt8(1, 0);
|
||||
return Buffer.concat([data, padding]);
|
||||
}
|
||||
}
|
||||
|
||||
unpad(data, isLast) {
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
if (data[i]) {
|
||||
if (isLast) {
|
||||
if (data[i] !== 2) {
|
||||
throw new Error('delimiter of final record is not 2');
|
||||
}
|
||||
} else {
|
||||
if (data[i] !== 1) {
|
||||
throw new Error('delimiter of not final record is not 1');
|
||||
}
|
||||
}
|
||||
return data.slice(0, i);
|
||||
}
|
||||
}
|
||||
throw new Error('no delimiter found');
|
||||
}
|
||||
|
||||
createHeader() {
|
||||
const nums = Buffer.alloc(5);
|
||||
nums.writeUIntBE(this.rs, 0, 4);
|
||||
nums.writeUIntBE(0, 4, 1);
|
||||
return Buffer.concat([Buffer.from(this.salt), nums]);
|
||||
}
|
||||
|
||||
readHeader(buffer) {
|
||||
if (buffer.length < 21) {
|
||||
throw new Error('chunk too small for reading header');
|
||||
}
|
||||
const header = {};
|
||||
header.salt = buffer.buffer.slice(0, KEY_LENGTH);
|
||||
header.rs = buffer.readUIntBE(KEY_LENGTH, 4);
|
||||
const idlen = buffer.readUInt8(KEY_LENGTH + 4);
|
||||
header.length = idlen + KEY_LENGTH + 5;
|
||||
return header;
|
||||
}
|
||||
|
||||
async encryptRecord(buffer, seq, isLast) {
|
||||
const nonce = this.generateNonce(seq);
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: nonce },
|
||||
this.key,
|
||||
this.pad(buffer, isLast)
|
||||
);
|
||||
return Buffer.from(encrypted);
|
||||
}
|
||||
|
||||
async decryptRecord(buffer, seq, isLast) {
|
||||
const nonce = this.generateNonce(seq);
|
||||
const data = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: nonce,
|
||||
tagLength: 128
|
||||
},
|
||||
this.key,
|
||||
buffer
|
||||
);
|
||||
|
||||
return this.unpad(Buffer.from(data), isLast);
|
||||
}
|
||||
|
||||
async start(controller) {
|
||||
if (this.mode === MODE_ENCRYPT) {
|
||||
this.key = await this.generateKey();
|
||||
this.nonceBase = await this.generateNonceBase();
|
||||
controller.enqueue(this.createHeader());
|
||||
} else if (this.mode !== MODE_DECRYPT) {
|
||||
throw new Error('mode must be either encrypt or decrypt');
|
||||
}
|
||||
}
|
||||
|
||||
async transformPrevChunk(isLast, controller) {
|
||||
if (this.mode === MODE_ENCRYPT) {
|
||||
controller.enqueue(
|
||||
await this.encryptRecord(this.prevChunk, this.seq, isLast)
|
||||
);
|
||||
this.seq++;
|
||||
} else {
|
||||
if (this.seq === 0) {
|
||||
//the first chunk during decryption contains only the header
|
||||
const header = this.readHeader(this.prevChunk);
|
||||
this.salt = header.salt;
|
||||
this.rs = header.rs;
|
||||
this.key = await this.generateKey();
|
||||
this.nonceBase = await this.generateNonceBase();
|
||||
} else {
|
||||
controller.enqueue(
|
||||
await this.decryptRecord(this.prevChunk, this.seq - 1, isLast)
|
||||
);
|
||||
}
|
||||
this.seq++;
|
||||
}
|
||||
}
|
||||
|
||||
async transform(chunk, controller) {
|
||||
if (!this.firstchunk) {
|
||||
await this.transformPrevChunk(false, controller);
|
||||
}
|
||||
this.firstchunk = false;
|
||||
this.prevChunk = Buffer.from(chunk.buffer);
|
||||
}
|
||||
|
||||
async flush(controller) {
|
||||
if (this.prevChunk) {
|
||||
await this.transformPrevChunk(true, controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BlobSlicer {
|
||||
constructor(blob, rs, mode) {
|
||||
this.blob = blob;
|
||||
this.index = 0;
|
||||
this.mode = mode;
|
||||
this.chunkSize = mode === MODE_ENCRYPT ? rs - 17 : rs;
|
||||
}
|
||||
|
||||
pull(controller) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const bytesLeft = this.blob.size - this.index;
|
||||
if (bytesLeft <= 0) {
|
||||
controller.close();
|
||||
return resolve();
|
||||
}
|
||||
let size = 1;
|
||||
if (this.mode === MODE_DECRYPT && this.index === 0) {
|
||||
size = Math.min(21, bytesLeft);
|
||||
} else {
|
||||
size = Math.min(this.chunkSize, bytesLeft);
|
||||
}
|
||||
const blob = this.blob.slice(this.index, this.index + size);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
controller.enqueue(new Uint8Array(reader.result));
|
||||
resolve();
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(blob);
|
||||
this.index += size;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class BlobSliceStream extends ReadableStream {
|
||||
constructor(blob, size, mode) {
|
||||
super(new BlobSlicer(blob, size, mode));
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
input: a blob containing data to be transformed
|
||||
key: Uint8Array containing key of size KEY_LENGTH
|
||||
mode: string, either 'encrypt' or 'decrypt'
|
||||
rs: int containing record size, optional
|
||||
salt: ArrayBuffer containing salt of KEY_LENGTH length, optional
|
||||
*/
|
||||
export default class ECE {
|
||||
constructor(input, key, mode, rs, salt) {
|
||||
if (rs === undefined) {
|
||||
rs = RS;
|
||||
}
|
||||
if (salt === undefined) {
|
||||
salt = generateSalt(KEY_LENGTH);
|
||||
}
|
||||
|
||||
this.streamInfo = {
|
||||
recordSize: rs,
|
||||
fileSize: 21 + input.size + 16 * Math.floor(input.size / (rs - 17))
|
||||
};
|
||||
const inputStream = new BlobSliceStream(input, rs, mode);
|
||||
|
||||
const ts = new TransformStream(new ECETransformer(mode, key, rs, salt));
|
||||
this.stream = inputStream.pipeThrough(ts);
|
||||
}
|
||||
}
|
|
@ -52,7 +52,7 @@ export default function(state, emitter) {
|
|||
checkFiles();
|
||||
});
|
||||
|
||||
emitter.on('navigate', checkFiles);
|
||||
//emitter.on('navigate', checkFiles);
|
||||
|
||||
emitter.on('render', () => {
|
||||
lastRender = Date.now();
|
||||
|
|
|
@ -51,6 +51,21 @@ export default class FileReceiver extends Nanobus {
|
|||
this.state = 'ready';
|
||||
}
|
||||
|
||||
async streamToArrayBuffer(stream, streamSize) {
|
||||
const reader = stream.getReader();
|
||||
const result = new Uint8Array(streamSize);
|
||||
let offset = 0;
|
||||
|
||||
let state = await reader.read();
|
||||
while (!state.done) {
|
||||
result.set(state.value, offset);
|
||||
offset += state.value.length;
|
||||
state = await reader.read();
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
async download(noSave = false) {
|
||||
this.state = 'downloading';
|
||||
this.downloadRequest = await downloadFile(
|
||||
|
@ -61,13 +76,21 @@ export default class FileReceiver extends Nanobus {
|
|||
this.emit('progress');
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const ciphertext = await this.downloadRequest.result;
|
||||
this.downloadRequest = null;
|
||||
this.msg = 'decryptingFile';
|
||||
this.state = 'decrypting';
|
||||
this.emit('decrypting');
|
||||
const plaintext = await this.keychain.decryptFile(ciphertext);
|
||||
|
||||
const dec = await this.keychain.decryptStream(ciphertext);
|
||||
const plainstream = dec.stream;
|
||||
const plaintext = await this.streamToArrayBuffer(
|
||||
plainstream,
|
||||
dec.streamInfo.fileSize
|
||||
);
|
||||
|
||||
if (!noSave) {
|
||||
await saveFile({
|
||||
plaintext,
|
||||
|
|
|
@ -3,7 +3,7 @@ import Nanobus from 'nanobus';
|
|||
import OwnedFile from './ownedFile';
|
||||
import Keychain from './keychain';
|
||||
import { arrayToB64, bytes } from './utils';
|
||||
import { uploadFile } from './api';
|
||||
import { uploadWs } from './api';
|
||||
|
||||
export default class FileSender extends Nanobus {
|
||||
constructor(file) {
|
||||
|
@ -59,28 +59,31 @@ export default class FileSender extends Nanobus {
|
|||
|
||||
async upload() {
|
||||
const start = Date.now();
|
||||
const plaintext = await this.readFile();
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
this.msg = 'encryptingFile';
|
||||
this.emit('encrypting');
|
||||
const encrypted = await this.keychain.encryptFile(plaintext);
|
||||
|
||||
const enc = this.keychain.encryptStream(this.file);
|
||||
const metadata = await this.keychain.encryptMetadata(this.file);
|
||||
const authKeyB64 = await this.keychain.authKeyB64();
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
this.uploadRequest = uploadFile(
|
||||
encrypted,
|
||||
|
||||
this.uploadRequest = uploadWs(
|
||||
enc.stream,
|
||||
enc.streamInfo,
|
||||
metadata,
|
||||
authKeyB64,
|
||||
this.keychain,
|
||||
p => {
|
||||
this.progress = p;
|
||||
this.emit('progress');
|
||||
}
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
throw new Error(0);
|
||||
}
|
||||
|
||||
this.msg = 'fileSizeProgress';
|
||||
this.emit('progress'); // HACK to kick MS Edge
|
||||
try {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { arrayToB64, b64ToArray } from './utils';
|
||||
|
||||
import ECE from './ece.js';
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
|
@ -179,6 +179,16 @@ export default class Keychain {
|
|||
return ciphertext;
|
||||
}
|
||||
|
||||
encryptStream(plaintext) {
|
||||
const enc = new ECE(plaintext, this.rawSecret, 'encrypt');
|
||||
return enc;
|
||||
}
|
||||
|
||||
decryptStream(encstream) {
|
||||
const dec = new ECE(encstream, this.rawSecret, 'decrypt');
|
||||
return dec;
|
||||
}
|
||||
|
||||
async decryptFile(ciphertext) {
|
||||
const encryptKey = await this.encryptKeyPromise;
|
||||
const plaintext = await window.crypto.subtle.decrypt(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue