This commit is contained in:
Eugen Rochko 2020-07-24 11:34:21 +02:00
parent a8b6524b43
commit 53b716f382
16 changed files with 298 additions and 320 deletions

View File

@ -1,5 +1,4 @@
import api, { getLinks } from '../api';
import openDB from '../storage/db';
import { importAccount, importFetchedAccount, importFetchedAccounts } from './importer';
export const ACCOUNT_FETCH_REQUEST = 'ACCOUNT_FETCH_REQUEST';
@ -74,24 +73,6 @@ export const FOLLOW_REQUEST_REJECT_REQUEST = 'FOLLOW_REQUEST_REJECT_REQUEST';
export const FOLLOW_REQUEST_REJECT_SUCCESS = 'FOLLOW_REQUEST_REJECT_SUCCESS';
export const FOLLOW_REQUEST_REJECT_FAIL = 'FOLLOW_REQUEST_REJECT_FAIL';
function getFromDB(dispatch, getState, index, id) {
return new Promise((resolve, reject) => {
const request = index.get(id);
request.onerror = reject;
request.onsuccess = () => {
if (!request.result) {
reject();
return;
}
dispatch(importAccount(request.result));
resolve(request.result.moved && getFromDB(dispatch, getState, index, request.result.moved));
};
});
}
export function fetchAccount(id) {
return (dispatch, getState) => {
dispatch(fetchRelationships([id]));
@ -102,17 +83,8 @@ export function fetchAccount(id) {
dispatch(fetchAccountRequest(id));
openDB().then(db => getFromDB(
dispatch,
getState,
db.transaction('accounts', 'read').objectStore('accounts').index('id'),
id,
).then(() => db.close(), error => {
db.close();
throw error;
})).catch(() => api(getState).get(`/api/v1/accounts/${id}`).then(response => {
api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(importFetchedAccount(response.data));
})).then(() => {
dispatch(fetchAccountSuccess());
}).catch(error => {
dispatch(fetchAccountFail(id, error));

View File

@ -0,0 +1,111 @@
import api, { getLinks } from '../api';
import Olm from 'olm';
import olmModule from 'olm/olm.wasm';
import CryptoStore from './crypto/store';
export const CRYPTO_INITIALIZE_REQUEST = 'CRYPTO_INITIALIZE_REQUEST';
export const CRYPTO_INITIALIZE_SUCCESS = 'CRYPTO_INITIALIZE_SUCCESS';
export const CRYPTO_INITIALIZE_FAIL = 'CRYPTO_INITIALIZE_FAIL ';
const cryptoStore = new CryptoStore();
const loadOlm = () => Olm.init({
locateFile: path => {
if (path.endsWith('.wasm')) {
return olmModule;
}
return path;
},
});
const getRandomBytes = size => {
const array = new Uint8Array(size);
crypto.getRandomValues(array);
return array.buffer;
};
const generateDeviceId = () => {
const id = new Uint16Array(getRandomBytes(2))[0];
return id & 0x3fff;
};
export const initializeCryptoRequest = () => ({
type: CRYPTO_INITIALIZE_REQUEST,
});
export const initializeCryptoSuccess = account => ({
type: CRYPTO_INITIALIZE_SUCCESS,
account,
});
export const initializeCryptoFail = error => ({
type: CRYPTO_INITIALIZE_FAIL,
error,
});
export const initializeCrypto = () => (dispatch, getState) => {
dispatch(initializeCryptoRequest());
loadOlm().then(() => {
return cryptoStore.getAccount();
}).then(account => {
dispatch(initializeCryptoSuccess(account));
}).catch(err => {
console.error(err);
dispatch(initializeCryptoFail(err));
});
};
export const enableCrypto = () => (dispatch, getState) => {
dispatch(initializeCryptoRequest());
loadOlm().then(() => {
const deviceId = generateDeviceId();
const account = new Olm.Account();
account.create();
account.generate_one_time_keys(10);
const deviceName = 'Browser';
const identityKeys = JSON.parse(account.identity_keys());
const oneTimeKeys = JSON.parse(account.one_time_keys());
return cryptoStore.storeAccount(account).then(api(getState).post('/api/v1/crypto/keys/upload', {
device: {
device_id: deviceId,
name: deviceName,
fingerprint_key: identityKeys.ed25519,
identity_key: identityKeys.curve25519,
},
one_time_keys: Object.keys(oneTimeKeys.curve25519).map(key => ({
key_id: key,
key: oneTimeKeys.curve25519[key],
signature: account.sign(oneTimeKeys.curve25519[key]),
})),
})).then(() => {
account.mark_keys_as_published();
}).then(() => {
return cryptoStore.storeAccount(account);
}).then(() => {
dispatch(initializeCryptoSuccess(account));
});
}).catch(err => {
console.error(err);
dispatch(initializeCryptoFail(err));
});
};
const MESSAGE_PREKEY = 0;
export const receiveCrypto = encryptedMessage => (dispatch, getState) => {
const { account_id, device_id, type, body } = encryptedMessage;
const deviceKey = `${account_id}:${device_id}`;
cryptoStore.decryptMessage(deviceKey, type, body).then(payloadString => {
console.log(encryptedMessage, payloadString);
});
};

View File

@ -0,0 +1,110 @@
import Dexie from 'dexie';
import Olm from 'olm';
const MESSAGE_TYPE_PREKEY = 0;
export default class CryptoStore {
constructor() {
this.pickleKey = 'DEFAULT_KEY';
this.db = new Dexie('mastodon-crypto');
this.db.version(1).stores({
accounts: '',
sessions: 'deviceKey,sessionId',
});
}
// FIXME: Need to call free() on returned accounts at some point
// but it needs to happen *after* you're done using them
getAccount () {
return this.db.accounts.get('-').then(pickledAccount => {
if (typeof pickledAccount === 'undefined') {
return null;
}
const account = new Olm.Account();
account.unpickle(this.pickleKey, pickledAccount);
return account;
});
}
storeAccount (account) {
return this.db.accounts.put(account.pickle(this.pickleKey), '-');
}
storeSession (deviceKey, session) {
return this.db.sessions.put({
deviceKey,
sessionId: session.session_id(),
pickledSession: session.pickle(this.pickleKey),
});
}
createInboundSession (deviceKey, type, body) {
return this.getAccount().then(account => {
const session = new Olm.Session();
let payloadString;
try {
session.create_inbound(account, body);
account.remove_one_time_keys(session);
this.storeAccount(account);
payloadString = session.decrypt(type, body);
this.storeSession(deviceKey, session);
} finally {
session.free();
account.free();
}
return payloadString;
});
}
// FIXME: Need to call free() on returned sessions at some point
// but it needs to happen *after* you're done using them
getSessionsForDevice (deviceKey) {
return this.db.sessions.where('deviceKey').equals(deviceKey).toArray().then(sessions => sessions.map(sessionData => {
const session = new Olm.Session();
session.unpickle(this.pickleKey, sessionData.pickledSession);
return session;
}));
}
decryptMessage (deviceKey, type, body) {
return this.getSessionsForDevice(deviceKey).then(sessions => {
let payloadString;
sessions.forEach(session => {
try {
payloadString = this.decryptMessageForSession(deviceKey, session, type, body);
} catch (e) {
console.error(e);
}
});
if (typeof payloadString !== 'undefined') {
return payloadString;
}
if (type === MESSAGE_TYPE_PREKEY) {
return this.createInboundSession(deviceKey, type, body);
}
});
}
decryptMessageForSession (deviceKey, session, type, body) {
const payloadString = session.decrypt(type, body);
this.storeSession(deviceKey, session);
return payloadString;
}
}

View File

@ -1,6 +1,4 @@
import api from '../api';
import openDB from '../storage/db';
import { evictStatus } from '../storage/modifier';
import { deleteFromTimelines } from './timelines';
import { importFetchedStatus, importFetchedStatuses, importAccount, importStatus, importFetchedAccount } from './importer';
@ -94,23 +92,10 @@ export function fetchStatus(id) {
dispatch(fetchStatusRequest(id, skipLoading));
openDB().then(db => {
const transaction = db.transaction(['accounts', 'statuses'], 'read');
const accountIndex = transaction.objectStore('accounts').index('id');
const index = transaction.objectStore('statuses').index('id');
return getFromDB(dispatch, getState, accountIndex, index, id).then(() => {
db.close();
}, error => {
db.close();
throw error;
});
}).then(() => {
dispatch(fetchStatusSuccess(skipLoading));
}, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => {
api(getState).get(`/api/v1/statuses/${id}`).then(response => {
dispatch(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading));
})).catch(error => {
}).catch(error => {
dispatch(fetchStatusFail(id, error, skipLoading));
});
};
@ -152,7 +137,6 @@ export function deleteStatus(id, routerHistory, withRedraft = false) {
dispatch(deleteStatusRequest(id));
api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
evictStatus(id);
dispatch(deleteStatusSuccess(id));
dispatch(deleteFromTimelines(id));
dispatch(importFetchedAccount(response.data.account));

View File

@ -8,6 +8,7 @@ import {
} from './timelines';
import { updateNotifications, expandNotifications } from './notifications';
import { updateConversations } from './conversations';
import { receiveCrypto } from './crypto';
import {
fetchAnnouncements,
updateAnnouncements,
@ -59,6 +60,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null,
case 'announcement.delete':
dispatch(deleteAnnouncement(data.payload));
break;
case 'encrypted_message':
dispatch(receiveCrypto(JSON.parse(data.payload)));
break;
}
},
};

View File

@ -3,17 +3,21 @@ import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { mountConversations, unmountConversations, expandConversations } from '../../actions/conversations';
import { mountConversations } from '../../actions/conversations';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { connectDirectStream } from '../../actions/streaming';
import { initializeCrypto, enableCrypto } from 'mastodon/actions/crypto';
import ConversationsListContainer from './containers/conversations_list_container';
const messages = defineMessages({
title: { id: 'column.direct', defaultMessage: 'Direct messages' },
});
export default @connect()
const mapStateToProps = state => ({
enabled: state.getIn(['crypto', 'enabled']),
});
export default @connect(mapStateToProps)
@injectIntl
class DirectTimeline extends React.PureComponent {
@ -24,6 +28,7 @@ class DirectTimeline extends React.PureComponent {
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
enabled: PropTypes.bool,
};
handlePin = () => {
@ -49,29 +54,23 @@ class DirectTimeline extends React.PureComponent {
const { dispatch } = this.props;
dispatch(mountConversations());
dispatch(expandConversations());
this.disconnect = dispatch(connectDirectStream());
dispatch(initializeCrypto());
}
componentWillUnmount () {
this.props.dispatch(unmountConversations());
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = maxId => {
this.props.dispatch(expandConversations({ maxId }));
handleEnableCrypto = () => {
this.props.dispatch(enableCrypto());
}
render () {
const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll } = this.props;
const { intl, hasUnread, columnId, multiColumn, shouldUpdateScroll, enabled } = this.props;
const pinned = !!columnId;
return (
@ -87,14 +86,9 @@ class DirectTimeline extends React.PureComponent {
multiColumn={multiColumn}
/>
<ConversationsListContainer
trackScroll={!pinned}
scrollKey={`direct_timeline-${columnId}`}
timelineId='direct'
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
shouldUpdateScroll={shouldUpdateScroll}
/>
{!enabled && <button onClick={this.handleEnableCrypto}>Enable crypto</button>}
{enabled && <span>Crypto enabled</span>}
</Column>
);
}

View File

@ -0,0 +1,15 @@
import { CRYPTO_INITIALIZE_SUCCESS } from 'mastodon/actions/crypto';
import { Map as ImmutableMap } from 'immutable';
const initialState = ImmutableMap({
enabled: false,
});
export default function crypto (state = initialState, action) {
switch(action.type) {
case CRYPTO_INITIALIZE_SUCCESS:
return state.set('enabled', action.account !== null);
default:
return state;
}
};

View File

@ -36,6 +36,7 @@ import trends from './trends';
import missed_updates from './missed_updates';
import announcements from './announcements';
import markers from './markers';
import crypto from './crypto';
const reducers = {
announcements,
@ -75,6 +76,7 @@ const reducers = {
trends,
missed_updates,
markers,
crypto,
};
export default combineReducers(reducers);

View File

@ -1,27 +0,0 @@
export default () => new Promise((resolve, reject) => {
// ServiceWorker is required to synchronize the login state.
// Microsoft Edge 17 does not support getAll according to:
// Catalog of standard and vendor APIs across browsers - Microsoft Edge Development
// https://developer.microsoft.com/en-us/microsoft-edge/platform/catalog/?q=specName%3Aindexeddb
if (!('caches' in self && 'getAll' in IDBObjectStore.prototype)) {
reject();
return;
}
const request = indexedDB.open('mastodon');
request.onerror = reject;
request.onsuccess = ({ target }) => resolve(target.result);
request.onupgradeneeded = ({ target }) => {
const accounts = target.result.createObjectStore('accounts', { autoIncrement: true });
const statuses = target.result.createObjectStore('statuses', { autoIncrement: true });
accounts.createIndex('id', 'id', { unique: true });
accounts.createIndex('moved', 'moved');
statuses.createIndex('id', 'id', { unique: true });
statuses.createIndex('account', 'account');
statuses.createIndex('reblog', 'reblog');
};
});

View File

@ -1,211 +0,0 @@
import openDB from './db';
const accountAssetKeys = ['avatar', 'avatar_static', 'header', 'header_static'];
const storageMargin = 8388608;
const storeLimit = 1024;
// navigator.storage is not present on:
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.100 Safari/537.36 Edge/16.16299
// estimate method is not present on Chrome 57.0.2987.98 on Linux.
export const storageFreeable = 'storage' in navigator && 'estimate' in navigator.storage;
function openCache() {
// ServiceWorker and Cache API is not available on iOS 11
// https://webkit.org/status/#specification-service-workers
return self.caches ? caches.open('mastodon-system') : Promise.reject();
}
function printErrorIfAvailable(error) {
if (error) {
console.warn(error);
}
}
function put(name, objects, onupdate, oncreate) {
return openDB().then(db => (new Promise((resolve, reject) => {
const putTransaction = db.transaction(name, 'readwrite');
const putStore = putTransaction.objectStore(name);
const putIndex = putStore.index('id');
objects.forEach(object => {
putIndex.getKey(object.id).onsuccess = retrieval => {
function addObject() {
putStore.add(object);
}
function deleteObject() {
putStore.delete(retrieval.target.result).onsuccess = addObject;
}
if (retrieval.target.result) {
if (onupdate) {
onupdate(object, retrieval.target.result, putStore, deleteObject);
} else {
deleteObject();
}
} else {
if (oncreate) {
oncreate(object, addObject);
} else {
addObject();
}
}
};
});
putTransaction.oncomplete = () => {
const readTransaction = db.transaction(name, 'readonly');
const readStore = readTransaction.objectStore(name);
const count = readStore.count();
count.onsuccess = () => {
const excess = count.result - storeLimit;
if (excess > 0) {
const retrieval = readStore.getAll(null, excess);
retrieval.onsuccess = () => resolve(retrieval.result);
retrieval.onerror = reject;
} else {
resolve([]);
}
};
count.onerror = reject;
};
putTransaction.onerror = reject;
})).then(resolved => {
db.close();
return resolved;
}, error => {
db.close();
throw error;
}));
}
function evictAccountsByRecords(records) {
return openDB().then(db => {
const transaction = db.transaction(['accounts', 'statuses'], 'readwrite');
const accounts = transaction.objectStore('accounts');
const accountsIdIndex = accounts.index('id');
const accountsMovedIndex = accounts.index('moved');
const statuses = transaction.objectStore('statuses');
const statusesIndex = statuses.index('account');
function evict(toEvict) {
toEvict.forEach(record => {
openCache()
.then(cache => accountAssetKeys.forEach(key => cache.delete(records[key])))
.catch(printErrorIfAvailable);
accountsMovedIndex.getAll(record.id).onsuccess = ({ target }) => evict(target.result);
statusesIndex.getAll(record.id).onsuccess =
({ target }) => evictStatusesByRecords(target.result);
accountsIdIndex.getKey(record.id).onsuccess =
({ target }) => target.result && accounts.delete(target.result);
});
}
evict(records);
db.close();
}).catch(printErrorIfAvailable);
}
export function evictStatus(id) {
evictStatuses([id]);
}
export function evictStatuses(ids) {
return openDB().then(db => {
const transaction = db.transaction('statuses', 'readwrite');
const store = transaction.objectStore('statuses');
const idIndex = store.index('id');
const reblogIndex = store.index('reblog');
ids.forEach(id => {
reblogIndex.getAllKeys(id).onsuccess =
({ target }) => target.result.forEach(reblogKey => store.delete(reblogKey));
idIndex.getKey(id).onsuccess =
({ target }) => target.result && store.delete(target.result);
});
db.close();
}).catch(printErrorIfAvailable);
}
function evictStatusesByRecords(records) {
return evictStatuses(records.map(({ id }) => id));
}
export function putAccounts(records, avatarStatic) {
const avatarKey = avatarStatic ? 'avatar_static' : 'avatar';
const newURLs = [];
put('accounts', records, (newRecord, oldKey, store, oncomplete) => {
store.get(oldKey).onsuccess = ({ target }) => {
accountAssetKeys.forEach(key => {
const newURL = newRecord[key];
const oldURL = target.result[key];
if (newURL !== oldURL) {
openCache()
.then(cache => cache.delete(oldURL))
.catch(printErrorIfAvailable);
}
});
const newURL = newRecord[avatarKey];
const oldURL = target.result[avatarKey];
if (newURL !== oldURL) {
newURLs.push(newURL);
}
oncomplete();
};
}, (newRecord, oncomplete) => {
newURLs.push(newRecord[avatarKey]);
oncomplete();
}).then(records => Promise.all([
evictAccountsByRecords(records),
openCache().then(cache => cache.addAll(newURLs)),
])).then(freeStorage, error => {
freeStorage();
throw error;
}).catch(printErrorIfAvailable);
}
export function putStatuses(records) {
put('statuses', records)
.then(evictStatusesByRecords)
.catch(printErrorIfAvailable);
}
export function freeStorage() {
return storageFreeable && navigator.storage.estimate().then(({ quota, usage }) => {
if (usage + storageMargin < quota) {
return null;
}
return openDB().then(db => new Promise((resolve, reject) => {
const retrieval = db.transaction('accounts', 'readonly').objectStore('accounts').getAll(null, 1);
retrieval.onsuccess = () => {
if (retrieval.result.length > 0) {
resolve(evictAccountsByRecords(retrieval.result).then(freeStorage));
} else {
resolve(caches.delete('mastodon-system'));
}
};
retrieval.onerror = reject;
db.close();
}));
});
}

View File

@ -340,22 +340,22 @@ Rails.application.routes.draw do
end
end
# namespace :crypto do
# resources :deliveries, only: :create
namespace :crypto do
resources :deliveries, only: :create
# namespace :keys do
# resource :upload, only: [:create]
# resource :query, only: [:create]
# resource :claim, only: [:create]
# resource :count, only: [:show]
# end
namespace :keys do
resource :upload, only: [:create]
resource :query, only: [:create]
resource :claim, only: [:create]
resource :count, only: [:show]
end
# resources :encrypted_messages, only: [:index] do
# collection do
# post :clear
# end
# end
# end
resources :encrypted_messages, only: [:index] do
collection do
post :clear
end
end
end
resources :conversations, only: [:index, :destroy] do
member do

View File

@ -2,6 +2,7 @@ const babel = require('./babel');
const css = require('./css');
const file = require('./file');
const nodeModules = require('./node_modules');
const wasm = require('./wasm');
// Webpack loaders are processed in reverse order
// https://webpack.js.org/concepts/loaders/#loader-features
@ -11,4 +12,5 @@ module.exports = {
css,
nodeModules,
babel,
wasm,
};

View File

@ -0,0 +1,8 @@
module.exports = {
test: /\.wasm$/,
type: "javascript/auto",
loader: "file-loader",
options: {
name: '[name]-[hash].[ext]'
}
};

View File

@ -109,5 +109,8 @@ module.exports = {
// Called by http-link-header in an API we never use, increases
// bundle size unnecessarily
Buffer: false,
// Called by olm
fs: 'empty',
},
};

View File

@ -88,6 +88,7 @@
"css-loader": "^3.6.0",
"cssnano": "^4.1.10",
"detect-passive-events": "^1.0.2",
"dexie": "^3.0.1",
"dotenv": "^8.2.0",
"emoji-mart": "Gargron/emoji-mart#build",
"es6-symbol": "^3.1.3",
@ -117,6 +118,7 @@
"object-fit-images": "^3.2.3",
"object.values": "^1.1.1",
"offline-plugin": "^5.0.7",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
"path-complete-extname": "^1.0.0",
"pg": "^6.4.0",
"postcss-loader": "^3.0.0",

View File

@ -3811,6 +3811,11 @@ detect-passive-events@^1.0.2:
resolved "https://registry.yarnpkg.com/detect-passive-events/-/detect-passive-events-1.0.4.tgz#6ed477e6e5bceb79079735dcd357789d37f9a91a"
integrity sha1-btR35uW863kHlzXc01d4nTf5qRo=
dexie@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dexie/-/dexie-3.0.1.tgz#faafeb94be0d5e18b25d700546a2c05725511cfc"
integrity sha512-/s4KzlaerQnCad/uY1ZNdFckTrbdMVhLlziYQzz62Ff9Ick1lHGomvTXNfwh4ApEZATyXRyVk5F6/y8UU84B0w==
diff-sequences@^25.2.6:
version "25.2.6"
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd"
@ -7658,6 +7663,10 @@ offline-plugin@^5.0.7:
minimatch "^3.0.3"
slash "^1.0.0"
"olm@https://packages.matrix.org/npm/olm/olm-3.1.4.tgz":
version "3.1.4"
resolved "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz#0f03128b7d3b2f614d2216409a1dfccca765fdb3"
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"