diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index ca11968c2..055710f10 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -558,7 +558,6 @@ class Application extends BaseApplication { // }); // }, 2000); - // setTimeout(() => { // this.dispatch({ // type: 'DIALOG_OPEN', diff --git a/packages/app-desktop/gui/MainScreen/MainScreen.tsx b/packages/app-desktop/gui/MainScreen/MainScreen.tsx index 6ed8869e8..15e8d341a 100644 --- a/packages/app-desktop/gui/MainScreen/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen/MainScreen.tsx @@ -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 { 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 { 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( diff --git a/packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx b/packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx index ee7b635da..7edd51175 100644 --- a/packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx +++ b/packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx @@ -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}`); } diff --git a/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx b/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx index f111a4f59..a13ede553 100644 --- a/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx +++ b/packages/app-desktop/gui/ShareFolderDialog/ShareFolderDialog.tsx @@ -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), diff --git a/packages/app-desktop/main.scss b/packages/app-desktop/main.scss index 47d33f585..bd7fe2967 100644 --- a/packages/app-desktop/main.scss +++ b/packages/app-desktop/main.scss @@ -162,7 +162,6 @@ h2 { } } - .form { display: flex; flex-direction: column; diff --git a/packages/app-desktop/services/share/invitationRespond.ts b/packages/app-desktop/services/share/invitationRespond.ts index 1b065dac2..f42803e04 100644 --- a/packages/app-desktop/services/share/invitationRespond.ts +++ b/packages/app-desktop/services/share/invitationRespond.ts @@ -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)); diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index f6d0b61f3..376bf10e0 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -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...'); diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 905d4a95f..f543b3d0b 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -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() { diff --git a/packages/lib/JoplinDatabase.ts b/packages/lib/JoplinDatabase.ts index 6f0372a4f..dc572209e 100644 --- a/packages/lib/JoplinDatabase.ts +++ b/packages/lib/JoplinDatabase.ts @@ -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] }; diff --git a/packages/lib/models/BaseItem.ts b/packages/lib/models/BaseItem.ts index 60203820b..52401b56b 100644 --- a/packages/lib/models/BaseItem.ts +++ b/packages/lib/models/BaseItem.ts @@ -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); diff --git a/packages/lib/models/Folder.ts b/packages/lib/models/Folder.ts index c96fa27ef..04e23261b 100644 --- a/packages/lib/models/Folder.ts +++ b/packages/lib/models/Folder.ts @@ -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 { + return this.db().selectAll('SELECT id, share_id FROM folders WHERE master_key_id = ?', [keyId]); + } + public static async updateFolderShareIds(): Promise { // Get all the sub-folders of the shared folders, and set the share_id // property. diff --git a/packages/lib/models/utils/itemCanBeEncrypted.ts b/packages/lib/models/utils/itemCanBeEncrypted.ts index e565ea5ba..28acd4790 100644 --- a/packages/lib/models/utils/itemCanBeEncrypted.ts +++ b/packages/lib/models/utils/itemCanBeEncrypted.ts @@ -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; } diff --git a/packages/lib/services/database/types.ts b/packages/lib/services/database/types.ts index 250316a2d..6b1e1d0b7 100644 --- a/packages/lib/services/database/types.ts +++ b/packages/lib/services/database/types.ts @@ -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 { diff --git a/packages/lib/services/e2ee/utils.test.ts b/packages/lib/services/e2ee/utils.test.ts index 7ce9edb96..f2c6459af 100644 --- a/packages/lib/services/e2ee/utils.test.ts +++ b/packages/lib/services/e2ee/utils.test.ts @@ -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); diff --git a/packages/lib/services/e2ee/utils.ts b/packages/lib/services/e2ee/utils.ts index 20dcd5ca4..a3db517f8 100644 --- a/packages/lib/services/e2ee/utils.ts +++ b/packages/lib/services/e2ee/utils.ts @@ -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); } diff --git a/packages/lib/services/share/ShareService.test.ts b/packages/lib/services/share/ShareService.test.ts index 1dbe79ff2..7e565a515 100644 --- a/packages/lib/services/share/ShareService.test.ts +++ b/packages/lib/services/share/ShareService.test.ts @@ -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 = null, _body: any = null, _headers: any = null, _options: any = null): Promise => { - 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 = null, _body: any = null, _headers: any = null, _options: any = null): Promise => { + 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 = {}) { + return mockService({ + exec: async (method: string, path: string, query: Record, 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, body: any) => { + return { + id: 'share_1', + master_key_id: body.master_key_id, + }; + }, + 'GET api/users/toto%40example.com/public_key': async (_query: Record, _body: any) => { + return recipientPpk; + }, + 'POST api/shares/share_1/users': async (_query: Record, 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); + }); }); diff --git a/packages/lib/services/share/ShareService.ts b/packages/lib/services/share/ShareService.ts index 96074e62f..917482f35 100644 --- a/packages/lib/services/share/ShareService.ts +++ b/packages/lib/services/share/ShareService.ts @@ -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 = 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, api: JoplinServerApi = null) { + public initialize(store: Store, 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 { 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 { + 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 = {}; + 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 { 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, diff --git a/packages/lib/services/share/reducer.ts b/packages/lib/services/share/reducer.ts index 319dc28f7..e840d1f2f 100644 --- a/packages/lib/services/share/reducer.ts +++ b/packages/lib/services/share/reducer.ts @@ -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; } diff --git a/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts b/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts index e968ec849..003fac1f4 100644 --- a/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts +++ b/packages/lib/services/synchronizer/Synchronizer.e2ee.test.ts @@ -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(); + // } + // })); }); diff --git a/packages/server/src/migrations/20210824174024_share_users.ts b/packages/server/src/migrations/20210824174024_share_users.ts new file mode 100644 index 000000000..575e3a9ad --- /dev/null +++ b/packages/server/src/migrations/20210824174024_share_users.ts @@ -0,0 +1,22 @@ +import { Knex } from 'knex'; +import { DbConnection } from '../db'; + +export async function up(db: DbConnection): Promise { + 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 { + 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'); + }); +} diff --git a/packages/server/src/models/ShareModel.test.ts b/packages/server/src/models/ShareModel.test.ts index 77c7026d9..6cb12755e 100644 --- a/packages/server/src/models/ShareModel.test.ts +++ b/packages/server/src/models/ShareModel.test.ts @@ -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(); diff --git a/packages/server/src/models/ShareModel.ts b/packages/server/src/models/ShareModel.ts index 4b8a65aaa..0f896c39f 100644 --- a/packages/server/src/models/ShareModel.ts +++ b/packages/server/src/models/ShareModel.ts @@ -63,6 +63,7 @@ export default class ShareModel extends BaseModel { 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 { return query; } + public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise { + 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 { @@ -344,36 +359,38 @@ export default class ShareModel extends BaseModel { await this.models().userItem().addMulti(userId, query); } - public async shareFolder(owner: User, folderId: string): Promise { + public async shareFolder(owner: User, folderId: string, masterKeyId: string): Promise { 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 { + public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise { 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); diff --git a/packages/server/src/models/ShareUserModel.ts b/packages/server/src/models/ShareUserModel.ts index 05db72519..847d7257d 100644 --- a/packages/server/src/models/ShareUserModel.ts +++ b/packages/server/src/models/ShareUserModel.ts @@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel { 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 { + public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise { 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 { @@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel { .first(); } - public async addByEmail(shareId: Uuid, userEmail: string): Promise { + public async addByEmail(shareId: Uuid, userEmail: string, masterKey: string): Promise { 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 { return this.save({ share_id: shareId, user_id: user.id, + master_key: masterKey, }); } diff --git a/packages/server/src/models/UserModel.test.ts b/packages/server/src/models/UserModel.test.ts index 079c6bf44..eb75fa439 100644 --- a/packages/server/src/models/UserModel.test.ts +++ b/packages/server/src/models/UserModel.test.ts @@ -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); diff --git a/packages/server/src/models/UserModel.ts b/packages/server/src/models/UserModel.ts index 579f201a1..993d1ac2a 100644 --- a/packages/server/src/models/UserModel.ts +++ b/packages/server/src/models/UserModel.ts @@ -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 { return output; } + private async syncInfo(userId: Uuid): Promise { + 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 { + 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: // diff --git a/packages/server/src/routes/api/share_users.ts b/packages/server/src/routes/api/share_users.ts index 06576481e..b0549ee74 100644 --- a/packages/server/src/routes/api/share_users.ts +++ b/packages/server/src/routes/api/share_users.ts @@ -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, diff --git a/packages/server/src/routes/api/shares.ts b/packages/server/src/routes/api/shares.ts index 64386abe1..bead7bcfb 100644 --- a/packages/server/src/routes/api/shares.ts +++ b/packages/server/src/routes/api/shares.ts @@ -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(ctx.req); + const fields = await bodyFields(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: { diff --git a/packages/server/src/routes/api/users.ts b/packages/server/src/routes/api/users.ts index 2319bf184..53adcac25 100644 --- a/packages/server/src/routes/api/users.ts +++ b/packages/server/src/routes/api/users.ts @@ -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); diff --git a/packages/server/src/services/database/types.ts b/packages/server/src/services/database/types.ts index f744c0154..252e5155b 100644 --- a/packages/server/src/services/database/types.ts +++ b/packages/server/src/services/database/types.ts @@ -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' }, diff --git a/packages/server/src/utils/request/limiterLoginBruteForce.ts b/packages/server/src/utils/request/limiterLoginBruteForce.ts index d9c465edf..5478841e5 100644 --- a/packages/server/src/utils/request/limiterLoginBruteForce.ts +++ b/packages/server/src/utils/request/limiterLoginBruteForce.ts @@ -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) { diff --git a/packages/server/src/utils/routeUtils.ts b/packages/server/src/utils/routeUtils.ts index 3cde05f39..18919685f 100644 --- a/packages/server/src/utils/routeUtils.ts +++ b/packages/server/src/utils/routeUtils.ts @@ -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]; } diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts index 62ec7b388..d6620bd96 100644 --- a/packages/server/src/utils/testing/testUtils.ts +++ b/packages/server/src/utils/testing/testUtils.ts @@ -508,6 +508,7 @@ markup_language: 1 is_shared: 1 share_id: ${note.share_id || ''} conflict_original_id: +master_key_id: type_: 1`; } diff --git a/readme/spec/server_sharing_e2ee.md b/readme/spec/server_sharing_e2ee.md new file mode 100644 index 000000000..51a61ca91 --- /dev/null +++ b/readme/spec/server_sharing_e2ee.md @@ -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.