1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

All, Server: Add support for sharing notes when E2EE is enabled (#5529)

This commit is contained in:
Laurent 2021-11-03 16:24:40 +00:00 committed by GitHub
parent a0d23046bf
commit af19865865
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 668 additions and 194 deletions

View File

@ -558,7 +558,6 @@ class Application extends BaseApplication {
// }); // });
// }, 2000); // }, 2000);
// setTimeout(() => { // setTimeout(() => {
// this.dispatch({ // this.dispatch({
// type: 'DIALOG_OPEN', // type: 'DIALOG_OPEN',

View File

@ -37,6 +37,7 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
import ElectronAppWrapper from '../../ElectronAppWrapper'; import ElectronAppWrapper from '../../ElectronAppWrapper';
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils'; import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
import commands from './commands/index'; import commands from './commands/index';
import invitationRespond from '../../services/share/invitationRespond'; import invitationRespond from '../../services/share/invitationRespond';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
@ -564,8 +565,8 @@ class MainScreenComponent extends React.Component<Props, State> {
bridge().restart(); bridge().restart();
}; };
const onInvitationRespond = async (shareUserId: string, folderId: string, accept: boolean) => { const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
await invitationRespond(shareUserId, folderId, accept); await invitationRespond(shareUserId, folderId, masterKey, accept);
}; };
let msg = null; let msg = null;
@ -610,9 +611,9 @@ class MainScreenComponent extends React.Component<Props, State> {
msg = this.renderNotificationMessage( msg = this.renderNotificationMessage(
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email), _('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
_('Accept'), _('Accept'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, true), () => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
_('Reject'), _('Reject'),
() => onInvitationRespond(invitation.id, invitation.share.folder_id, false) () => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, false)
); );
} else if (this.props.hasDisabledSyncItems) { } else if (this.props.hasDisabledSyncItems) {
msg = this.renderNotificationMessage( msg = this.renderNotificationMessage(

View File

@ -10,6 +10,7 @@ import { getMasterPasswordStatus, getMasterPasswordStatusMessage, checkHasMaster
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService'; import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
import KvStore from '@joplin/lib/services/KvStore'; import KvStore from '@joplin/lib/services/KvStore';
import ShareService from '@joplin/lib/services/share/ShareService';
interface Props { interface Props {
themeId: number; themeId: number;
@ -60,7 +61,7 @@ export default function(props: Props) {
if (mode === Mode.Set) { if (mode === Mode.Set) {
await updateMasterPassword(currentPassword, password1); await updateMasterPassword(currentPassword, password1);
} else if (mode === Mode.Reset) { } else if (mode === Mode.Reset) {
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), password1); await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), ShareService.instance(), password1);
} else { } else {
throw new Error(`Unknown mode: ${mode}`); throw new Error(`Unknown mode: ${mode}`);
} }

View File

@ -171,7 +171,7 @@ function ShareFolderDialog(props: Props) {
try { try {
setLatestError(null); setLatestError(null);
const share = await ShareService.instance().shareFolder(props.folderId); 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([ await Promise.all([
ShareService.instance().refreshShares(), ShareService.instance().refreshShares(),
ShareService.instance().refreshShareUsers(share.id), ShareService.instance().refreshShareUsers(share.id),

View File

@ -162,7 +162,6 @@ h2 {
} }
} }
.form { .form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -3,17 +3,18 @@ import Logger from '@joplin/lib/Logger';
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
const logger = Logger.create('invitationRespond'); const logger = Logger.create('invitationRespond');
export default async function(shareUserId: string, folderId: string, accept: boolean) { export default async function(shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) {
// The below functions can take a bit of time to complete so in the // The below functions can take a bit of time to complete so in the
// meantime we hide the notification so that the user doesn't click // meantime we hide the notification so that the user doesn't click
// multiple times on the Accept link. // multiple times on the Accept link.
ShareService.instance().setProcessingShareInvitationResponse(true); ShareService.instance().setProcessingShareInvitationResponse(true);
try { try {
await ShareService.instance().respondInvitation(shareUserId, accept); await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message)); alert(_('Could not respond to the invitation. Please try again, or check with the notebook owner if they are still sharing it.\n\nThe error was: "%s"', error.message));

View File

@ -561,7 +561,7 @@ async function initialize(dispatch: Function) {
// / E2EE SETUP // / E2EE SETUP
// ---------------------------------------------------------------- // ----------------------------------------------------------------
await ShareService.instance().initialize(store); await ShareService.instance().initialize(store, EncryptionService.instance());
reg.logger().info('Loading folders...'); reg.logger().info('Loading folders...');

View File

@ -637,7 +637,7 @@ export default class BaseApplication {
BaseSyncTarget.dispatch = this.store().dispatch; BaseSyncTarget.dispatch = this.store().dispatch;
DecryptionWorker.instance().dispatch = this.store().dispatch; DecryptionWorker.instance().dispatch = this.store().dispatch;
ResourceFetcher.instance().dispatch = this.store().dispatch; ResourceFetcher.instance().dispatch = this.store().dispatch;
ShareService.instance().initialize(this.store()); ShareService.instance().initialize(this.store(), EncryptionService.instance());
} }
public deinitRedux() { public deinitRedux() {

View File

@ -351,7 +351,7 @@ export default class JoplinDatabase extends Database {
// must be set in the synchronizer too. // must be set in the synchronizer too.
// Note: v16 and v17 don't do anything. They were used to debug an issue. // Note: v16 and v17 don't do anything. They were used to debug an issue.
const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39]; const existingDatabaseVersions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40];
let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion); let currentVersionIndex = existingDatabaseVersions.indexOf(fromVersion);
@ -900,10 +900,11 @@ export default class JoplinDatabase extends Database {
queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""'); queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""');
} }
// if (targetVersion == 40) { if (targetVersion == 40) {
// queries.push('ALTER TABLE `folders` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""'); queries.push('ALTER TABLE `folders` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
// queries.push('ALTER TABLE `notes` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""'); queries.push('ALTER TABLE `notes` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
// } queries.push('ALTER TABLE `resources` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
}
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] }; const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };

View File

@ -10,7 +10,7 @@ import ItemChange from './ItemChange';
import ShareService from '../services/share/ShareService'; import ShareService from '../services/share/ShareService';
import itemCanBeEncrypted from './utils/itemCanBeEncrypted'; import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils'; import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
const JoplinError = require('../JoplinError.js'); import JoplinError from '../JoplinError';
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const moment = require('moment'); const moment = require('moment');
@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
type_: ModelType; type_: ModelType;
updated_time: number; updated_time: number;
encryption_applied: number; encryption_applied: number;
share_id: string;
} }
export interface ItemsThatNeedSyncResult { export interface ItemsThatNeedSyncResult {
@ -414,6 +415,7 @@ export default class BaseItem extends BaseModel {
const shownKeys = ItemClass.fieldNames(); const shownKeys = ItemClass.fieldNames();
shownKeys.push('type_'); shownKeys.push('type_');
const share = item.share_id ? await this.shareService().shareById(item.share_id) : null;
const serialized = await ItemClass.serialize(item, shownKeys); const serialized = await ItemClass.serialize(item, shownKeys);
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) { if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
@ -431,7 +433,9 @@ export default class BaseItem extends BaseModel {
let cipherText = null; let cipherText = null;
try { 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) { } catch (error) {
const msg = [`Could not encrypt item ${item.id}`]; const msg = [`Could not encrypt item ${item.id}`];
if (error && error.message) msg.push(error.message); if (error && error.message) msg.push(error.message);

View File

@ -285,6 +285,10 @@ export default class Folder extends BaseItem {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""'); return this.db().selectAll('SELECT id, share_id FROM folders WHERE parent_id = "" AND share_id != ""');
} }
public static async rootShareFoldersByKeyId(keyId: string): Promise<FolderEntity[]> {
return this.db().selectAll('SELECT id, share_id FROM folders WHERE master_key_id = ?', [keyId]);
}
public static async updateFolderShareIds(): Promise<void> { public static async updateFolderShareIds(): Promise<void> {
// Get all the sub-folders of the shared folders, and set the share_id // Get all the sub-folders of the shared folders, and set the share_id
// property. // property.

View File

@ -1,5 +1,5 @@
import { BaseItemEntity } from '../../services/database/types'; import { BaseItemEntity } from '../../services/database/types';
export default function(resource: BaseItemEntity): boolean { export default function(resource: BaseItemEntity): boolean {
return !resource.is_shared && !resource.share_id; return !resource.is_shared;
} }

View File

@ -3,7 +3,12 @@ import { ModelType } from "../../BaseModel";
export interface BaseItemEntity { export interface BaseItemEntity {
id?: string; id?: string;
encryption_applied?: number; encryption_applied?: number;
// Means the item (note or resource) is published
is_shared?: number; is_shared?: number;
// Means the item (note, folder or resource) is shared, as part of a shared
// notebook
share_id?: string; share_id?: string;
type_?: ModelType; type_?: ModelType;
updated_time?: number; updated_time?: number;
@ -18,6 +23,10 @@ export interface BaseItemEntity {
// AUTO-GENERATED BY packages/tools/generate-database-types.js // AUTO-GENERATED BY packages/tools/generate-database-types.js
/* /*
@ -50,6 +59,7 @@ export interface FolderEntity {
"parent_id"?: string "parent_id"?: string
"is_shared"?: number "is_shared"?: number
"share_id"?: string "share_id"?: string
"master_key_id"?: string
"type_"?: number "type_"?: number
} }
export interface ItemChangeEntity { export interface ItemChangeEntity {
@ -126,6 +136,7 @@ export interface NoteEntity {
"is_shared"?: number "is_shared"?: number
"share_id"?: string "share_id"?: string
"conflict_original_id"?: string "conflict_original_id"?: string
"master_key_id"?: string
"type_"?: number "type_"?: number
} }
export interface NotesNormalizedEntity { export interface NotesNormalizedEntity {
@ -167,6 +178,7 @@ export interface ResourceEntity {
"size"?: number "size"?: number
"is_shared"?: number "is_shared"?: number
"share_id"?: string "share_id"?: string
"master_key_id"?: string
"type_"?: number "type_"?: number
} }
export interface ResourcesToDownloadEntity { export interface ResourcesToDownloadEntity {

View File

@ -139,7 +139,7 @@ describe('e2ee/utils', function() {
setPpk(await generateKeyPair(encryptionService(), masterPassword1)); setPpk(await generateKeyPair(encryptionService(), masterPassword1));
const previousPpk = localSyncInfo().ppk; const previousPpk = localSyncInfo().ppk;
await resetMasterPassword(encryptionService(), kvStore(), masterPassword2); await resetMasterPassword(encryptionService(), kvStore(), null, masterPassword2);
expect(masterKeyEnabled(masterKeyById(mk1.id))).toBe(false); expect(masterKeyEnabled(masterKeyById(mk1.id))).toBe(false);
expect(masterKeyEnabled(masterKeyById(mk2.id))).toBe(false); expect(masterKeyEnabled(masterKeyById(mk2.id))).toBe(false);

View File

@ -8,6 +8,8 @@ import { getActiveMasterKey, getActiveMasterKeyId, localSyncInfo, masterKeyEnabl
import JoplinError from '../../JoplinError'; import JoplinError from '../../JoplinError';
import { generateKeyPair, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk'; import { generateKeyPair, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk';
import KvStore from '../KvStore'; import KvStore from '../KvStore';
import Folder from '../../models/Folder';
import ShareService from '../share/ShareService';
const logger = Logger.create('e2ee/utils'); const logger = Logger.create('e2ee/utils');
@ -240,7 +242,30 @@ export async function updateMasterPassword(currentPassword: string, newPassword:
Setting.setValue('encryption.masterPassword', newPassword); Setting.setValue('encryption.masterPassword', newPassword);
} }
export async function resetMasterPassword(encryptionService: EncryptionService, kvStore: KvStore, newPassword: string) { const unshareEncryptedFolders = async (shareService: ShareService, masterKeyId: string) => {
const rootFolders = await Folder.rootShareFoldersByKeyId(masterKeyId);
for (const folder of rootFolders) {
const isOwner = shareService.isSharedFolderOwner(folder.id);
if (isOwner) {
await shareService.unshareFolder(folder.id);
} else {
await shareService.leaveSharedFolder(folder.id);
}
}
};
export async function resetMasterPassword(encryptionService: EncryptionService, kvStore: KvStore, shareService: ShareService, newPassword: string) {
// First thing we do is to unshare all shared folders. If that fails, which
// may happen in particular if no connection is available, then we don't
// proceed. `unshareEncryptedFolders` will throw if something cannot be
// done.
if (shareService) {
for (const mk of localSyncInfo().masterKeys) {
if (!masterKeyEnabled(mk)) continue;
await unshareEncryptedFolders(shareService, mk.id);
}
}
for (const mk of localSyncInfo().masterKeys) { for (const mk of localSyncInfo().masterKeys) {
if (!masterKeyEnabled(mk)) continue; if (!masterKeyEnabled(mk)) continue;
mk.enabled = 0; mk.enabled = 0;
@ -254,8 +279,6 @@ export async function resetMasterPassword(encryptionService: EncryptionService,
saveLocalSyncInfo(syncInfo); saveLocalSyncInfo(syncInfo);
} }
// TODO: Unshare any folder associated with a disabled master key?
Setting.setValue('encryption.masterPassword', newPassword); Setting.setValue('encryption.masterPassword', newPassword);
} }

View File

@ -1,26 +1,20 @@
import Note from '../../models/Note'; 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 ShareService from './ShareService';
import reducer from '../../reducer'; import reducer from '../../reducer';
import { createStore } from 'redux'; import { createStore } from 'redux';
import { NoteEntity } from '../database/types'; import { FolderEntity, NoteEntity } from '../database/types';
import Folder from '../../models/Folder';
import { setEncryptionEnabled, setPpk } from '../synchronizer/syncInfoUtils';
import { generateKeyPair } from '../e2ee/ppk';
import MasterKey from '../../models/MasterKey';
import { MasterKeyEntity } from '../e2ee/types';
import { updateMasterPassword } from '../e2ee/utils';
function mockApi() { function mockService(api: any) {
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() {
const service = new ShareService(); const service = new ShareService();
const store = createStore(reducer as any); const store = createStore(reducer as any);
service.initialize(store, mockApi() as any); service.initialize(store, encryptionService(), api);
return service; return service;
} }
@ -32,9 +26,17 @@ describe('ShareService', function() {
done(); 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({}); 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 msleep(1);
await service.shareNote(note.id); await service.shareNote(note.id);
@ -61,6 +63,90 @@ describe('ShareService', function() {
const noteReloaded = await Note.load(note.id); const noteReloaded = await Note.load(note.id);
checkTimestamps(note, noteReloaded); 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);
await updateMasterPassword('', '111111');
const ppk = await generateKeyPair(encryptionService(), '111111');
setPpk(ppk);
await testShareFolder(testShareFolderService());
expect((await MasterKey.all()).length).toBe(1);
const mk = (await MasterKey.all())[0];
const folder: FolderEntity = (await Folder.all())[0];
expect(folder.master_key_id).toBe(mk.id);
});
it('should add a recipient', async () => {
setEncryptionEnabled(true);
await updateMasterPassword('', '111111');
const ppk = await generateKeyPair(encryptionService(), '111111');
setPpk(ppk);
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);
});
}); });

View File

@ -1,18 +1,41 @@
import { Store } from 'redux'; import { Store } from 'redux';
import JoplinServerApi from '../../JoplinServerApi'; import JoplinServerApi from '../../JoplinServerApi';
import { _ } from '../../locale';
import Logger from '../../Logger'; import Logger from '../../Logger';
import Folder from '../../models/Folder'; import Folder from '../../models/Folder';
import MasterKey from '../../models/MasterKey';
import Note from '../../models/Note'; import Note from '../../models/Note';
import Setting from '../../models/Setting'; 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'); 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 { export default class ShareService {
private static instance_: ShareService; private static instance_: ShareService;
private api_: JoplinServerApi = null; private api_: JoplinServerApi = null;
private store_: Store<any> = null; private store_: Store<any> = null;
private encryptionService_: EncryptionService = null;
private initialized_ = false; private initialized_ = false;
public static instance(): ShareService { public static instance(): ShareService {
@ -21,9 +44,10 @@ export default class ShareService {
return this.instance_; return this.instance_;
} }
public initialize(store: Store<any>, api: JoplinServerApi = null) { public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
this.initialized_ = true; this.initialized_ = true;
this.store_ = store; this.store_ = store;
this.encryptionService_ = encryptionService;
this.api_ = api; this.api_ = api;
} }
@ -59,15 +83,41 @@ export default class ShareService {
return this.api_; return this.api_;
} }
public async shareFolder(folderId: string) { public async shareFolder(folderId: string): Promise<ApiShare> {
const folder = await Folder.load(folderId); const folder = await Folder.load(folderId);
if (!folder) throw new Error(`No such folder: ${folderId}`); if (!folder) throw new Error(`No such folder: ${folderId}`);
if (folder.parent_id) { let folderMasterKey: MasterKeyEntity = null;
await Folder.save({ id: folder.id, parent_id: '' });
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.');
// TODO: handle "undefinedMasterPassword" error - show master password dialog
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 // Note: race condition if the share is created but the app crashes
// before setting share_id on the folder. See unshareFolder() for info. // before setting share_id on the folder. See unshareFolder() for info.
@ -181,6 +231,18 @@ export default class ShareService {
return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`; return `${this.api().personalizedUserContentBaseUrl(userId)}/shares/${share.id}`;
} }
public folderShare(folderId: string): StateShare {
return this.shares.find(s => s.folder_id === folderId);
}
public isSharedFolderOwner(folderId: string, userId: string = null): boolean {
if (userId === null) userId = this.userId;
const share = this.folderShare(folderId);
if (!share) throw new Error(`Cannot find share associated with folder: ${folderId}`);
return share.user.id === userId;
}
public get shares() { public get shares() {
return this.state.shares; return this.state.shares;
} }
@ -193,9 +255,34 @@ export default class ShareService {
return this.state.shareInvitations; 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`, {}, { return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
email: recipientEmail, email: recipientEmail,
master_key: JSON.stringify(recipientMasterKey),
}); });
} }
@ -226,8 +313,24 @@ export default class ShareService {
}); });
} }
public async respondInvitation(shareUserId: string, accept: boolean) { public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
logger.info('respondInvitation: ', shareUserId, accept);
if (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 }); await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
} else { } else {
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 }); await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
@ -237,15 +340,57 @@ export default class ShareService {
public async refreshShareInvitations() { public async refreshShareInvitations() {
const result = await this.loadShareInvitations(); const result = await this.loadShareInvitations();
const invitations = formatShareInvitations(result.items);
logger.info('Refresh share invitations:', invitations);
this.store.dispatch({ this.store.dispatch({
type: 'SHARE_INVITATION_SET', 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[]> { public async refreshShares(): Promise<StateShare[]> {
const result = await this.loadShares(); const result = await this.loadShares();
logger.info('Refreshed shares:', result);
this.store.dispatch({ this.store.dispatch({
type: 'SHARE_SET', type: 'SHARE_SET',
shares: result.items, shares: result.items,
@ -257,6 +402,8 @@ export default class ShareService {
public async refreshShareUsers(shareId: string) { public async refreshShareUsers(shareId: string) {
const result = await this.loadShareUsers(shareId); const result = await this.loadShareUsers(shareId);
logger.info('Refreshed share users:', result);
this.store.dispatch({ this.store.dispatch({
type: 'SHARE_USER_SET', type: 'SHARE_USER_SET',
shareId: shareId, shareId: shareId,

View File

@ -1,6 +1,7 @@
import { State as RootState } from '../../reducer'; import { State as RootState } from '../../reducer';
import { Draft } from 'immer'; import { Draft } from 'immer';
import { FolderEntity } from '../database/types'; import { FolderEntity } from '../database/types';
import { MasterKeyEntity } from '../e2ee/types';
interface StateShareUserUser { interface StateShareUserUser {
id: string; id: string;
@ -25,11 +26,13 @@ export interface StateShare {
type: number; type: number;
folder_id: string; folder_id: string;
note_id: string; note_id: string;
master_key_id: string;
user?: StateShareUserUser; user?: StateShareUserUser;
} }
export interface ShareInvitation { export interface ShareInvitation {
id: string; id: string;
master_key: MasterKeyEntity;
share: StateShare; share: StateShare;
status: ShareUserStatus; status: ShareUserStatus;
} }

View File

@ -1,14 +1,13 @@
import time from '../../time'; import time from '../../time';
import shim from '../../shim'; import shim from '../../shim';
import Setting from '../../models/Setting'; import Setting from '../../models/Setting';
import { createFolderTree, syncTargetName, synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } from '../../testing/test-utils'; import { synchronizerStart, allSyncTargetItemsEncrypted, kvStore, supportDir, setupDatabaseAndSynchronizer, synchronizer, fileApi, switchClient, encryptionService, loadEncryptionMasterKey, decryptionWorker, checkThrowAsync } from '../../testing/test-utils';
import Folder from '../../models/Folder'; import Folder from '../../models/Folder';
import Note from '../../models/Note'; import Note from '../../models/Note';
import Resource from '../../models/Resource'; import Resource from '../../models/Resource';
import ResourceFetcher from '../../services/ResourceFetcher'; import ResourceFetcher from '../../services/ResourceFetcher';
import MasterKey from '../../models/MasterKey'; import MasterKey from '../../models/MasterKey';
import BaseItem from '../../models/BaseItem'; import BaseItem from '../../models/BaseItem';
import { ResourceEntity } from '../database/types';
import Synchronizer from '../../Synchronizer'; import Synchronizer from '../../Synchronizer';
import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils'; import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils'; import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
@ -366,153 +365,153 @@ describe('Synchronizer.e2ee', function() {
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0); expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
})); }));
it('should not encrypt notes that are shared by link', (async () => { // it('should not encrypt notes that are shared by link', (async () => {
setEncryptionEnabled(true); // setEncryptionEnabled(true);
await loadEncryptionMasterKey(); // await loadEncryptionMasterKey();
await createFolderTree('', [ // await createFolderTree('', [
{ // {
title: 'folder1', // title: 'folder1',
children: [ // children: [
{ // {
title: 'un', // title: 'un',
}, // },
{ // {
title: 'deux', // title: 'deux',
}, // },
], // ],
}, // },
]); // ]);
let note1 = await Note.loadByTitle('un'); // let note1 = await Note.loadByTitle('un');
let note2 = await Note.loadByTitle('deux'); // let note2 = await Note.loadByTitle('deux');
await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`); // await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`); // await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
note1 = await Note.loadByTitle('un'); // note1 = await Note.loadByTitle('un');
note2 = await Note.loadByTitle('deux'); // note2 = await Note.loadByTitle('deux');
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0]; // const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
const resourceId2 = (await Note.linkedResourceIds(note2.body))[0]; // const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
await synchronizerStart(); // await synchronizerStart();
await switchClient(2); // await switchClient(2);
await synchronizerStart(); // await synchronizerStart();
await switchClient(1); // await switchClient(1);
const origNote2 = Object.assign({}, note2); // const origNote2 = Object.assign({}, note2);
await BaseItem.updateShareStatus(note2, true); // await BaseItem.updateShareStatus(note2, true);
note2 = await Note.load(note2.id); // note2 = await Note.load(note2.id);
// Sharing a note should not modify the timestamps // // Sharing a note should not modify the timestamps
expect(note2.user_updated_time).toBe(origNote2.user_updated_time); // expect(note2.user_updated_time).toBe(origNote2.user_updated_time);
expect(note2.user_created_time).toBe(origNote2.user_created_time); // expect(note2.user_created_time).toBe(origNote2.user_created_time);
await synchronizerStart(); // await synchronizerStart();
await switchClient(2); // await switchClient(2);
await synchronizerStart(); // await synchronizerStart();
// The shared note should be decrypted // // The shared note should be decrypted
const note2_2 = await Note.load(note2.id); // const note2_2 = await Note.load(note2.id);
expect(note2_2.title).toBe('deux'); // expect(note2_2.title).toBe('deux');
expect(note2_2.encryption_applied).toBe(0); // expect(note2_2.encryption_applied).toBe(0);
expect(note2_2.is_shared).toBe(1); // expect(note2_2.is_shared).toBe(1);
// The resource linked to the shared note should also be decrypted // // The resource linked to the shared note should also be decrypted
const resource2: ResourceEntity = await Resource.load(resourceId2); // const resource2: ResourceEntity = await Resource.load(resourceId2);
expect(resource2.is_shared).toBe(1); // expect(resource2.is_shared).toBe(1);
expect(resource2.encryption_applied).toBe(0); // expect(resource2.encryption_applied).toBe(0);
const fetcher = newResourceFetcher(synchronizer()); // const fetcher = newResourceFetcher(synchronizer());
await fetcher.start(); // await fetcher.start();
await fetcher.waitForAllFinished(); // await fetcher.waitForAllFinished();
// Because the resource is decrypted, the encrypted blob file should not // // Because the resource is decrypted, the encrypted blob file should not
// exist, but the plain text one should. // // exist, but the plain text one should.
expect(await shim.fsDriver().exists(Resource.fullPath(resource2, true))).toBe(false); // expect(await shim.fsDriver().exists(Resource.fullPath(resource2, true))).toBe(false);
expect(await shim.fsDriver().exists(Resource.fullPath(resource2))).toBe(true); // expect(await shim.fsDriver().exists(Resource.fullPath(resource2))).toBe(true);
// The non-shared note should be encrypted // // The non-shared note should be encrypted
const note1_2 = await Note.load(note1.id); // const note1_2 = await Note.load(note1.id);
expect(note1_2.title).toBe(''); // expect(note1_2.title).toBe('');
// The linked resource should also be encrypted // // The linked resource should also be encrypted
const resource1: ResourceEntity = await Resource.load(resourceId1); // const resource1: ResourceEntity = await Resource.load(resourceId1);
expect(resource1.is_shared).toBe(0); // expect(resource1.is_shared).toBe(0);
expect(resource1.encryption_applied).toBe(1); // expect(resource1.encryption_applied).toBe(1);
// And the plain text blob should not be present. The encrypted one // // And the plain text blob should not be present. The encrypted one
// shouldn't either because it can only be downloaded once the metadata // // shouldn't either because it can only be downloaded once the metadata
// has been decrypted. // // has been decrypted.
expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false); // expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false); // expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
})); // }));
it('should not encrypt items that are shared by folder', (async () => { // it('should not encrypt items that are shared by folder', (async () => {
// We skip this test for Joplin Server because it's going to check if // // We skip this test for Joplin Server because it's going to check if
// the share_id refers to an existing share. // // the share_id refers to an existing share.
if (syncTargetName() === 'joplinServer') { // if (syncTargetName() === 'joplinServer') {
expect(true).toBe(true); // expect(true).toBe(true);
return; // return;
} // }
setEncryptionEnabled(true); // setEncryptionEnabled(true);
await loadEncryptionMasterKey(); // await loadEncryptionMasterKey();
const folder1 = await createFolderTree('', [ // const folder1 = await createFolderTree('', [
{ // {
title: 'folder1', // title: 'folder1',
children: [ // children: [
{ // {
title: 'note1', // title: 'note1',
}, // },
], // ],
}, // },
{ // {
title: 'folder2', // title: 'folder2',
children: [ // children: [
{ // {
title: 'note2', // title: 'note2',
}, // },
], // ],
}, // },
]); // ]);
await synchronizerStart(); // await synchronizerStart();
await switchClient(2); // await switchClient(2);
await synchronizerStart(); // await synchronizerStart();
await switchClient(1); // await switchClient(1);
// Simulate that the folder has been shared // // Simulate that the folder has been shared
await Folder.save({ id: folder1.id, share_id: 'abcd' }); // await Folder.save({ id: folder1.id, share_id: 'abcd' });
await synchronizerStart(); // await synchronizerStart();
await switchClient(2); // await switchClient(2);
await synchronizerStart(); // await synchronizerStart();
// The shared items should be decrypted // // The shared items should be decrypted
{ // {
const folder1 = await Folder.loadByTitle('folder1'); // const folder1 = await Folder.loadByTitle('folder1');
const note1 = await Note.loadByTitle('note1'); // const note1 = await Note.loadByTitle('note1');
expect(folder1.title).toBe('folder1'); // expect(folder1.title).toBe('folder1');
expect(note1.title).toBe('note1'); // expect(note1.title).toBe('note1');
} // }
// The non-shared items should be encrypted // // The non-shared items should be encrypted
{ // {
const folder2 = await Folder.loadByTitle('folder2'); // const folder2 = await Folder.loadByTitle('folder2');
const note2 = await Note.loadByTitle('note2'); // const note2 = await Note.loadByTitle('note2');
expect(folder2).toBeFalsy(); // expect(folder2).toBeFalsy();
expect(note2).toBeFalsy(); // expect(note2).toBeFalsy();
} // }
})); // }));
}); });

View 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');
});
}

View File

@ -67,6 +67,20 @@ describe('ShareModel', function() {
expect(shares3.length).toBe(1); expect(shares3.length).toBe(1);
expect(shares3.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy(); 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() { 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 share1 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001'); const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
expect(share1.id).toBe(share2.id); 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'); const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
await models().item().delete(noteItem.id); await models().item().delete(noteItem.id);
expect(await models().item().load(noteItem.id)).toBeFalsy(); expect(await models().item().load(noteItem.id)).toBeFalsy();

View File

@ -63,6 +63,7 @@ export default class ShareModel extends BaseModel<Share> {
if (object.folder_id) output.folder_id = object.folder_id; if (object.folder_id) output.folder_id = object.folder_id;
if (object.owner_id) output.owner_id = object.owner_id; if (object.owner_id) output.owner_id = object.owner_id;
if (object.note_id) output.note_id = object.note_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; return output;
} }
@ -151,6 +152,20 @@ export default class ShareModel extends BaseModel<Share> {
return query; 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 // 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. // the folder has been shared with, as well as the folder owner.
public async allShareUserIds(share: Share): Promise<Uuid[]> { public async allShareUserIds(share: Share): Promise<Uuid[]> {
@ -344,36 +359,38 @@ export default class ShareModel extends BaseModel<Share> {
await this.models().userItem().addMulti(userId, query); await this.models().userItem().addMulti(userId, query);
} }
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); const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`); if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id); const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
if (share) return share; if (share) return share;
const shareToSave = { const shareToSave: Share = {
type: ShareType.Folder, type: ShareType.Folder,
item_id: folderItem.id, item_id: folderItem.id,
owner_id: owner.id, owner_id: owner.id,
folder_id: folderId, folder_id: folderId,
master_key_id: masterKeyId,
}; };
await this.checkIfAllowed(owner, AclAction.Create, shareToSave); await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
return super.save(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); const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`); if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
const existingShare = await this.byItemId(noteItem.id); const existingShare = await this.byItemId(noteItem.id);
if (existingShare) return existingShare; if (existingShare) return existingShare;
const shareToSave = { const shareToSave: Share = {
type: ShareType.Note, type: ShareType.Note,
item_id: noteItem.id, item_id: noteItem.id,
owner_id: owner.id, owner_id: owner.id,
note_id: noteId, note_id: noteId,
master_key_id: masterKeyId,
}; };
await this.checkIfAllowed(owner, AclAction.Create, shareToSave); await this.checkIfAllowed(owner, AclAction.Create, shareToSave);

View File

@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
return this.db(this.tableName).where(link).first(); return this.db(this.tableName).where(link).first();
} }
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) { public async shareWithUserAndAccept(share: Share, shareeId: Uuid, masterKey: string = '') {
await this.models().shareUser().addById(share.id, shareeId); await this.models().shareUser().addById(share.id, shareeId, masterKey);
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted); 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); 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> { public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
.first(); .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); const share = await this.models().share().load(shareId);
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`); if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
@ -110,6 +110,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
return this.save({ return this.save({
share_id: shareId, share_id: shareId,
user_id: user.id, user_id: user.id,
master_key: masterKey,
}); });
} }

View File

@ -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 { EmailSender, User, UserFlagType } from '../services/database/types';
import { ErrorUnprocessableEntity } from '../utils/errors'; import { ErrorUnprocessableEntity } from '../utils/errors';
import { betaUserDateRange, stripeConfig } from '../utils/stripe'; import { betaUserDateRange, stripeConfig } from '../utils/stripe';
@ -236,6 +236,35 @@ describe('UserModel', function() {
stripeConfig().enabled = false; stripeConfig().enabled = false;
}); });
test('should disable disable the account and send an email if payment failed for good', async () => {
stripeConfig().enabled = true;
const { user: user1 } = await models().subscription().saveUserAndSubscription('toto@example.com', 'Toto', AccountType.Basic, 'usr_111', 'sub_111');
const sub = await models().subscription().byUserId(user1.id);
const now = Date.now();
const paymentFailedTime = now - failedPaymentFinalAccount - 10;
await models().subscription().save({
id: sub.id,
last_payment_time: now - failedPaymentFinalAccount * 2,
last_payment_failed_time: paymentFailedTime,
});
await models().user().handleFailedPaymentSubscriptions();
{
const user1 = await models().user().loadByEmail('toto@example.com');
expect(user1.enabled).toBe(0);
const email = (await models().email().all()).pop();
expect(email.key).toBe(`payment_failed_account_disabled_${paymentFailedTime}`);
expect(email.body).toContain(stripePortalUrl());
}
stripeConfig().enabled = false;
});
test('should send emails and flag accounts when it is over the size limit', async () => { test('should send emails and flag accounts when it is over the size limit', async () => {
const { user: user1 } = await createUserAndSession(1); const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2); const { user: user2 } = await createUserAndSession(2);
@ -301,6 +330,58 @@ 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().publicPrivateKey(user1.id)).publicKey).toBe('PUBLIC_KEY_1');
expect((await models().user().publicPrivateKey(user2.id)).publicKey).toBe('PUBLIC_KEY_2');
expect((await models().user().publicPrivateKey(user3.id))).toBeFalsy();
await expectThrow(async () => models().user().publicPrivateKey(user4.id));
});
test('should remove flag when account goes under the limit', async () => { test('should remove flag when account goes under the limit', async () => {
const { user: user1 } = await createUserAndSession(1); const { user: user1 } = await createUserAndSession(1);

View File

@ -15,6 +15,7 @@ import resetPasswordTemplate from '../views/emails/resetPasswordTemplate';
import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe'; import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
import endOfBetaTemplate from '../views/emails/endOfBetaTemplate'; import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
import Logger from '@joplin/lib/Logger'; import Logger from '@joplin/lib/Logger';
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate'; import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate';
import oversizedAccount1 from '../views/emails/oversizedAccount1'; import oversizedAccount1 from '../views/emails/oversizedAccount1';
import oversizedAccount2 from '../views/emails/oversizedAccount2'; import oversizedAccount2 from '../views/emails/oversizedAccount2';
@ -583,6 +584,18 @@ export default class UserModel extends BaseModel<User> {
return output; 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 // Note that when the "password" property is provided, it is going to be
// hashed automatically. It means that it is not safe to do: // hashed automatically. It means that it is not safe to do:
// //

View File

@ -43,6 +43,7 @@ router.get('api/share_users', async (_path: SubPath, ctx: AppContext) => {
items.push({ items.push({
id: su.id, id: su.id,
status: su.status, status: su.status,
master_key: su.master_key,
share: { share: {
id: share.id, id: share.id,
folder_id: share.folder_id, folder_id: share.folder_id,

View File

@ -19,11 +19,18 @@ router.public = true;
router.post('api/shares', async (_path: SubPath, ctx: AppContext) => { router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
ownerRequired(ctx); ownerRequired(ctx);
interface Fields {
folder_id?: string;
note_id?: string;
master_key_id?: string;
}
const shareModel = ctx.joplin.models.share(); 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; const shareInput: ShareApiInput = shareModel.fromApiInput(fields) as ShareApiInput;
if (fields.folder_id) shareInput.folder_id = fields.folder_id; if (fields.folder_id) shareInput.folder_id = fields.folder_id;
if (fields.note_id) shareInput.note_id = fields.note_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: // - The API end point should only expose two ways of sharing:
// - By folder_id (JoplinRootFolder) // - 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. // - Additionally, the App method is available, but not exposed via the API.
if (shareInput.folder_id) { 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) { } 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 { } else {
throw new ErrorBadRequest('Either folder_id or note_id must be provided'); 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 { interface UserInput {
email: string; email: string;
master_key?: string;
} }
const fields = await bodyFields(ctx.req) as UserInput; const fields = await bodyFields(ctx.req) as UserInput;
const user = await ctx.joplin.models.user().loadByEmail(fields.email); const user = await ctx.joplin.models.user().loadByEmail(fields.email);
if (!user) throw new ErrorNotFound('User not found'); if (!user) throw new ErrorNotFound('User not found');
const masterKey = fields.master_key || '';
const shareId = path.id; const shareId = path.id;
await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, { await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, {
share_id: shareId, share_id: shareId,
user_id: user.id, 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) => { 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(); 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) => { router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
ownerRequired(ctx); 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. // Fake paginated results so that it can be added later on, if needed.
return { return {
items: shares.map(share => { items: ownedShares.concat(participatedShares).map(share => {
return { return {
...share, ...share,
user: { user: {

View File

@ -26,6 +26,21 @@ router.get('api/users/:id', async (path: SubPath, ctx: AppContext) => {
return user; 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) => { router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create); await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create);
const user = await postedUserFromContext(ctx); const user = await postedUserFromContext(ctx);

View File

@ -143,6 +143,7 @@ export interface ShareUser extends WithDates, WithUuid {
share_id?: Uuid; share_id?: Uuid;
user_id?: Uuid; user_id?: Uuid;
status?: ShareUserStatus; status?: ShareUserStatus;
master_key?: string;
} }
export interface UserItem extends WithDates { export interface UserItem extends WithDates {
@ -170,6 +171,7 @@ export interface Share extends WithDates, WithUuid {
type?: ShareType; type?: ShareType;
folder_id?: Uuid; folder_id?: Uuid;
note_id?: Uuid; note_id?: Uuid;
master_key_id?: Uuid;
} }
export interface Change extends WithDates, WithUuid { export interface Change extends WithDates, WithUuid {
@ -307,6 +309,7 @@ export const databaseSchema: DatabaseTables = {
status: { type: 'number' }, status: { type: 'number' },
updated_time: { type: 'string' }, updated_time: { type: 'string' },
created_time: { type: 'string' }, created_time: { type: 'string' },
master_key: { type: 'string' },
}, },
user_items: { user_items: {
id: { type: 'number' }, id: { type: 'number' },
@ -335,6 +338,7 @@ export const databaseSchema: DatabaseTables = {
created_time: { type: 'string' }, created_time: { type: 'string' },
folder_id: { type: 'string' }, folder_id: { type: 'string' },
note_id: { type: 'string' }, note_id: { type: 'string' },
master_key_id: { type: 'string' },
}, },
changes: { changes: {
counter: { type: 'number' }, counter: { type: 'number' },

View File

@ -2,8 +2,8 @@ import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
import { ErrorTooManyRequests } from '../errors'; import { ErrorTooManyRequests } from '../errors';
const limiterSlowBruteByIP = new RateLimiterMemory({ const limiterSlowBruteByIP = new RateLimiterMemory({
points: 3, // Up to 3 requests per IP points: 10, // Up to 10 requests per IP
duration: 30, // Per 30 seconds duration: 60, // Per 60 seconds
}); });
export default async function(ip: string) { export default async function(ip: string) {

View File

@ -136,12 +136,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
if (colonIndex2 < 0) { if (colonIndex2 < 0) {
throw new ErrorBadRequest(`Invalid path format: ${p}`); throw new ErrorBadRequest(`Invalid path format: ${p}`);
} else { } else {
output.id = p.substr(0, colonIndex2 + 1); output.id = decodeURIComponent(p.substr(0, colonIndex2 + 1));
output.link = ltrimSlashes(p.substr(colonIndex2 + 1)); output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
} }
} else { } else {
const s = p.split('/'); 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]; if (s.length >= 2) output.link = s[1];
} }

View File

@ -508,6 +508,7 @@ markup_language: 1
is_shared: 1 is_shared: 1
share_id: ${note.share_id || ''} share_id: ${note.share_id || ''}
conflict_original_id: conflict_original_id:
master_key_id:
type_: 1`; type_: 1`;
} }

View 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.