Skip to content

Commit 718d74f

Browse files
committed
Implemented FxA
1 parent 70bc2b7 commit 718d74f

40 files changed

+1305
-650
lines changed

app/api.js

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,6 @@ export async function fileInfo(id, owner_token) {
6565
throw new Error(response.status);
6666
}
6767

68-
export async function hasPassword(id) {
69-
const response = await fetch(`/api/exists/${id}`);
70-
if (response.ok) {
71-
return response.json();
72-
}
73-
throw new Error(response.status);
74-
}
75-
7668
export async function metadata(id, keychain) {
7769
const result = await fetchWithAuthAndRetry(
7870
`/api/metadata/${id}`,
@@ -141,6 +133,7 @@ async function upload(
141133
metadata,
142134
verifierB64,
143135
timeLimit,
136+
bearerToken,
144137
onprogress,
145138
canceller
146139
) {
@@ -159,6 +152,7 @@ async function upload(
159152
const fileMeta = {
160153
fileMetadata: metadataHeader,
161154
authorization: `send-v1 ${verifierB64}`,
155+
bearer: bearerToken,
162156
timeLimit
163157
};
164158

@@ -200,8 +194,9 @@ export function uploadWs(
200194
encrypted,
201195
metadata,
202196
verifierB64,
203-
onprogress,
204-
timeLimit
197+
timeLimit,
198+
bearerToken,
199+
onprogress
205200
) {
206201
const canceller = { cancelled: false };
207202

@@ -216,6 +211,7 @@ export function uploadWs(
216211
metadata,
217212
verifierB64,
218213
timeLimit,
214+
bearerToken,
219215
onprogress,
220216
canceller
221217
)
@@ -332,3 +328,19 @@ export function downloadFile(id, keychain, onprogress) {
332328
result: tryDownload(id, keychain, onprogress, canceller, 2)
333329
};
334330
}
331+
332+
export async function getFileList(bearerToken) {
333+
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
334+
const response = await fetch('/api/filelist', { headers });
335+
return response.body; // stream
336+
}
337+
338+
export async function setFileList(bearerToken, data) {
339+
const headers = new Headers({ Authorization: `Bearer ${bearerToken}` });
340+
const response = await fetch('/api/filelist', {
341+
headers,
342+
method: 'POST',
343+
body: data
344+
});
345+
return response.status === 200;
346+
}

app/archive.js

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* global MAXFILESIZE */
1+
/* global LIMITS */
22
import { blobStream, concatStream } from './streams';
33

44
function isDupe(newFile, array) {
@@ -15,7 +15,7 @@ function isDupe(newFile, array) {
1515
}
1616

1717
export default class Archive {
18-
constructor(files) {
18+
constructor(files = []) {
1919
this.files = Array.from(files);
2020
}
2121

@@ -49,20 +49,19 @@ export default class Archive {
4949
return concatStream(this.files.map(file => blobStream(file)));
5050
}
5151

52-
addFiles(files) {
52+
addFiles(files, maxSize) {
53+
if (this.files.length + files.length > LIMITS.MAX_FILES_PER_ARCHIVE) {
54+
throw new Error('tooManyFiles');
55+
}
5356
const newFiles = files.filter(file => !isDupe(file, this.files));
5457
const newSize = newFiles.reduce((total, file) => total + file.size, 0);
55-
if (this.size + newSize > MAXFILESIZE) {
56-
return false;
58+
if (this.size + newSize > maxSize) {
59+
throw new Error('fileTooBig');
5760
}
5861
this.files = this.files.concat(newFiles);
5962
return true;
6063
}
6164

62-
checkSize() {
63-
return this.size <= MAXFILESIZE;
64-
}
65-
6665
remove(index) {
6766
this.files.splice(index, 1);
6867
}

app/fileManager.js

Lines changed: 39 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
1-
/* global MAXFILESIZE */
2-
/* global DEFAULT_EXPIRE_SECONDS */
1+
/* global DEFAULTS LIMITS */
32
import FileSender from './fileSender';
43
import FileReceiver from './fileReceiver';
54
import { copyToClipboard, delay, openLinksInNewTab, percent } from './utils';
65
import * as metrics from './metrics';
7-
import { hasPassword } from './api';
86
import Archive from './archive';
97
import { bytes } from './utils';
8+
import { prepareWrapKey } from './fxa';
109

1110
export default function(state, emitter) {
1211
let lastRender = 0;
@@ -17,19 +16,8 @@ export default function(state, emitter) {
1716
}
1817

1918
async function checkFiles() {
20-
const files = state.storage.files.slice();
21-
let rerender = false;
22-
for (const file of files) {
23-
const oldLimit = file.dlimit;
24-
const oldTotal = file.dtotal;
25-
await file.updateDownloadCount();
26-
if (file.dtotal === file.dlimit) {
27-
state.storage.remove(file.id);
28-
rerender = true;
29-
} else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
30-
rerender = true;
31-
}
32-
}
19+
const changes = await state.user.syncFileList();
20+
const rerender = changes.incoming || changes.downloadCount;
3321
if (rerender) {
3422
render();
3523
}
@@ -57,6 +45,16 @@ export default function(state, emitter) {
5745
lastRender = Date.now();
5846
});
5947

48+
emitter.on('login', async () => {
49+
const k = await prepareWrapKey(state.storage);
50+
location.assign(`/api/fxa/login?keys_jwk=${k}`);
51+
});
52+
53+
emitter.on('logout', () => {
54+
state.user.logout();
55+
render();
56+
});
57+
6058
emitter.on('changeLimit', async ({ file, value }) => {
6159
await file.changeLimit(value);
6260
state.storage.writeFile(file);
@@ -90,29 +88,37 @@ export default function(state, emitter) {
9088
});
9189

9290
emitter.on('addFiles', async ({ files }) => {
93-
if (state.archive) {
94-
if (!state.archive.addFiles(files)) {
95-
// eslint-disable-next-line no-alert
96-
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
97-
return;
98-
}
99-
} else {
100-
const archive = new Archive(files);
101-
if (!archive.checkSize()) {
102-
// eslint-disable-next-line no-alert
103-
alert(state.translate('fileTooBig', { size: bytes(MAXFILESIZE) }));
104-
return;
105-
}
106-
state.archive = archive;
91+
const maxSize = state.user.maxSize;
92+
state.archive = state.archive || new Archive();
93+
try {
94+
state.archive.addFiles(files, maxSize);
95+
} catch (e) {
96+
alert(
97+
state.translate(e.message, {
98+
size: bytes(maxSize),
99+
count: LIMITS.MAX_FILES_PER_ARCHIVE
100+
})
101+
);
107102
}
108103
render();
109104
});
110105

111106
emitter.on('upload', async ({ type, dlCount, password }) => {
112107
if (!state.archive) return;
108+
if (state.storage.files.length >= LIMITS.MAX_ARCHIVES_PER_USER) {
109+
return alert(
110+
state.translate('tooManyArchives', {
111+
count: LIMITS.MAX_ARCHIVES_PER_USER
112+
})
113+
);
114+
}
113115
const size = state.archive.size;
114-
if (!state.timeLimit) state.timeLimit = DEFAULT_EXPIRE_SECONDS;
115-
const sender = new FileSender(state.archive, state.timeLimit);
116+
if (!state.timeLimit) state.timeLimit = DEFAULTS.EXPIRE_SECONDS;
117+
const sender = new FileSender(
118+
state.archive,
119+
state.timeLimit,
120+
state.user.bearerToken
121+
);
116122

117123
sender.on('progress', updateProgress);
118124
sender.on('encrypting', render);
@@ -132,7 +138,6 @@ export default function(state, emitter) {
132138
metrics.completedUpload(ownedFile);
133139

134140
state.storage.addFile(ownedFile);
135-
136141
if (password) {
137142
emitter.emit('password', { password, file: ownedFile });
138143
}
@@ -185,17 +190,6 @@ export default function(state, emitter) {
185190
render();
186191
});
187192

188-
emitter.on('getPasswordExist', async ({ id }) => {
189-
try {
190-
state.fileInfo = await hasPassword(id);
191-
render();
192-
} catch (e) {
193-
if (e.message === '404') {
194-
return emitter.emit('pushState', '/404');
195-
}
196-
}
197-
});
198-
199193
emitter.on('getMetadata', async () => {
200194
const file = state.fileInfo;
201195

@@ -204,7 +198,7 @@ export default function(state, emitter) {
204198
await receiver.getMetadata();
205199
state.transfer = receiver;
206200
} catch (e) {
207-
if (e.message === '401') {
201+
if (e.message === '401' || e.message === '404') {
208202
file.password = null;
209203
if (!file.requiresPassword) {
210204
return emitter.emit('pushState', '/404');

app/fileReceiver.js

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Nanobus from 'nanobus';
22
import Keychain from './keychain';
3-
import { delay, bytes } from './utils';
3+
import { delay, bytes, streamToArrayBuffer } from './utils';
44
import { downloadFile, metadata } from './api';
55
import { blobStream } from './streams';
66
import Zip from './zip';
@@ -191,20 +191,6 @@ export default class FileReceiver extends Nanobus {
191191
}
192192
}
193193

194-
async function streamToArrayBuffer(stream, size) {
195-
const result = new Uint8Array(size);
196-
let offset = 0;
197-
const reader = stream.getReader();
198-
let state = await reader.read();
199-
while (!state.done) {
200-
result.set(state.value, offset);
201-
offset += state.value.length;
202-
state = await reader.read();
203-
}
204-
205-
return result.buffer;
206-
}
207-
208194
async function saveFile(file) {
209195
return new Promise(function(resolve, reject) {
210196
const dataView = new DataView(file.plaintext);

app/fileSender.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/* global DEFAULT_EXPIRE_SECONDS */
1+
/* global DEFAULTS */
22
import Nanobus from 'nanobus';
33
import OwnedFile from './ownedFile';
44
import Keychain from './keychain';
@@ -7,9 +7,10 @@ import { uploadWs } from './api';
77
import { encryptedSize } from './ece';
88

99
export default class FileSender extends Nanobus {
10-
constructor(file, timeLimit) {
10+
constructor(file, timeLimit, bearerToken) {
1111
super('FileSender');
12-
this.timeLimit = timeLimit || DEFAULT_EXPIRE_SECONDS;
12+
this.timeLimit = timeLimit || DEFAULTS.EXPIRE_SECONDS;
13+
this.bearerToken = bearerToken;
1314
this.file = file;
1415
this.keychain = new Keychain();
1516
this.reset();
@@ -75,11 +76,12 @@ export default class FileSender extends Nanobus {
7576
encStream,
7677
metadata,
7778
authKeyB64,
79+
this.timeLimit,
80+
this.bearerToken,
7881
p => {
7982
this.progress = [p, totalSize];
8083
this.emit('progress');
81-
},
82-
this.timeLimit
84+
}
8385
);
8486

8587
if (this.cancelled) {

app/fxa.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import jose from 'node-jose';
2+
import { arrayToB64, b64ToArray } from './utils';
3+
4+
const encoder = new TextEncoder();
5+
6+
export async function prepareWrapKey(storage) {
7+
const keystore = jose.JWK.createKeyStore();
8+
const keypair = await keystore.generate('EC', 'P-256');
9+
storage.set('fxaWrapKey', JSON.stringify(keystore.toJSON(true)));
10+
return jose.util.base64url.encode(JSON.stringify(keypair.toJSON()));
11+
}
12+
13+
export async function getFileListKey(storage, bundle) {
14+
const keystore = await jose.JWK.asKeyStore(
15+
JSON.parse(storage.get('fxaWrapKey'))
16+
);
17+
const result = await jose.JWE.createDecrypt(keystore).decrypt(bundle);
18+
const jwks = JSON.parse(jose.util.utf8.encode(result.plaintext));
19+
const jwk = jwks['https://identity.mozilla.com/apps/send'];
20+
const baseKey = await crypto.subtle.importKey(
21+
'raw',
22+
b64ToArray(jwk.k),
23+
{ name: 'HKDF' },
24+
false,
25+
['deriveKey']
26+
);
27+
const fileListKey = await crypto.subtle.deriveKey(
28+
{
29+
name: 'HKDF',
30+
salt: new Uint8Array(),
31+
info: encoder.encode('fileList'),
32+
hash: 'SHA-256'
33+
},
34+
baseKey,
35+
{
36+
name: 'AES-GCM',
37+
length: 128
38+
},
39+
true,
40+
['encrypt', 'decrypt']
41+
);
42+
const rawFileListKey = await crypto.subtle.exportKey('raw', fileListKey);
43+
return arrayToB64(new Uint8Array(rawFileListKey));
44+
}

app/main.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/* global userInfo */
12
import 'fast-text-encoding'; // MS Edge support
23
import 'fluent-intl-polyfill';
34
import app from './routes';
@@ -11,6 +12,8 @@ import metrics from './metrics';
1112
import experiments from './experiments';
1213
import Raven from 'raven-js';
1314
import './main.css';
15+
import User from './user';
16+
import { getFileListKey } from './fxa';
1417

1518
(async function start() {
1619
if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
@@ -20,13 +23,17 @@ import './main.css';
2023
if (capa.streamDownload) {
2124
navigator.serviceWorker.register('/serviceWorker.js');
2225
}
26+
if (userInfo && userInfo.keys_jwe) {
27+
userInfo.fileListKey = await getFileListKey(storage, userInfo.keys_jwe);
28+
}
2329
app.use((state, emitter) => {
2430
state.capabilities = capa;
2531
state.transfer = null;
2632
state.fileInfo = null;
2733
state.translate = locale.getTranslator();
2834
state.storage = storage;
2935
state.raven = Raven;
36+
state.user = new User(userInfo, storage);
3037
window.appState = state;
3138
let unsupportedReason = null;
3239
if (

0 commit comments

Comments
 (0)