You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-26 23:38:08 +02:00
Compare commits
1 Commits
ppk
...
sharing_e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1651055e |
@@ -37,6 +37,7 @@ import { reg } from '@joplin/lib/registry';
|
||||
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
|
||||
import commands from './commands/index';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
@@ -545,8 +546,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||
const onInvitationRespond = async (shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
void reg.scheduleSync(1000);
|
||||
};
|
||||
@@ -593,9 +594,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
||||
_('Accept'),
|
||||
() => onInvitationRespond(invitation.id, true),
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, true),
|
||||
_('Reject'),
|
||||
() => onInvitationRespond(invitation.id, false)
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, false)
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
msg = this.renderNotificationMessage(
|
||||
|
||||
@@ -171,7 +171,7 @@ function ShareFolderDialog(props: Props) {
|
||||
try {
|
||||
setLatestError(null);
|
||||
const share = await ShareService.instance().shareFolder(props.folderId);
|
||||
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
||||
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail);
|
||||
await Promise.all([
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
|
||||
@@ -142,7 +142,7 @@ export default class JoplinServerApi {
|
||||
}
|
||||
|
||||
if (sessionId) headers['X-API-AUTH'] = sessionId;
|
||||
headers['X-API-MIN-VERSION'] = '2.1.4';
|
||||
headers['X-API-MIN-VERSION'] = '2.5.0';
|
||||
|
||||
const fetchOptions: any = {};
|
||||
fetchOptions.headers = headers;
|
||||
|
||||
@@ -40,6 +40,10 @@ export default class FileApiDriverJoplinServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
public get requiresPublicPrivateKeyPair() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public requestRepeatCount() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ class FileApi {
|
||||
return !!this.driver().supportsAccurateTimestamp;
|
||||
}
|
||||
|
||||
public get requiresPublicPrivateKeyPair(): boolean {
|
||||
return !!this.driver().requiresPublicPrivateKeyPair;
|
||||
}
|
||||
|
||||
async fetchRemoteDateOffset_() {
|
||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -10,7 +10,7 @@ import ItemChange from './ItemChange';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||
const JoplinError = require('../JoplinError.js');
|
||||
import JoplinError from '../JoplinError';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
|
||||
type_: ModelType;
|
||||
updated_time: number;
|
||||
encryption_applied: number;
|
||||
share_id: string;
|
||||
}
|
||||
|
||||
export interface ItemsThatNeedSyncResult {
|
||||
@@ -409,6 +410,7 @@ export default class BaseItem extends BaseModel {
|
||||
const shownKeys = ItemClass.fieldNames();
|
||||
shownKeys.push('type_');
|
||||
|
||||
const share = item.share_id ? await this.shareService().shareById(item.share_id) : null;
|
||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||
|
||||
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
|
||||
@@ -426,7 +428,9 @@ export default class BaseItem extends BaseModel {
|
||||
let cipherText = null;
|
||||
|
||||
try {
|
||||
cipherText = await this.encryptionService().encryptString(serialized);
|
||||
cipherText = await this.encryptionService().encryptString(serialized, {
|
||||
masterKeyId: share && share.master_key_id ? share.master_key_id : '',
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = [`Could not encrypt item ${item.id}`];
|
||||
if (error && error.message) msg.push(error.message);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseItemEntity } from '../../services/database/types';
|
||||
|
||||
export default function(resource: BaseItemEntity): boolean {
|
||||
return !resource.is_shared && !resource.share_id;
|
||||
export default function(_resource: BaseItemEntity): boolean {
|
||||
return true;
|
||||
// return !resource.is_shared && !resource.share_id;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
import Note from '../../models/Note';
|
||||
import { msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import { encryptionService, msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import ShareService from './ShareService';
|
||||
import reducer from '../../reducer';
|
||||
import { createStore } from 'redux';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import Folder from '../../models/Folder';
|
||||
import { localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import { generateKeyPair, generateKeyPairAndSave } from '../e2ee/ppk';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
|
||||
function mockApi() {
|
||||
return {
|
||||
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
||||
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
||||
return null;
|
||||
},
|
||||
personalizedUserContentBaseUrl(_userId: string) {
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockService() {
|
||||
function mockService(api: any) {
|
||||
const service = new ShareService();
|
||||
const store = createStore(reducer as any);
|
||||
service.initialize(store, mockApi() as any);
|
||||
service.initialize(store, encryptionService(), api);
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -32,9 +25,17 @@ describe('ShareService', function() {
|
||||
done();
|
||||
});
|
||||
|
||||
it('should not change the note user timestamps when sharing or unsharing', (async () => {
|
||||
it('should not change the note user timestamps when sharing or unsharing', async () => {
|
||||
let note = await Note.save({});
|
||||
const service = mockService();
|
||||
const service = mockService({
|
||||
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
||||
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
||||
return null;
|
||||
},
|
||||
personalizedUserContentBaseUrl(_userId: string) {
|
||||
|
||||
},
|
||||
});
|
||||
await msleep(1);
|
||||
await service.shareNote(note.id);
|
||||
|
||||
@@ -61,6 +62,86 @@ describe('ShareService', function() {
|
||||
const noteReloaded = await Note.load(note.id);
|
||||
checkTimestamps(note, noteReloaded);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
function testShareFolderService(extraExecHandlers: Record<string, Function> = {}) {
|
||||
return mockService({
|
||||
exec: async (method: string, path: string, query: Record<string, any>, body: any) => {
|
||||
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
|
||||
|
||||
if (method === 'POST' && path === 'api/shares') {
|
||||
return {
|
||||
id: 'share_1',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function testShareFolder(service: ShareService) {
|
||||
const folder = await Folder.save({});
|
||||
const note = await Note.save({ parent_id: folder.id });
|
||||
|
||||
const share = await service.shareFolder(folder.id);
|
||||
expect(share.id).toBe('share_1');
|
||||
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
|
||||
expect((await Note.load(note.id)).share_id).toBe('share_1');
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
it('should share a folder', async () => {
|
||||
await testShareFolder(testShareFolderService());
|
||||
});
|
||||
|
||||
it('should share a folder - E2EE', async () => {
|
||||
setEncryptionEnabled(true);
|
||||
const ppk = await generateKeyPairAndSave(encryptionService(), localSyncInfo(), '111111');
|
||||
|
||||
await testShareFolder(testShareFolderService());
|
||||
|
||||
expect((await MasterKey.all()).length).toBe(1);
|
||||
|
||||
const mk = (await MasterKey.all())[0];
|
||||
const content = JSON.parse(mk.content);
|
||||
expect(content.ppkId).toBe(ppk.id);
|
||||
});
|
||||
|
||||
it('should add a recipient', async () => {
|
||||
setEncryptionEnabled(true);
|
||||
const ppk = await generateKeyPairAndSave(encryptionService(), localSyncInfo(), '111111');
|
||||
const recipientPpk = await generateKeyPair(encryptionService(), '222222');
|
||||
expect(ppk.id).not.toBe(recipientPpk.id);
|
||||
|
||||
let uploadedEmail: string = '';
|
||||
let uploadedMasterKey: MasterKeyEntity = null;
|
||||
|
||||
const service = testShareFolderService({
|
||||
'POST api/shares': (_query: Record<string, any>, body: any) => {
|
||||
return {
|
||||
id: 'share_1',
|
||||
master_key_id: body.master_key_id,
|
||||
};
|
||||
},
|
||||
'GET api/users/toto%40example.com/public_key': async (_query: Record<string, any>, _body: any) => {
|
||||
return recipientPpk;
|
||||
},
|
||||
'POST api/shares/share_1/users': async (_query: Record<string, any>, body: any) => {
|
||||
uploadedEmail = body.email;
|
||||
uploadedMasterKey = JSON.parse(body.master_key);
|
||||
},
|
||||
});
|
||||
|
||||
const share = await testShareFolder(service);
|
||||
|
||||
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com');
|
||||
|
||||
expect(uploadedEmail).toBe('toto@example.com');
|
||||
|
||||
const content = JSON.parse(uploadedMasterKey.content);
|
||||
expect(content.ppkId).toBe(recipientPpk.id);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
import { Store } from 'redux';
|
||||
import JoplinServerApi from '../../JoplinServerApi';
|
||||
import { _ } from '../../locale';
|
||||
import Logger from '../../Logger';
|
||||
import Folder from '../../models/Folder';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import { State, stateRootKey, StateShare } from './reducer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { getMasterPassword } from '../e2ee/utils';
|
||||
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
|
||||
|
||||
const logger = Logger.create('ShareService');
|
||||
|
||||
export interface ApiShare {
|
||||
id: string;
|
||||
master_key_id: string;
|
||||
}
|
||||
|
||||
function formatShareInvitations(invitations: any[]): ShareInvitation[] {
|
||||
return invitations.map(inv => {
|
||||
return {
|
||||
...inv,
|
||||
master_key: inv.master_key ? JSON.parse(inv.master_key) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default class ShareService {
|
||||
|
||||
private static instance_: ShareService;
|
||||
private api_: JoplinServerApi = null;
|
||||
private store_: Store<any> = null;
|
||||
private encryptionService_: EncryptionService = null;
|
||||
|
||||
public static instance(): ShareService {
|
||||
if (this.instance_) return this.instance_;
|
||||
@@ -20,8 +43,9 @@ export default class ShareService {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public initialize(store: Store<any>, api: JoplinServerApi = null) {
|
||||
public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
|
||||
this.store_ = store;
|
||||
this.encryptionService_ = encryptionService;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
@@ -56,15 +80,40 @@ export default class ShareService {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string) {
|
||||
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
if (folder.parent_id) {
|
||||
await Folder.save({ id: folder.id, parent_id: '' });
|
||||
let folderMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
const syncInfo = localSyncInfo();
|
||||
|
||||
// Shouldn't happen
|
||||
if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');
|
||||
|
||||
folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
|
||||
folderMasterKey = await MasterKey.save(folderMasterKey);
|
||||
|
||||
addMasterKey(syncInfo, folderMasterKey);
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
||||
const newFolderProps: FolderEntity = {};
|
||||
|
||||
if (folder.parent_id) newFolderProps.parent_id = '';
|
||||
if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;
|
||||
|
||||
if (Object.keys(newFolderProps).length) {
|
||||
await Folder.save({
|
||||
id: folder.id,
|
||||
...newFolderProps,
|
||||
});
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
folder_id: folderId,
|
||||
master_key_id: folderMasterKey ? folderMasterKey.id : '',
|
||||
});
|
||||
|
||||
// Note: race condition if the share is created but the app crashes
|
||||
// before setting share_id on the folder. See unshareFolder() for info.
|
||||
@@ -174,9 +223,34 @@ export default class ShareService {
|
||||
return this.state.shareInvitations;
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
||||
private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair> {
|
||||
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string) {
|
||||
let recipientMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
const syncInfo = localSyncInfo();
|
||||
const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
|
||||
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
|
||||
|
||||
const recipientPublicKey: PublicPrivateKeyPair = await this.userPublicKey(recipientEmail);
|
||||
if (!recipientPublicKey) throw new Error(_('Cannot share notebook with recipient %s because they do not have a public key. Ask them to create one from the menu "%s"', recipientEmail, 'Tools > Generate Public-Private Key pair'));
|
||||
|
||||
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);
|
||||
|
||||
recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
getMasterPassword(),
|
||||
recipientPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
master_key: JSON.stringify(recipientMasterKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,8 +274,24 @@ export default class ShareService {
|
||||
return this.api().exec('GET', 'api/share_users');
|
||||
}
|
||||
|
||||
public async respondInvitation(shareUserId: string, accept: boolean) {
|
||||
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||
logger.info('respondInvitation: ', shareUserId, accept);
|
||||
|
||||
if (accept) {
|
||||
if (masterKey) {
|
||||
const reencryptedMasterKey = await mkReencryptFromPublicKeyToPassword(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
localSyncInfo().ppk,
|
||||
getMasterPassword(),
|
||||
getMasterPassword()
|
||||
);
|
||||
|
||||
logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);
|
||||
|
||||
await MasterKey.save(reencryptedMasterKey);
|
||||
}
|
||||
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||
} else {
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||
@@ -211,15 +301,57 @@ export default class ShareService {
|
||||
public async refreshShareInvitations() {
|
||||
const result = await this.loadShareInvitations();
|
||||
|
||||
const invitations = formatShareInvitations(result.items);
|
||||
logger.info('Refresh share invitations:', invitations);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_INVITATION_SET',
|
||||
shareInvitations: result.items,
|
||||
shareInvitations: invitations,
|
||||
});
|
||||
}
|
||||
|
||||
public async shareById(id: string) {
|
||||
const stateShare = this.state.shares.find(s => s.id === id);
|
||||
if (stateShare) return stateShare;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
const refreshedShare = refreshedShares.find(s => s.id === id);
|
||||
if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
|
||||
return refreshedShare;
|
||||
}
|
||||
|
||||
// In most cases the share objects will already be part of the state, so
|
||||
// this function checks there first. If the required share objects are not
|
||||
// present, it refreshes them from the API.
|
||||
public async sharesByIds(ids: string[]) {
|
||||
const buildOutput = async (shares: StateShare[]) => {
|
||||
const output: Record<string, StateShare> = {};
|
||||
for (const share of shares) {
|
||||
if (ids.includes(share.id)) output[share.id] = share;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
let output = await buildOutput(this.state.shares);
|
||||
if (Object.keys(output).length === ids.length) return output;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
output = await buildOutput(refreshedShares);
|
||||
|
||||
if (Object.keys(output).length !== ids.length) {
|
||||
logger.error('sharesByIds: Need:', ids);
|
||||
logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
|
||||
throw new Error('Could not retrieve required share objects');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async refreshShares(): Promise<StateShare[]> {
|
||||
const result = await this.loadShares();
|
||||
|
||||
logger.info('Refreshed shares:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_SET',
|
||||
shares: result.items,
|
||||
@@ -231,6 +363,8 @@ export default class ShareService {
|
||||
public async refreshShareUsers(shareId: string) {
|
||||
const result = await this.loadShareUsers(shareId);
|
||||
|
||||
logger.info('Refreshed share users:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_USER_SET',
|
||||
shareId: shareId,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { State as RootState } from '../../reducer';
|
||||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
@@ -25,11 +26,13 @@ export interface StateShare {
|
||||
type: number;
|
||||
folder_id: string;
|
||||
note_id: string;
|
||||
master_key_id: string;
|
||||
user?: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string;
|
||||
master_key: MasterKeyEntity;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.3",
|
||||
"version": "2.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
||||
Binary file not shown.
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal file
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Knex } from 'knex';
|
||||
import { DbConnection } from '../db';
|
||||
|
||||
export async function up(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
|
||||
table.text('master_key', 'mediumtext').defaultTo('').notNullable();
|
||||
});
|
||||
|
||||
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
|
||||
table.string('master_key_id', 32).defaultTo('').notNullable();
|
||||
});
|
||||
}
|
||||
|
||||
export async function down(db: DbConnection): Promise<any> {
|
||||
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
|
||||
table.dropColumn('master_key');
|
||||
});
|
||||
|
||||
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
|
||||
table.dropColumn('master_key_id');
|
||||
});
|
||||
}
|
||||
@@ -67,6 +67,20 @@ describe('ShareModel', function() {
|
||||
|
||||
expect(shares3.length).toBe(1);
|
||||
expect(shares3.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy();
|
||||
|
||||
const participatedShares1 = await models().share().participatedSharesByUser(user1.id, ShareType.Folder);
|
||||
const participatedShares2 = await models().share().participatedSharesByUser(user2.id, ShareType.Folder);
|
||||
const participatedShares3 = await models().share().participatedSharesByUser(user3.id, ShareType.Folder);
|
||||
|
||||
expect(participatedShares1.length).toBe(1);
|
||||
expect(participatedShares1[0].owner_id).toBe(user2.id);
|
||||
expect(participatedShares1[0].folder_id).toBe('000000000000000000000000000000F2');
|
||||
|
||||
expect(participatedShares2.length).toBe(0);
|
||||
|
||||
expect(participatedShares3.length).toBe(1);
|
||||
expect(participatedShares3[0].owner_id).toBe(user1.id);
|
||||
expect(participatedShares3[0].folder_id).toBe('000000000000000000000000000000F1');
|
||||
});
|
||||
|
||||
test('should generate only one link per shared note', async function() {
|
||||
@@ -78,8 +92,8 @@ describe('ShareModel', function() {
|
||||
},
|
||||
});
|
||||
|
||||
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
|
||||
expect(share1.id).toBe(share2.id);
|
||||
});
|
||||
@@ -93,7 +107,7 @@ describe('ShareModel', function() {
|
||||
},
|
||||
});
|
||||
|
||||
await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
|
||||
await models().item().delete(noteItem.id);
|
||||
expect(await models().item().load(noteItem.id)).toBeFalsy();
|
||||
|
||||
@@ -60,6 +60,7 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
if (object.folder_id) output.folder_id = object.folder_id;
|
||||
if (object.owner_id) output.owner_id = object.owner_id;
|
||||
if (object.note_id) output.note_id = object.note_id;
|
||||
if (object.master_key_id) output.master_key_id = object.master_key_id;
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -148,6 +149,20 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
return query;
|
||||
}
|
||||
|
||||
public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
|
||||
const query = this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.whereIn('id', this.db('share_users')
|
||||
.select('share_id')
|
||||
.where('user_id', '=', userId)
|
||||
.andWhere('status', '=', ShareUserStatus.Accepted
|
||||
));
|
||||
|
||||
if (type) void query.andWhere('type', '=', type);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Returns all user IDs concerned by the share. That includes all the users
|
||||
// the folder has been shared with, as well as the folder owner.
|
||||
public async allShareUserIds(share: Share): Promise<Uuid[]> {
|
||||
@@ -318,36 +333,38 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
});
|
||||
}
|
||||
|
||||
public async shareFolder(owner: User, folderId: string): Promise<Share> {
|
||||
public async shareFolder(owner: User, folderId: string, masterKeyId: string): Promise<Share> {
|
||||
const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
|
||||
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
|
||||
|
||||
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
|
||||
if (share) return share;
|
||||
|
||||
const shareToSave = {
|
||||
const shareToSave: Share = {
|
||||
type: ShareType.Folder,
|
||||
item_id: folderItem.id,
|
||||
owner_id: owner.id,
|
||||
folder_id: folderId,
|
||||
master_key_id: masterKeyId,
|
||||
};
|
||||
|
||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||
return super.save(shareToSave);
|
||||
}
|
||||
|
||||
public async shareNote(owner: User, noteId: string): Promise<Share> {
|
||||
public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise<Share> {
|
||||
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
|
||||
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
|
||||
|
||||
const existingShare = await this.byItemId(noteItem.id);
|
||||
if (existingShare) return existingShare;
|
||||
|
||||
const shareToSave = {
|
||||
const shareToSave: Share = {
|
||||
type: ShareType.Note,
|
||||
item_id: noteItem.id,
|
||||
owner_id: owner.id,
|
||||
note_id: noteId,
|
||||
master_key_id: masterKeyId,
|
||||
};
|
||||
|
||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||
|
||||
@@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
return this.db(this.tableName).where(link).first();
|
||||
}
|
||||
|
||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
|
||||
await this.models().shareUser().addById(share.id, shareeId);
|
||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid, masterKey: string = '') {
|
||||
await this.models().shareUser().addById(share.id, shareeId, masterKey);
|
||||
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
|
||||
}
|
||||
|
||||
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
|
||||
public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise<ShareUser> {
|
||||
const user = await this.models().user().load(userId);
|
||||
return this.addByEmail(shareId, user.email);
|
||||
return this.addByEmail(shareId, user.email, masterKey);
|
||||
}
|
||||
|
||||
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||
@@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
.first();
|
||||
}
|
||||
|
||||
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||
public async addByEmail(shareId: Uuid, userEmail: string, masterKey: string): Promise<ShareUser> {
|
||||
const share = await this.models().share().load(shareId);
|
||||
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
|
||||
|
||||
@@ -110,6 +110,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
return this.save({
|
||||
share_id: shareId,
|
||||
user_id: user.id,
|
||||
master_key: masterKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, expectThrow } from '../utils/testing/testUtils';
|
||||
import { EmailSender, User, UserFlagType } from '../services/database/types';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
@@ -267,4 +267,56 @@ describe('UserModel', function() {
|
||||
}
|
||||
});
|
||||
|
||||
test('should get the user public key', async function() {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
const { user: user2 } = await createUserAndSession(2);
|
||||
const { user: user3 } = await createUserAndSession(3);
|
||||
const { user: user4 } = await createUserAndSession(4);
|
||||
|
||||
const syncInfo1: any = {
|
||||
'version': 3,
|
||||
'e2ee': {
|
||||
'value': false,
|
||||
'updatedTime': 0,
|
||||
},
|
||||
'ppk': {
|
||||
'value': {
|
||||
publicKey: 'PUBLIC_KEY_1',
|
||||
privateKey: {
|
||||
encryptionMode: 4,
|
||||
ciphertext: 'PRIVATE_KEY',
|
||||
},
|
||||
},
|
||||
'updatedTime': 0,
|
||||
},
|
||||
};
|
||||
|
||||
const syncInfo2: any = JSON.parse(JSON.stringify(syncInfo1));
|
||||
syncInfo2.ppk.value.publicKey = 'PUBLIC_KEY_2';
|
||||
|
||||
const syncInfo3: any = JSON.parse(JSON.stringify(syncInfo1));
|
||||
delete syncInfo3.ppk;
|
||||
|
||||
await models().item().saveForUser(user1.id, {
|
||||
content: Buffer.from(JSON.stringify(syncInfo1)),
|
||||
name: 'info.json',
|
||||
});
|
||||
|
||||
await models().item().saveForUser(user2.id, {
|
||||
content: Buffer.from(JSON.stringify(syncInfo2)),
|
||||
name: 'info.json',
|
||||
});
|
||||
|
||||
await models().item().saveForUser(user3.id, {
|
||||
content: Buffer.from(JSON.stringify(syncInfo3)),
|
||||
name: 'info.json',
|
||||
});
|
||||
|
||||
expect(await models().user().publicKey(user1.id)).toBe('PUBLIC_KEY_1');
|
||||
expect(await models().user().publicKey(user2.id)).toBe('PUBLIC_KEY_2');
|
||||
expect(await models().user().publicKey(user3.id)).toBe('');
|
||||
|
||||
await expectThrow(async () => models().user().publicKey(user4.id));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import resetPasswordTemplate from '../views/emails/resetPasswordTemplate';
|
||||
import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
|
||||
import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
||||
import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate';
|
||||
import oversizedAccount1 from '../views/emails/oversizedAccount1';
|
||||
import oversizedAccount2 from '../views/emails/oversizedAccount2';
|
||||
@@ -439,6 +440,18 @@ export default class UserModel extends BaseModel<User> {
|
||||
return output;
|
||||
}
|
||||
|
||||
private async syncInfo(userId: Uuid): Promise<any> {
|
||||
const item = await this.models().item().loadByName(userId, 'info.json');
|
||||
if (!item) throw new Error('Cannot find info.json file');
|
||||
const withContent = await this.models().item().loadWithContent(item.id);
|
||||
return JSON.parse(withContent.content.toString());
|
||||
}
|
||||
|
||||
public async publicPrivateKey(userId: string): Promise<PublicPrivateKeyPair> {
|
||||
const syncInfo = await this.syncInfo(userId);
|
||||
return syncInfo.ppk?.value || null;// syncInfo.ppk?.value.publicKey || '';
|
||||
}
|
||||
|
||||
// Note that when the "password" property is provided, it is going to be
|
||||
// hashed automatically. It means that it is not safe to do:
|
||||
//
|
||||
|
||||
@@ -43,6 +43,7 @@ router.get('api/share_users', async (_path: SubPath, ctx: AppContext) => {
|
||||
items.push({
|
||||
id: su.id,
|
||||
status: su.status,
|
||||
master_key: su.master_key,
|
||||
share: {
|
||||
id: share.id,
|
||||
folder_id: share.folder_id,
|
||||
|
||||
@@ -19,11 +19,18 @@ router.public = true;
|
||||
router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
ownerRequired(ctx);
|
||||
|
||||
interface Fields {
|
||||
folder_id?: string;
|
||||
note_id?: string;
|
||||
master_key_id?: string;
|
||||
}
|
||||
|
||||
const shareModel = ctx.joplin.models.share();
|
||||
const fields = await bodyFields<any>(ctx.req);
|
||||
const fields = await bodyFields<Fields>(ctx.req);
|
||||
const shareInput: ShareApiInput = shareModel.fromApiInput(fields) as ShareApiInput;
|
||||
if (fields.folder_id) shareInput.folder_id = fields.folder_id;
|
||||
if (fields.note_id) shareInput.note_id = fields.note_id;
|
||||
const masterKeyId = fields.master_key_id || '';
|
||||
|
||||
// - The API end point should only expose two ways of sharing:
|
||||
// - By folder_id (JoplinRootFolder)
|
||||
@@ -31,9 +38,9 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
// - Additionally, the App method is available, but not exposed via the API.
|
||||
|
||||
if (shareInput.folder_id) {
|
||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id);
|
||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id, masterKeyId);
|
||||
} else if (shareInput.note_id) {
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id);
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId);
|
||||
} else {
|
||||
throw new ErrorBadRequest('Either folder_id or note_id must be provided');
|
||||
}
|
||||
@@ -44,20 +51,23 @@ router.post('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||
|
||||
interface UserInput {
|
||||
email: string;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
const fields = await bodyFields(ctx.req) as UserInput;
|
||||
const user = await ctx.joplin.models.user().loadByEmail(fields.email);
|
||||
if (!user) throw new ErrorNotFound('User not found');
|
||||
|
||||
const masterKey = fields.master_key || '';
|
||||
const shareId = path.id;
|
||||
|
||||
await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, {
|
||||
share_id: shareId,
|
||||
user_id: user.id,
|
||||
master_key: masterKey,
|
||||
});
|
||||
|
||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email);
|
||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email, masterKey);
|
||||
});
|
||||
|
||||
router.get('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||
@@ -102,13 +112,17 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
throw new ErrorNotFound();
|
||||
});
|
||||
|
||||
// This end points returns both the shares owned by the user, and those they
|
||||
// participate in.
|
||||
router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
ownerRequired(ctx);
|
||||
|
||||
const shares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||
const ownedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||
const participatedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().participatedSharesByUser(ctx.joplin.owner.id));
|
||||
|
||||
// Fake paginated results so that it can be added later on, if needed.
|
||||
return {
|
||||
items: shares.map(share => {
|
||||
items: ownedShares.concat(participatedShares).map(share => {
|
||||
return {
|
||||
...share,
|
||||
user: {
|
||||
|
||||
@@ -26,6 +26,21 @@ router.get('api/users/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
return user;
|
||||
});
|
||||
|
||||
router.publicSchemas.push('api/users/:id/public_key');
|
||||
|
||||
// "id" in this case is actually the email address
|
||||
router.get('api/users/:id/public_key', async (path: SubPath, ctx: AppContext) => {
|
||||
const user = await ctx.joplin.models.user().loadByEmail(path.id);
|
||||
if (!user) return ''; // Don't throw an error to prevent polling the end point
|
||||
|
||||
const ppk = await ctx.joplin.models.user().publicPrivateKey(user.id);
|
||||
|
||||
return {
|
||||
id: ppk.id,
|
||||
publicKey: ppk.publicKey,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
|
||||
await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create);
|
||||
const user = await postedUserFromContext(ctx);
|
||||
|
||||
@@ -137,6 +137,7 @@ export interface ShareUser extends WithDates, WithUuid {
|
||||
share_id?: Uuid;
|
||||
user_id?: Uuid;
|
||||
status?: ShareUserStatus;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
export interface Item extends WithDates, WithUuid {
|
||||
@@ -177,6 +178,7 @@ export interface Share extends WithDates, WithUuid {
|
||||
type?: ShareType;
|
||||
folder_id?: Uuid;
|
||||
note_id?: Uuid;
|
||||
master_key_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
@@ -293,6 +295,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
status: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
master_key: { type: 'string' },
|
||||
},
|
||||
items: {
|
||||
id: { type: 'string' },
|
||||
@@ -336,6 +339,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
created_time: { type: 'string' },
|
||||
folder_id: { type: 'string' },
|
||||
note_id: { type: 'string' },
|
||||
master_key_id: { type: 'string' },
|
||||
},
|
||||
changes: {
|
||||
counter: { type: 'number' },
|
||||
|
||||
@@ -2,8 +2,8 @@ import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { ErrorTooManyRequests } from '../errors';
|
||||
|
||||
const limiterSlowBruteByIP = new RateLimiterMemory({
|
||||
points: 3, // Up to 3 requests per IP
|
||||
duration: 30, // Per 30 seconds
|
||||
points: 10, // Up to 10 requests per IP
|
||||
duration: 60, // Per 60 seconds
|
||||
});
|
||||
|
||||
export default async function(ip: string) {
|
||||
|
||||
@@ -135,12 +135,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
|
||||
if (colonIndex2 < 0) {
|
||||
throw new ErrorBadRequest(`Invalid path format: ${p}`);
|
||||
} else {
|
||||
output.id = p.substr(0, colonIndex2 + 1);
|
||||
output.id = decodeURIComponent(p.substr(0, colonIndex2 + 1));
|
||||
output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
|
||||
}
|
||||
} else {
|
||||
const s = p.split('/');
|
||||
if (s.length >= 1) output.id = s[0];
|
||||
if (s.length >= 1) output.id = decodeURIComponent(s[0]);
|
||||
if (s.length >= 2) output.link = s[1];
|
||||
}
|
||||
|
||||
|
||||
11
readme/spec/server_sharing_e2ee.md
Normal file
11
readme/spec/server_sharing_e2ee.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Sharing a notebook with E2EE enabled
|
||||
|
||||
- When sharing the notebook, a key (NOTEBOOK_KEY) is automatically generated and encrypted with the sender master password.
|
||||
- That key ID is then associated with the notebook
|
||||
- When adding a recipient, the key is decrypted using the sender master password, and reencrypted using the recipient public key
|
||||
- That encrypted key is then attached to the share_user object (the invitation)
|
||||
- When the recipient receives the invitation, the key is retrieved from it, then decrypted using the private key, and reencrypted using the recipient master password.
|
||||
|
||||
Once the key exchange is done, each user has their own copy of NOTEBOOK_KEY encrypted with their own master password. Public/Private Keys are only used to transfer NOTEBOOK_KEY.
|
||||
|
||||
Whenever any item within the notebook is encrypted, it is done with NOTEBOOK_KEY.
|
||||
Reference in New Issue
Block a user