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:
parent
a0d23046bf
commit
af19865865
@ -558,7 +558,6 @@ class Application extends BaseApplication {
|
||||
// });
|
||||
// }, 2000);
|
||||
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
|
@ -37,6 +37,7 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
|
||||
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import ElectronAppWrapper from '../../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
|
||||
import commands from './commands/index';
|
||||
import invitationRespond from '../../services/share/invitationRespond';
|
||||
const { connect } = require('react-redux');
|
||||
@ -564,8 +565,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, folderId: string, accept: boolean) => {
|
||||
await invitationRespond(shareUserId, folderId, accept);
|
||||
const onInvitationRespond = async (shareUserId: string, folderId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||
await invitationRespond(shareUserId, folderId, masterKey, accept);
|
||||
};
|
||||
|
||||
let msg = null;
|
||||
@ -610,9 +611,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, invitation.share.folder_id, true),
|
||||
() => onInvitationRespond(invitation.id, invitation.share.folder_id, invitation.master_key, true),
|
||||
_('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) {
|
||||
msg = this.renderNotificationMessage(
|
||||
|
@ -10,6 +10,7 @@ import { getMasterPasswordStatus, getMasterPasswordStatusMessage, checkHasMaster
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import KvStore from '@joplin/lib/services/KvStore';
|
||||
import ShareService from '@joplin/lib/services/share/ShareService';
|
||||
|
||||
interface Props {
|
||||
themeId: number;
|
||||
@ -60,7 +61,7 @@ export default function(props: Props) {
|
||||
if (mode === Mode.Set) {
|
||||
await updateMasterPassword(currentPassword, password1);
|
||||
} else if (mode === Mode.Reset) {
|
||||
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), password1);
|
||||
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), ShareService.instance(), password1);
|
||||
} else {
|
||||
throw new Error(`Unknown mode: ${mode}`);
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -162,7 +162,6 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -3,17 +3,18 @@ import Logger from '@joplin/lib/Logger';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
|
||||
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
|
||||
// meantime we hide the notification so that the user doesn't click
|
||||
// multiple times on the Accept link.
|
||||
ShareService.instance().setProcessingShareInvitationResponse(true);
|
||||
|
||||
try {
|
||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||
} catch (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));
|
||||
|
@ -561,7 +561,7 @@ async function initialize(dispatch: Function) {
|
||||
// / E2EE SETUP
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
await ShareService.instance().initialize(store);
|
||||
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||
|
||||
reg.logger().info('Loading folders...');
|
||||
|
||||
|
@ -637,7 +637,7 @@ export default class BaseApplication {
|
||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||
DecryptionWorker.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() {
|
||||
|
@ -351,7 +351,7 @@ export default class JoplinDatabase extends Database {
|
||||
// must be set in the synchronizer too.
|
||||
|
||||
// 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);
|
||||
|
||||
@ -900,10 +900,11 @@ export default class JoplinDatabase extends Database {
|
||||
queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""');
|
||||
}
|
||||
|
||||
// if (targetVersion == 40) {
|
||||
// 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 ""');
|
||||
// }
|
||||
if (targetVersion == 40) {
|
||||
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 `resources` ADD COLUMN master_key_id TEXT NOT NULL DEFAULT ""');
|
||||
}
|
||||
|
||||
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
||||
|
||||
|
@ -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 {
|
||||
@ -414,6 +415,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)) {
|
||||
@ -431,7 +433,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);
|
||||
|
@ -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 != ""');
|
||||
}
|
||||
|
||||
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> {
|
||||
// Get all the sub-folders of the shared folders, and set the share_id
|
||||
// property.
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { BaseItemEntity } from '../../services/database/types';
|
||||
|
||||
export default function(resource: BaseItemEntity): boolean {
|
||||
return !resource.is_shared && !resource.share_id;
|
||||
return !resource.is_shared;
|
||||
}
|
||||
|
@ -3,7 +3,12 @@ import { ModelType } from "../../BaseModel";
|
||||
export interface BaseItemEntity {
|
||||
id?: string;
|
||||
encryption_applied?: number;
|
||||
|
||||
// Means the item (note or resource) is published
|
||||
is_shared?: number;
|
||||
|
||||
// Means the item (note, folder or resource) is shared, as part of a shared
|
||||
// notebook
|
||||
share_id?: string;
|
||||
type_?: ModelType;
|
||||
updated_time?: number;
|
||||
@ -18,6 +23,10 @@ export interface BaseItemEntity {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
||||
|
||||
/*
|
||||
@ -50,6 +59,7 @@ export interface FolderEntity {
|
||||
"parent_id"?: string
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"master_key_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ItemChangeEntity {
|
||||
@ -126,6 +136,7 @@ export interface NoteEntity {
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"conflict_original_id"?: string
|
||||
"master_key_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NotesNormalizedEntity {
|
||||
@ -167,6 +178,7 @@ export interface ResourceEntity {
|
||||
"size"?: number
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"master_key_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ResourcesToDownloadEntity {
|
||||
|
@ -139,7 +139,7 @@ describe('e2ee/utils', function() {
|
||||
setPpk(await generateKeyPair(encryptionService(), masterPassword1));
|
||||
|
||||
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(mk2.id))).toBe(false);
|
||||
|
@ -8,6 +8,8 @@ import { getActiveMasterKey, getActiveMasterKeyId, localSyncInfo, masterKeyEnabl
|
||||
import JoplinError from '../../JoplinError';
|
||||
import { generateKeyPair, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk';
|
||||
import KvStore from '../KvStore';
|
||||
import Folder from '../../models/Folder';
|
||||
import ShareService from '../share/ShareService';
|
||||
|
||||
const logger = Logger.create('e2ee/utils');
|
||||
|
||||
@ -240,7 +242,30 @@ export async function updateMasterPassword(currentPassword: string, 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) {
|
||||
if (!masterKeyEnabled(mk)) continue;
|
||||
mk.enabled = 0;
|
||||
@ -254,8 +279,6 @@ export async function resetMasterPassword(encryptionService: EncryptionService,
|
||||
saveLocalSyncInfo(syncInfo);
|
||||
}
|
||||
|
||||
// TODO: Unshare any folder associated with a disabled master key?
|
||||
|
||||
Setting.setValue('encryption.masterPassword', newPassword);
|
||||
}
|
||||
|
||||
|
@ -1,26 +1,20 @@
|
||||
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 { 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() {
|
||||
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 +26,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 +63,90 @@ 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);
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
@ -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;
|
||||
private initialized_ = false;
|
||||
|
||||
public static instance(): ShareService {
|
||||
@ -21,9 +44,10 @@ 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.initialized_ = true;
|
||||
this.store_ = store;
|
||||
this.encryptionService_ = encryptionService;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
@ -59,15 +83,41 @@ 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.');
|
||||
|
||||
// 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
|
||||
// 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}`;
|
||||
}
|
||||
|
||||
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() {
|
||||
return this.state.shares;
|
||||
}
|
||||
@ -193,9 +255,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),
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 (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 });
|
||||
@ -237,15 +340,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,
|
||||
@ -257,6 +402,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,14 +1,13 @@
|
||||
import time from '../../time';
|
||||
import shim from '../../shim';
|
||||
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 Note from '../../models/Note';
|
||||
import Resource from '../../models/Resource';
|
||||
import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import { ResourceEntity } from '../database/types';
|
||||
import Synchronizer from '../../Synchronizer';
|
||||
import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
|
||||
@ -366,153 +365,153 @@ describe('Synchronizer.e2ee', function() {
|
||||
expect((await decryptionWorker().decryptionDisabledItems()).length).toBe(0);
|
||||
}));
|
||||
|
||||
it('should not encrypt notes that are shared by link', (async () => {
|
||||
setEncryptionEnabled(true);
|
||||
await loadEncryptionMasterKey();
|
||||
// it('should not encrypt notes that are shared by link', (async () => {
|
||||
// setEncryptionEnabled(true);
|
||||
// await loadEncryptionMasterKey();
|
||||
|
||||
await createFolderTree('', [
|
||||
{
|
||||
title: 'folder1',
|
||||
children: [
|
||||
{
|
||||
title: 'un',
|
||||
},
|
||||
{
|
||||
title: 'deux',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
// await createFolderTree('', [
|
||||
// {
|
||||
// title: 'folder1',
|
||||
// children: [
|
||||
// {
|
||||
// title: 'un',
|
||||
// },
|
||||
// {
|
||||
// title: 'deux',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// ]);
|
||||
|
||||
let note1 = await Note.loadByTitle('un');
|
||||
let note2 = await Note.loadByTitle('deux');
|
||||
await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
|
||||
await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
|
||||
note1 = await Note.loadByTitle('un');
|
||||
note2 = await Note.loadByTitle('deux');
|
||||
const resourceId1 = (await Note.linkedResourceIds(note1.body))[0];
|
||||
const resourceId2 = (await Note.linkedResourceIds(note2.body))[0];
|
||||
// let note1 = await Note.loadByTitle('un');
|
||||
// let note2 = await Note.loadByTitle('deux');
|
||||
// await shim.attachFileToNote(note1, `${supportDir}/photo.jpg`);
|
||||
// await shim.attachFileToNote(note2, `${supportDir}/photo.jpg`);
|
||||
// note1 = await Note.loadByTitle('un');
|
||||
// note2 = await Note.loadByTitle('deux');
|
||||
// const resourceId1 = (await Note.linkedResourceIds(note1.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);
|
||||
await BaseItem.updateShareStatus(note2, true);
|
||||
note2 = await Note.load(note2.id);
|
||||
// const origNote2 = Object.assign({}, note2);
|
||||
// await BaseItem.updateShareStatus(note2, true);
|
||||
// note2 = await Note.load(note2.id);
|
||||
|
||||
// Sharing a note should not modify the timestamps
|
||||
expect(note2.user_updated_time).toBe(origNote2.user_updated_time);
|
||||
expect(note2.user_created_time).toBe(origNote2.user_created_time);
|
||||
// // Sharing a note should not modify the timestamps
|
||||
// expect(note2.user_updated_time).toBe(origNote2.user_updated_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
|
||||
const note2_2 = await Note.load(note2.id);
|
||||
expect(note2_2.title).toBe('deux');
|
||||
expect(note2_2.encryption_applied).toBe(0);
|
||||
expect(note2_2.is_shared).toBe(1);
|
||||
// // The shared note should be decrypted
|
||||
// const note2_2 = await Note.load(note2.id);
|
||||
// expect(note2_2.title).toBe('deux');
|
||||
// expect(note2_2.encryption_applied).toBe(0);
|
||||
// expect(note2_2.is_shared).toBe(1);
|
||||
|
||||
// The resource linked to the shared note should also be decrypted
|
||||
const resource2: ResourceEntity = await Resource.load(resourceId2);
|
||||
expect(resource2.is_shared).toBe(1);
|
||||
expect(resource2.encryption_applied).toBe(0);
|
||||
// // The resource linked to the shared note should also be decrypted
|
||||
// const resource2: ResourceEntity = await Resource.load(resourceId2);
|
||||
// expect(resource2.is_shared).toBe(1);
|
||||
// expect(resource2.encryption_applied).toBe(0);
|
||||
|
||||
const fetcher = newResourceFetcher(synchronizer());
|
||||
await fetcher.start();
|
||||
await fetcher.waitForAllFinished();
|
||||
// const fetcher = newResourceFetcher(synchronizer());
|
||||
// await fetcher.start();
|
||||
// await fetcher.waitForAllFinished();
|
||||
|
||||
// Because the resource is decrypted, the encrypted blob file should not
|
||||
// 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))).toBe(true);
|
||||
// // Because the resource is decrypted, the encrypted blob file should not
|
||||
// // 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))).toBe(true);
|
||||
|
||||
// The non-shared note should be encrypted
|
||||
const note1_2 = await Note.load(note1.id);
|
||||
expect(note1_2.title).toBe('');
|
||||
// // The non-shared note should be encrypted
|
||||
// const note1_2 = await Note.load(note1.id);
|
||||
// expect(note1_2.title).toBe('');
|
||||
|
||||
// The linked resource should also be encrypted
|
||||
const resource1: ResourceEntity = await Resource.load(resourceId1);
|
||||
expect(resource1.is_shared).toBe(0);
|
||||
expect(resource1.encryption_applied).toBe(1);
|
||||
// // The linked resource should also be encrypted
|
||||
// const resource1: ResourceEntity = await Resource.load(resourceId1);
|
||||
// expect(resource1.is_shared).toBe(0);
|
||||
// expect(resource1.encryption_applied).toBe(1);
|
||||
|
||||
// And the plain text blob should not be present. The encrypted one
|
||||
// shouldn't either because it can only be downloaded once the metadata
|
||||
// has been decrypted.
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
|
||||
expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
|
||||
}));
|
||||
// // And the plain text blob should not be present. The encrypted one
|
||||
// // shouldn't either because it can only be downloaded once the metadata
|
||||
// // has been decrypted.
|
||||
// expect(await shim.fsDriver().exists(Resource.fullPath(resource1, true))).toBe(false);
|
||||
// expect(await shim.fsDriver().exists(Resource.fullPath(resource1))).toBe(false);
|
||||
// }));
|
||||
|
||||
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
|
||||
// the share_id refers to an existing share.
|
||||
if (syncTargetName() === 'joplinServer') {
|
||||
expect(true).toBe(true);
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
// // the share_id refers to an existing share.
|
||||
// if (syncTargetName() === 'joplinServer') {
|
||||
// expect(true).toBe(true);
|
||||
// return;
|
||||
// }
|
||||
|
||||
setEncryptionEnabled(true);
|
||||
await loadEncryptionMasterKey();
|
||||
// setEncryptionEnabled(true);
|
||||
// await loadEncryptionMasterKey();
|
||||
|
||||
const folder1 = await createFolderTree('', [
|
||||
{
|
||||
title: 'folder1',
|
||||
children: [
|
||||
{
|
||||
title: 'note1',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'folder2',
|
||||
children: [
|
||||
{
|
||||
title: 'note2',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
// const folder1 = await createFolderTree('', [
|
||||
// {
|
||||
// title: 'folder1',
|
||||
// children: [
|
||||
// {
|
||||
// title: 'note1',
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// title: 'folder2',
|
||||
// children: [
|
||||
// {
|
||||
// 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
|
||||
await Folder.save({ id: folder1.id, share_id: 'abcd' });
|
||||
// // Simulate that the folder has been shared
|
||||
// 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
|
||||
{
|
||||
const folder1 = await Folder.loadByTitle('folder1');
|
||||
const note1 = await Note.loadByTitle('note1');
|
||||
expect(folder1.title).toBe('folder1');
|
||||
expect(note1.title).toBe('note1');
|
||||
}
|
||||
// // The shared items should be decrypted
|
||||
// {
|
||||
// const folder1 = await Folder.loadByTitle('folder1');
|
||||
// const note1 = await Note.loadByTitle('note1');
|
||||
// expect(folder1.title).toBe('folder1');
|
||||
// expect(note1.title).toBe('note1');
|
||||
// }
|
||||
|
||||
// The non-shared items should be encrypted
|
||||
{
|
||||
const folder2 = await Folder.loadByTitle('folder2');
|
||||
const note2 = await Note.loadByTitle('note2');
|
||||
expect(folder2).toBeFalsy();
|
||||
expect(note2).toBeFalsy();
|
||||
}
|
||||
}));
|
||||
// // The non-shared items should be encrypted
|
||||
// {
|
||||
// const folder2 = await Folder.loadByTitle('folder2');
|
||||
// const note2 = await Note.loadByTitle('note2');
|
||||
// expect(folder2).toBeFalsy();
|
||||
// expect(note2).toBeFalsy();
|
||||
// }
|
||||
// }));
|
||||
|
||||
});
|
||||
|
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();
|
||||
|
@ -63,6 +63,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;
|
||||
}
|
||||
@ -151,6 +152,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[]> {
|
||||
@ -344,36 +359,38 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
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);
|
||||
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';
|
||||
@ -236,6 +236,35 @@ describe('UserModel', function() {
|
||||
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 () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
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 () => {
|
||||
const { user: user1 } = await createUserAndSession(1);
|
||||
|
||||
|
@ -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';
|
||||
@ -583,6 +584,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);
|
||||
|
@ -143,6 +143,7 @@ export interface ShareUser extends WithDates, WithUuid {
|
||||
share_id?: Uuid;
|
||||
user_id?: Uuid;
|
||||
status?: ShareUserStatus;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
export interface UserItem extends WithDates {
|
||||
@ -170,6 +171,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 {
|
||||
@ -307,6 +309,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
status: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
master_key: { type: 'string' },
|
||||
},
|
||||
user_items: {
|
||||
id: { type: 'number' },
|
||||
@ -335,6 +338,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) {
|
||||
|
@ -136,12 +136,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];
|
||||
}
|
||||
|
||||
|
@ -508,6 +508,7 @@ markup_language: 1
|
||||
is_shared: 1
|
||||
share_id: ${note.share_id || ''}
|
||||
conflict_original_id:
|
||||
master_key_id:
|
||||
type_: 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.
|
Loading…
Reference in New Issue
Block a user