Skip to content

Commit

Permalink
All, Server: Add support for sharing notes when E2EE is enabled (laur…
Browse files Browse the repository at this point in the history
  • Loading branch information
laurent22 committed Nov 3, 2021
1 parent a0d2304 commit af19865
Show file tree
Hide file tree
Showing 33 changed files with 699 additions and 225 deletions.
1 change: 0 additions & 1 deletion packages/app-desktop/app.ts
Expand Up @@ -558,7 +558,6 @@ class Application extends BaseApplication {
// });
// }, 2000);


// setTimeout(() => {
// this.dispatch({
// type: 'DIALOG_OPEN',
Expand Down
9 changes: 5 additions & 4 deletions packages/app-desktop/gui/MainScreen/MainScreen.tsx
Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`);
}
Expand Down
Expand Up @@ -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),
Expand Down
1 change: 0 additions & 1 deletion packages/app-desktop/main.scss
Expand Up @@ -162,7 +162,6 @@ h2 {
}
}


.form {
display: flex;
flex-direction: column;
Expand Down
5 changes: 3 additions & 2 deletions packages/app-desktop/services/share/invitationRespond.ts
Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion packages/app-mobile/root.tsx
Expand Up @@ -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...');

Expand Down
2 changes: 1 addition & 1 deletion packages/lib/BaseApplication.ts
Expand Up @@ -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() {
Expand Down
11 changes: 6 additions & 5 deletions packages/lib/JoplinDatabase.ts
Expand Up @@ -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);

Expand Down Expand Up @@ -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] };

Expand Down
8 changes: 6 additions & 2 deletions packages/lib/models/BaseItem.ts
Expand Up @@ -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');

Expand All @@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
type_: ModelType;
updated_time: number;
encryption_applied: number;
share_id: string;
}

export interface ItemsThatNeedSyncResult {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/models/Folder.ts
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion 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;
}
12 changes: 12 additions & 0 deletions packages/lib/services/database/types.ts
Expand Up @@ -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;
Expand All @@ -18,6 +23,10 @@ export interface BaseItemEntity {







// AUTO-GENERATED BY packages/tools/generate-database-types.js

/*
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -167,6 +178,7 @@ export interface ResourceEntity {
"size"?: number
"is_shared"?: number
"share_id"?: string
"master_key_id"?: string
"type_"?: number
}
export interface ResourcesToDownloadEntity {
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/services/e2ee/utils.test.ts
Expand Up @@ -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);
Expand Down
29 changes: 26 additions & 3 deletions packages/lib/services/e2ee/utils.ts
Expand Up @@ -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');

Expand Down Expand Up @@ -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;
Expand All @@ -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);
}

Expand Down

0 comments on commit af19865

Please sign in to comment.