You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-11 23:17:19 +02:00
Compare commits
2 Commits
android-v3
...
sharing_e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d1651055e | ||
|
|
81b2164d84 |
@@ -328,6 +328,9 @@ packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js.map
|
|||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.d.ts
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js.map
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js.map
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.d.ts
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js.map
|
||||||
packages/app-desktop/gui/MenuBar.d.ts
|
packages/app-desktop/gui/MenuBar.d.ts
|
||||||
packages/app-desktop/gui/MenuBar.js
|
packages/app-desktop/gui/MenuBar.js
|
||||||
packages/app-desktop/gui/MenuBar.js.map
|
packages/app-desktop/gui/MenuBar.js.map
|
||||||
@@ -928,6 +931,9 @@ packages/lib/commands/historyForward.js.map
|
|||||||
packages/lib/commands/index.d.ts
|
packages/lib/commands/index.d.ts
|
||||||
packages/lib/commands/index.js
|
packages/lib/commands/index.js
|
||||||
packages/lib/commands/index.js.map
|
packages/lib/commands/index.js.map
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.d.ts
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.js
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.js.map
|
||||||
packages/lib/commands/synchronize.d.ts
|
packages/lib/commands/synchronize.d.ts
|
||||||
packages/lib/commands/synchronize.js
|
packages/lib/commands/synchronize.js
|
||||||
packages/lib/commands/synchronize.js.map
|
packages/lib/commands/synchronize.js.map
|
||||||
@@ -1231,6 +1237,12 @@ packages/lib/services/e2ee/EncryptionService.js.map
|
|||||||
packages/lib/services/e2ee/EncryptionService.test.d.ts
|
packages/lib/services/e2ee/EncryptionService.test.d.ts
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js
|
packages/lib/services/e2ee/EncryptionService.test.js
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js.map
|
packages/lib/services/e2ee/EncryptionService.test.js.map
|
||||||
|
packages/lib/services/e2ee/ppk.d.ts
|
||||||
|
packages/lib/services/e2ee/ppk.js
|
||||||
|
packages/lib/services/e2ee/ppk.js.map
|
||||||
|
packages/lib/services/e2ee/ppk.test.d.ts
|
||||||
|
packages/lib/services/e2ee/ppk.test.js
|
||||||
|
packages/lib/services/e2ee/ppk.test.js.map
|
||||||
packages/lib/services/e2ee/types.d.ts
|
packages/lib/services/e2ee/types.d.ts
|
||||||
packages/lib/services/e2ee/types.js
|
packages/lib/services/e2ee/types.js
|
||||||
packages/lib/services/e2ee/types.js.map
|
packages/lib/services/e2ee/types.js.map
|
||||||
@@ -1567,6 +1579,9 @@ packages/lib/services/synchronizer/Synchronizer.conflicts.test.js.map
|
|||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.d.ts
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.d.ts
|
||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js.map
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js.map
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.d.ts
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.js.map
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.d.ts
|
packages/lib/services/synchronizer/Synchronizer.resources.test.d.ts
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ module.exports = {
|
|||||||
selector: 'enumMember',
|
selector: 'enumMember',
|
||||||
format: null,
|
format: null,
|
||||||
'filter': {
|
'filter': {
|
||||||
'regex': '^(GET|POST|PUT|DELETE|PATCH|HEAD|SQLite|PostgreSQL|ASC|DESC|E2EE|OR|AND|UNION|INTERSECT|EXCLUSION|INCLUSION|EUR|GBP|USD)$',
|
'regex': '^(GET|POST|PUT|DELETE|PATCH|HEAD|SQLite|PostgreSQL|ASC|DESC|E2EE|OR|AND|UNION|INTERSECT|EXCLUSION|INCLUSION|EUR|GBP|USD|SJCL.*)$',
|
||||||
'match': true,
|
'match': true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -313,6 +313,9 @@ packages/app-desktop/gui/MainScreen/commands/toggleSideBar.js.map
|
|||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.d.ts
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.d.ts
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js
|
||||||
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js.map
|
packages/app-desktop/gui/MainScreen/commands/toggleVisiblePanes.js.map
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.d.ts
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js
|
||||||
|
packages/app-desktop/gui/MasterPasswordDialog/Dialog.js.map
|
||||||
packages/app-desktop/gui/MenuBar.d.ts
|
packages/app-desktop/gui/MenuBar.d.ts
|
||||||
packages/app-desktop/gui/MenuBar.js
|
packages/app-desktop/gui/MenuBar.js
|
||||||
packages/app-desktop/gui/MenuBar.js.map
|
packages/app-desktop/gui/MenuBar.js.map
|
||||||
@@ -913,6 +916,9 @@ packages/lib/commands/historyForward.js.map
|
|||||||
packages/lib/commands/index.d.ts
|
packages/lib/commands/index.d.ts
|
||||||
packages/lib/commands/index.js
|
packages/lib/commands/index.js
|
||||||
packages/lib/commands/index.js.map
|
packages/lib/commands/index.js.map
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.d.ts
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.js
|
||||||
|
packages/lib/commands/openMasterPasswordDialog.js.map
|
||||||
packages/lib/commands/synchronize.d.ts
|
packages/lib/commands/synchronize.d.ts
|
||||||
packages/lib/commands/synchronize.js
|
packages/lib/commands/synchronize.js
|
||||||
packages/lib/commands/synchronize.js.map
|
packages/lib/commands/synchronize.js.map
|
||||||
@@ -1216,6 +1222,12 @@ packages/lib/services/e2ee/EncryptionService.js.map
|
|||||||
packages/lib/services/e2ee/EncryptionService.test.d.ts
|
packages/lib/services/e2ee/EncryptionService.test.d.ts
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js
|
packages/lib/services/e2ee/EncryptionService.test.js
|
||||||
packages/lib/services/e2ee/EncryptionService.test.js.map
|
packages/lib/services/e2ee/EncryptionService.test.js.map
|
||||||
|
packages/lib/services/e2ee/ppk.d.ts
|
||||||
|
packages/lib/services/e2ee/ppk.js
|
||||||
|
packages/lib/services/e2ee/ppk.js.map
|
||||||
|
packages/lib/services/e2ee/ppk.test.d.ts
|
||||||
|
packages/lib/services/e2ee/ppk.test.js
|
||||||
|
packages/lib/services/e2ee/ppk.test.js.map
|
||||||
packages/lib/services/e2ee/types.d.ts
|
packages/lib/services/e2ee/types.d.ts
|
||||||
packages/lib/services/e2ee/types.js
|
packages/lib/services/e2ee/types.js
|
||||||
packages/lib/services/e2ee/types.js.map
|
packages/lib/services/e2ee/types.js.map
|
||||||
@@ -1552,6 +1564,9 @@ packages/lib/services/synchronizer/Synchronizer.conflicts.test.js.map
|
|||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.d.ts
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.d.ts
|
||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
|
||||||
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js.map
|
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js.map
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.d.ts
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
|
||||||
|
packages/lib/services/synchronizer/Synchronizer.ppk.test.js.map
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.d.ts
|
packages/lib/services/synchronizer/Synchronizer.resources.test.d.ts
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
packages/lib/services/synchronizer/Synchronizer.resources.test.js
|
||||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
|||||||
}
|
}
|
||||||
|
|
||||||
description() {
|
description() {
|
||||||
return _('Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, `status`, `decrypt-file` and `target-status`.');
|
return _('Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, `status`, `decrypt-file`, and `target-status`.'); // `generate-ppk`
|
||||||
}
|
}
|
||||||
|
|
||||||
options() {
|
options() {
|
||||||
@@ -151,6 +151,19 @@ class Command extends BaseCommand {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if (args.command === 'generate-ppk') {
|
||||||
|
// const syncInfo = localSyncInfo();
|
||||||
|
// if (syncInfo.ppk) throw new Error('This account already has a public-private key pair');
|
||||||
|
|
||||||
|
// const argPassword = options.password ? options.password.toString() : '';
|
||||||
|
// if (!argPassword) throw new Error('Password must be provided'); // TODO: should get from prompt
|
||||||
|
// const ppk = await generateKeyPair(EncryptionService.instance(), argPassword);
|
||||||
|
|
||||||
|
// syncInfo.ppk = ppk;
|
||||||
|
// saveLocalSyncInfo(syncInfo);
|
||||||
|
// await Setting.saveAll();
|
||||||
|
// }
|
||||||
|
|
||||||
if (args.command === 'target-status') {
|
if (args.command === 'target-status') {
|
||||||
const fs = require('fs-extra');
|
const fs = require('fs-extra');
|
||||||
|
|
||||||
|
|||||||
@@ -535,12 +535,12 @@ class Application extends BaseApplication {
|
|||||||
// }, 2000);
|
// }, 2000);
|
||||||
|
|
||||||
|
|
||||||
// setTimeout(() => {
|
setTimeout(() => {
|
||||||
// this.dispatch({
|
this.dispatch({
|
||||||
// type: 'DIALOG_OPEN',
|
type: 'DIALOG_OPEN',
|
||||||
// name: 'syncWizard',
|
name: 'masterPassword',
|
||||||
// });
|
});
|
||||||
// }, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// setTimeout(() => {
|
// setTimeout(() => {
|
||||||
// this.dispatch({
|
// this.dispatch({
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ const DialogRoot = styled.div`
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
renderContent: Function;
|
renderContent: Function;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dialog(props: Props) {
|
export default function Dialog(props: Props) {
|
||||||
return (
|
return (
|
||||||
<DialogModalLayer>
|
<DialogModalLayer className={props.className}>
|
||||||
<DialogRoot>
|
<DialogRoot>
|
||||||
{props.renderContent()}
|
{props.renderContent()}
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ export type ClickEventHandler = (event: ClickEvent)=> void;
|
|||||||
interface Props {
|
interface Props {
|
||||||
themeId: number;
|
themeId: number;
|
||||||
onClick?: ClickEventHandler;
|
onClick?: ClickEventHandler;
|
||||||
okButtonShow?: boolean;
|
|
||||||
cancelButtonShow?: boolean;
|
cancelButtonShow?: boolean;
|
||||||
cancelButtonLabel?: string;
|
cancelButtonLabel?: string;
|
||||||
|
cancelButtonDisabled?: boolean;
|
||||||
|
okButtonShow?: boolean;
|
||||||
|
okButtonLabel?: string;
|
||||||
okButtonRef?: any;
|
okButtonRef?: any;
|
||||||
|
okButtonDisabled?: boolean;
|
||||||
customButtons?: ButtonSpec[];
|
customButtons?: ButtonSpec[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,15 +71,15 @@ export default function DialogButtonRow(props: Props) {
|
|||||||
|
|
||||||
if (props.okButtonShow !== false) {
|
if (props.okButtonShow !== false) {
|
||||||
buttonComps.push(
|
buttonComps.push(
|
||||||
<button key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||||
{_('OK')}
|
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.cancelButtonShow !== false) {
|
if (props.cancelButtonShow !== false) {
|
||||||
buttonComps.push(
|
buttonComps.push(
|
||||||
<button key="cancel" style={Object.assign({}, buttonStyle)} onClick={cancelButton_click}>
|
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={cancelButton_click}>
|
||||||
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}
|
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import styled from 'styled-components';
|
|||||||
|
|
||||||
const Root = styled.div`
|
const Root = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: ${props => props.justifyContent ? props.justifyContent : 'flex-start'};
|
justify-content: ${props => props.justifyContent ? props.justifyContent : 'center'};
|
||||||
font-family: ${props => props.theme.fontFamily};
|
font-family: ${props => props.theme.fontFamily};
|
||||||
font-size: ${props => props.theme.fontSize * 1.5}px;
|
font-size: ${props => props.theme.fontSize * 1.5}px;
|
||||||
line-height: 1.6em;
|
line-height: 1.6em;
|
||||||
color: ${props => props.theme.color};
|
color: ${props => props.theme.color};
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 1.2em;
|
margin-bottom: 1em;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import bridge from '../services/bridge';
|
|||||||
import shared from '@joplin/lib/components/shared/encryption-config-shared';
|
import shared from '@joplin/lib/components/shared/encryption-config-shared';
|
||||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||||
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
import { getEncryptionEnabled, masterKeyEnabled, SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||||
import { getDefaultMasterKey, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
import { getDefaultMasterKey, toggleAndSetupEncryption, getMasterPassword } from '@joplin/lib/services/e2ee/utils';
|
||||||
import MasterKey from '@joplin/lib/models/MasterKey';
|
import MasterKey from '@joplin/lib/models/MasterKey';
|
||||||
import StyledInput from './style/StyledInput';
|
import StyledInput from './style/StyledInput';
|
||||||
import Button, { ButtonLevel } from './Button/Button';
|
import Button, { ButtonLevel } from './Button/Button';
|
||||||
@@ -218,7 +218,7 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderMasterPassword() {
|
private renderMasterPassword() {
|
||||||
if (!this.props.encryptionEnabled && !this.props.masterKeys.length) return null;
|
// if (!this.props.encryptionEnabled && !this.props.masterKeys.length) return null;
|
||||||
|
|
||||||
const theme = themeStyle(this.props.themeId);
|
const theme = themeStyle(this.props.themeId);
|
||||||
|
|
||||||
@@ -230,24 +230,41 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.state.passwordChecks['master']) {
|
// const status = this.getMasterPasswordStatus();
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
// const statusMessages = {
|
||||||
<span style={theme.textStyle}>{_('Master password:')}</span>
|
// [MasterPasswordStatus.NotSet]: 'Not set',
|
||||||
<span style={{ ...theme.textStyle, fontWeight: 'bold' }}>✔ {_('Loaded')}</span>
|
// [MasterPasswordStatus.Valid]: '✓ ' + 'Valid',
|
||||||
</div>
|
// [MasterPasswordStatus.Invalid]: '❌ ' + 'Invalid',
|
||||||
);
|
// };
|
||||||
} else {
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<span style={theme.textStyle}>❌ {'The master password is not set or is invalid. Please type it below:'}</span>
|
<span style={theme.textStyle}>{_('Master password:')}</span>
|
||||||
<div style={{ display: 'flex', flexDirection: 'row', marginTop: 10 }}>
|
<span style={{ ...theme.textStyle, fontWeight: 'bold' }}>{statusMessages[status]}</span>
|
||||||
<MasterPasswordInput placeholder={_('Enter your master password')} type="password" value={this.state.masterPasswordInput} onChange={(event: any) => shared.onMasterPasswordChange(this, event.target.value)} />{' '}
|
</div>
|
||||||
<Button ml="10px" level={ButtonLevel.Secondary} onClick={onMasterPasswordSave} title={_('Save')} />
|
);
|
||||||
</div>
|
|
||||||
</div>
|
// if (this.state.passwordChecks['master']) {
|
||||||
);
|
// return (
|
||||||
}
|
// <div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||||
|
// <span style={theme.textStyle}>{_('Status:')}</span>
|
||||||
|
// <span style={{ ...theme.textStyle, fontWeight: 'bold' }}>{statusMessages[status]}</span>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// } else {
|
||||||
|
|
||||||
|
|
||||||
|
// return (
|
||||||
|
// <div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||||
|
// <span style={theme.textStyle}>❌ {'The master password is not set or is invalid. Please type it below:'}</span>
|
||||||
|
// <div style={{ display: 'flex', flexDirection: 'row', marginTop: 10 }}>
|
||||||
|
// <MasterPasswordInput placeholder={_('Enter your master password')} type="password" value={this.state.masterPasswordInput} onChange={(event: any) => shared.onMasterPasswordChange(this, event.target.value)} />{' '}
|
||||||
|
// <Button ml="10px" level={ButtonLevel.Secondary} onClick={onMasterPasswordSave} title={_('Save')} />
|
||||||
|
// </div>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -359,11 +376,13 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h1 style={theme.h1Style}>{_('Status')}</h1>
|
<h1 style={theme.h1Style}>{_('Master password')}</h1>
|
||||||
|
{this.renderMasterPassword()}
|
||||||
|
|
||||||
|
<h1 style={theme.h1Style}>{_('End-to-end encryption')}</h1>
|
||||||
<p style={theme.textStyle}>
|
<p style={theme.textStyle}>
|
||||||
{_('Encryption is:')} <strong>{this.props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
|
{_('Encryption is:')} <strong>{this.props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
|
||||||
</p>
|
</p>
|
||||||
{this.renderMasterPassword()}
|
|
||||||
{decryptedItemsInfo}
|
{decryptedItemsInfo}
|
||||||
{toggleButton}
|
{toggleButton}
|
||||||
{needUpgradeSection}
|
{needUpgradeSection}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { reg } from '@joplin/lib/registry';
|
|||||||
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
|
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
|
||||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||||
|
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
|
||||||
import commands from './commands/index';
|
import commands from './commands/index';
|
||||||
|
|
||||||
const { connect } = require('react-redux');
|
const { connect } = require('react-redux');
|
||||||
@@ -545,8 +546,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
bridge().restart();
|
bridge().restart();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
const onInvitationRespond = async (shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||||
await ShareService.instance().refreshShareInvitations();
|
await ShareService.instance().refreshShareInvitations();
|
||||||
void reg.scheduleSync(1000);
|
void reg.scheduleSync(1000);
|
||||||
};
|
};
|
||||||
@@ -593,9 +594,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
|||||||
msg = this.renderNotificationMessage(
|
msg = this.renderNotificationMessage(
|
||||||
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
||||||
_('Accept'),
|
_('Accept'),
|
||||||
() => onInvitationRespond(invitation.id, true),
|
() => onInvitationRespond(invitation.id, invitation.master_key, true),
|
||||||
_('Reject'),
|
_('Reject'),
|
||||||
() => onInvitationRespond(invitation.id, false)
|
() => onInvitationRespond(invitation.id, invitation.master_key, false)
|
||||||
);
|
);
|
||||||
} else if (this.props.hasDisabledSyncItems) {
|
} else if (this.props.hasDisabledSyncItems) {
|
||||||
msg = this.renderNotificationMessage(
|
msg = this.renderNotificationMessage(
|
||||||
|
|||||||
165
packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx
Normal file
165
packages/app-desktop/gui/MasterPasswordDialog/Dialog.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { useCallback, useState, useEffect } from 'react';
|
||||||
|
import { _ } from '@joplin/lib/locale';
|
||||||
|
import useAsyncEffect, { AsyncEffectEvent } from '@joplin/lib/hooks/useAsyncEffect';
|
||||||
|
import DialogButtonRow, { ClickEvent } from '../DialogButtonRow';
|
||||||
|
import Dialog from '../Dialog';
|
||||||
|
import DialogTitle from '../DialogTitle';
|
||||||
|
import StyledInput from '../style/StyledInput';
|
||||||
|
import { getMasterPasswordStatus, getMasterPasswordStatusMessage, masterPasswordIsValid, MasterPasswordStatus, updateMasterPassword } from '@joplin/lib/services/e2ee/utils';
|
||||||
|
import { reg } from '@joplin/lib/registry';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
themeId: number;
|
||||||
|
dispatch: Function;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function(props: Props) {
|
||||||
|
const [status, setStatus] = useState(MasterPasswordStatus.NotSet);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
|
const [currentPasswordIsValid, setCurrentPasswordIsValid] = useState(false);
|
||||||
|
const [password1, setPassword1] = useState('');
|
||||||
|
const [password2, setPassword2] = useState('');
|
||||||
|
const [saveButtonDisabled, setSaveButtonDisabled] = useState(true);
|
||||||
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
const [updatingPassword, setUpdatingPassword] = useState(false);
|
||||||
|
|
||||||
|
function closeDialog(dispatch: Function) {
|
||||||
|
dispatch({
|
||||||
|
type: 'DIALOG_CLOSE',
|
||||||
|
name: 'masterPassword',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||||
|
const newStatus = await getMasterPasswordStatus();
|
||||||
|
if (event.cancelled) return;
|
||||||
|
setStatus(newStatus);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onButtonRowClick = useCallback(async (event: ClickEvent) => {
|
||||||
|
if (event.buttonName === 'cancel') {
|
||||||
|
closeDialog(props.dispatch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.buttonName === 'ok') {
|
||||||
|
setUpdatingPassword(true);
|
||||||
|
try {
|
||||||
|
await updateMasterPassword(currentPassword, password1, () => reg.waitForSyncFinishedThenSync());
|
||||||
|
closeDialog(props.dispatch);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message);
|
||||||
|
} finally {
|
||||||
|
setUpdatingPassword(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [props.dispatch, currentPassword, password1]);
|
||||||
|
|
||||||
|
const onCurrentPasswordChange = useCallback((event: any) => {
|
||||||
|
setCurrentPassword(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPasswordChange1 = useCallback((event: any) => {
|
||||||
|
setPassword1(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPasswordChange2 = useCallback((event: any) => {
|
||||||
|
setPassword2(event.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onShowPasswordForm = useCallback(() => {
|
||||||
|
setShowPasswordForm(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSaveButtonDisabled(updatingPassword || (!password1 || password1 !== password2));
|
||||||
|
}, [password1, password2, updatingPassword]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowPasswordForm(status === MasterPasswordStatus.NotSet);
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||||
|
const isValid = await masterPasswordIsValid(currentPassword);
|
||||||
|
if (event.cancelled) return;
|
||||||
|
setCurrentPasswordIsValid(isValid);
|
||||||
|
}, [currentPassword]);
|
||||||
|
|
||||||
|
function renderCurrentPasswordIcon() {
|
||||||
|
if (!currentPassword || status === MasterPasswordStatus.NotSet) return null;
|
||||||
|
return currentPasswordIsValid ? <i className="fas fa-check"></i> : <i className="fas fa-times"></i>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPasswordForm() {
|
||||||
|
if (showPasswordForm) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="form">
|
||||||
|
<div className="form-input-group">
|
||||||
|
<label>{'Current password'}</label>
|
||||||
|
<div className="current-password-wrapper">
|
||||||
|
<StyledInput
|
||||||
|
disabled={status === MasterPasswordStatus.NotSet}
|
||||||
|
placeholder={status === MasterPasswordStatus.NotSet ? `(${_('Not set')})` : ''}
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={onCurrentPasswordChange}
|
||||||
|
/>
|
||||||
|
{renderCurrentPasswordIcon()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-input-group">
|
||||||
|
<label>{'Enter new password'}</label>
|
||||||
|
<StyledInput type="password" value={password1} onChange={onPasswordChange1}/>
|
||||||
|
</div>
|
||||||
|
<div className="form-input-group">
|
||||||
|
<label>{'Re-enter password'}</label>
|
||||||
|
<StyledInput type="password" value={password2} onChange={onPasswordChange2}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="bold">Please make sure you remember your password. For security reasons, it is not possible to recover it if it is lost.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<p>
|
||||||
|
<a onClick={onShowPasswordForm} href="#">Change master password</a>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
return (
|
||||||
|
<div className="dialog-content">
|
||||||
|
<p>Your master password is used to protect sensitive information. In particular, it is used to encrypt your notes when end-to-end encryption (E2EE) is enabled, or to share and encrypt notes with someone who has E2EE enabled.</p>
|
||||||
|
<p>
|
||||||
|
<span>{'Master password status:'}</span> <span className="bold">{getMasterPasswordStatusMessage(status)}</span>
|
||||||
|
</p>
|
||||||
|
{renderPasswordForm()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialogWrapper() {
|
||||||
|
return (
|
||||||
|
<div className="dialog-root">
|
||||||
|
<DialogTitle title={_('Master password')}/>
|
||||||
|
{renderContent()}
|
||||||
|
<DialogButtonRow
|
||||||
|
themeId={props.themeId}
|
||||||
|
onClick={onButtonRowClick}
|
||||||
|
okButtonLabel={_('Save')}
|
||||||
|
okButtonDisabled={saveButtonDisabled}
|
||||||
|
cancelButtonDisabled={updatingPassword}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog className="master-password-dialog" renderContent={renderDialogWrapper}/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -752,6 +752,7 @@ function useMenu(props: Props) {
|
|||||||
|
|
||||||
rootMenus.go.submenu.push(menuItemDic.gotoAnything);
|
rootMenus.go.submenu.push(menuItemDic.gotoAnything);
|
||||||
rootMenus.tools.submenu.push(menuItemDic.commandPalette);
|
rootMenus.tools.submenu.push(menuItemDic.commandPalette);
|
||||||
|
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);
|
||||||
|
|
||||||
for (const view of props.pluginMenuItems) {
|
for (const view of props.pluginMenuItems) {
|
||||||
const location: MenuItemLocation = view.location;
|
const location: MenuItemLocation = view.location;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import DialogTitle from './DialogTitle';
|
|||||||
import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow';
|
import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow';
|
||||||
import Dialog from './Dialog';
|
import Dialog from './Dialog';
|
||||||
import SyncWizardDialog from './SyncWizard/Dialog';
|
import SyncWizardDialog from './SyncWizard/Dialog';
|
||||||
|
import MasterPasswordDialog from './MasterPasswordDialog/Dialog';
|
||||||
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
|
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
|
||||||
const { ImportScreen } = require('./ImportScreen.min.js');
|
const { ImportScreen } = require('./ImportScreen.min.js');
|
||||||
const { ResourceScreen } = require('./ResourceScreen.js');
|
const { ResourceScreen } = require('./ResourceScreen.js');
|
||||||
@@ -61,6 +62,12 @@ const registeredDialogs: Record<string, RegisteredDialog> = {
|
|||||||
return <SyncWizardDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId}/>;
|
return <SyncWizardDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId}/>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
masterPassword: {
|
||||||
|
render: (props: RegisteredDialogProps) => {
|
||||||
|
return <MasterPasswordDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId}/>;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const GlobalStyle = createGlobalStyle`
|
const GlobalStyle = createGlobalStyle`
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ function ShareFolderDialog(props: Props) {
|
|||||||
try {
|
try {
|
||||||
setLatestError(null);
|
setLatestError(null);
|
||||||
const share = await ShareService.instance().shareFolder(props.folderId);
|
const share = await ShareService.instance().shareFolder(props.folderId);
|
||||||
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
ShareService.instance().refreshShares(),
|
ShareService.instance().refreshShares(),
|
||||||
ShareService.instance().refreshShareUsers(share.id),
|
ShareService.instance().refreshShareUsers(share.id),
|
||||||
|
|||||||
@@ -49,5 +49,6 @@ export default function() {
|
|||||||
'showShareFolderDialog',
|
'showShareFolderDialog',
|
||||||
'gotoAnything',
|
'gotoAnything',
|
||||||
'commandPalette',
|
'commandPalette',
|
||||||
|
'openMasterPasswordDialog',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
# Setup the sync parameters for user X and create a few folders and notes to
|
# Setup the sync parameters for user X and create a few folders and notes to
|
||||||
# allow sharing. Also calls the API to create the test users and clear the data.
|
# allow sharing. Also calls the API to create the test users and clear the data.
|
||||||
|
|
||||||
|
# For example, to setup a user for sharing, and another as recipient with E2EE
|
||||||
|
# enabled:
|
||||||
|
|
||||||
|
# ./runForTesting.sh 1 createUsers,createData,reset,e2ee,sync && ./runForTesting.sh 2 reset,e2ee,sync && ./runForTesting.sh 1
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||||
@@ -50,12 +55,21 @@ do
|
|||||||
echo "config sync.target 10" >> "$CMD_FILE"
|
echo "config sync.target 10" >> "$CMD_FILE"
|
||||||
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||||
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
|
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
|
||||||
|
|
||||||
elif [[ $CMD == "e2ee" ]]; then
|
elif [[ $CMD == "e2ee" ]]; then
|
||||||
|
|
||||||
echo "e2ee enable --password 111111" >> "$CMD_FILE"
|
echo "e2ee enable --password 111111" >> "$CMD_FILE"
|
||||||
|
|
||||||
|
elif [[ $CMD == "sync" ]]; then
|
||||||
|
|
||||||
|
echo "sync" >> "$CMD_FILE"
|
||||||
|
|
||||||
|
# elif [[ $CMD == "generatePpk" ]]; then
|
||||||
|
|
||||||
|
# echo "e2ee generate-ppk --password 111111" >> "$CMD_FILE"
|
||||||
|
# echo "sync" >> "$CMD_FILE"
|
||||||
|
|
||||||
else
|
else
|
||||||
|
|
||||||
echo "Unknown command: $CMD"
|
echo "Unknown command: $CMD"
|
||||||
|
|||||||
@@ -143,4 +143,87 @@ a {
|
|||||||
|
|
||||||
*:focus {
|
*:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================================
|
||||||
|
General classes
|
||||||
|
========================================================================================= */
|
||||||
|
|
||||||
|
* {
|
||||||
|
color: var(--joplin-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form > .form-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form > .form-input-group > label {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
p,
|
||||||
|
div.form,
|
||||||
|
.form > .form-input-group {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form > .form-input-group:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--joplin-url-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =========================================================================================
|
||||||
|
Component-specific classes
|
||||||
|
========================================================================================= */
|
||||||
|
|
||||||
|
.master-password-dialog .dialog-root {
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-password-dialog .dialog-content {
|
||||||
|
background-color: var(--joplin-background-color3);
|
||||||
|
padding: 1em;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-password-dialog .current-password-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-password-dialog .current-password-wrapper input {
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-password-dialog .fa-check {
|
||||||
|
color: var(--joplin-color-correct);
|
||||||
|
}
|
||||||
|
|
||||||
|
.master-password-dialog .fa-times {
|
||||||
|
color: var(--joplin-color-error);
|
||||||
}
|
}
|
||||||
@@ -552,7 +552,7 @@ async function initialize(dispatch: Function) {
|
|||||||
// / E2EE SETUP
|
// / E2EE SETUP
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
await ShareService.instance().initialize(store);
|
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||||
|
|
||||||
reg.logger().info('Loading folders...');
|
reg.logger().info('Loading folders...');
|
||||||
|
|
||||||
|
|||||||
@@ -630,7 +630,7 @@ export default class BaseApplication {
|
|||||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||||
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
||||||
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
||||||
ShareService.instance().initialize(this.store());
|
ShareService.instance().initialize(this.store(), EncryptionService.instance());
|
||||||
}
|
}
|
||||||
|
|
||||||
deinitRedux() {
|
deinitRedux() {
|
||||||
|
|||||||
@@ -900,6 +900,11 @@ export default class JoplinDatabase extends Database {
|
|||||||
queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""');
|
queries.push('ALTER TABLE `notes` ADD COLUMN conflict_original_id TEXT NOT NULL DEFAULT ""');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// if (targetVersion == 40) {
|
||||||
|
// 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 ""');
|
||||||
|
// }
|
||||||
|
|
||||||
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
const updateVersionQuery = { sql: 'UPDATE version SET version = ?', params: [targetVersion] };
|
||||||
|
|
||||||
queries.push(updateVersionQuery);
|
queries.push(updateVersionQuery);
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default class JoplinServerApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) headers['X-API-AUTH'] = sessionId;
|
if (sessionId) headers['X-API-AUTH'] = sessionId;
|
||||||
headers['X-API-MIN-VERSION'] = '2.1.4';
|
headers['X-API-MIN-VERSION'] = '2.5.0';
|
||||||
|
|
||||||
const fetchOptions: any = {};
|
const fetchOptions: any = {};
|
||||||
fetchOptions.headers = headers;
|
fetchOptions.headers = headers;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { FileApi } from './file-api';
|
|||||||
import JoplinDatabase from './JoplinDatabase';
|
import JoplinDatabase from './JoplinDatabase';
|
||||||
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||||
import { setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
import { setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||||
|
import { setPpkIfNotExist } from './services/e2ee/ppk';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||||
|
|
||||||
@@ -420,49 +421,52 @@ export default class Synchronizer {
|
|||||||
this.api().setTempDirName(Dirnames.Temp);
|
this.api().setTempDirName(Dirnames.Temp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const remoteInfo = await fetchSyncInfo(this.api());
|
let remoteInfo = await fetchSyncInfo(this.api());
|
||||||
logger.info('Sync target remote info:', remoteInfo);
|
logger.info('Sync target remote info:', remoteInfo);
|
||||||
|
|
||||||
if (!remoteInfo.version) {
|
if (!remoteInfo.version) {
|
||||||
logger.info('Sync target is new - setting it up...');
|
logger.info('Sync target is new - setting it up...');
|
||||||
await this.migrationHandler().upgrade(Setting.value('syncVersion'));
|
await this.migrationHandler().upgrade(Setting.value('syncVersion'));
|
||||||
} else {
|
remoteInfo = await fetchSyncInfo(this.api());
|
||||||
logger.info('Sync target is already setup - checking it...');
|
}
|
||||||
|
|
||||||
await this.migrationHandler().checkCanSync(remoteInfo);
|
logger.info('Sync target is already setup - checking it...');
|
||||||
|
|
||||||
const localInfo = await localSyncInfo();
|
await this.migrationHandler().checkCanSync(remoteInfo);
|
||||||
|
|
||||||
logger.info('Sync target local info:', localInfo);
|
const localInfo = await localSyncInfo();
|
||||||
|
|
||||||
// console.info('LOCAL', localInfo);
|
logger.info('Sync target local info:', localInfo);
|
||||||
// console.info('REMOTE', remoteInfo);
|
|
||||||
|
|
||||||
if (!syncInfoEquals(localInfo, remoteInfo)) {
|
await setPpkIfNotExist(this.encryptionService(), localInfo, remoteInfo);
|
||||||
const newInfo = mergeSyncInfos(localInfo, remoteInfo);
|
|
||||||
const previousE2EE = localInfo.e2ee;
|
|
||||||
logger.info('Sync target info differs between local and remote - merging infos: ', newInfo.toObject());
|
|
||||||
|
|
||||||
await this.lockHandler().acquireLock(LockType.Exclusive, this.appType_, this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
|
// console.info('LOCAL', localInfo);
|
||||||
await uploadSyncInfo(this.api(), newInfo);
|
// console.info('REMOTE', remoteInfo);
|
||||||
await saveLocalSyncInfo(newInfo);
|
|
||||||
await this.lockHandler().releaseLock(LockType.Exclusive, this.appType_, this.clientId_);
|
|
||||||
|
|
||||||
// console.info('NEW', newInfo);
|
if (!syncInfoEquals(localInfo, remoteInfo)) {
|
||||||
|
const newInfo = mergeSyncInfos(localInfo, remoteInfo);
|
||||||
|
const previousE2EE = localInfo.e2ee;
|
||||||
|
logger.info('Sync target info differs between local and remote - merging infos: ', newInfo.toObject());
|
||||||
|
|
||||||
if (newInfo.e2ee !== previousE2EE) {
|
await this.lockHandler().acquireLock(LockType.Exclusive, this.appType_, this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
|
||||||
if (newInfo.e2ee) {
|
await uploadSyncInfo(this.api(), newInfo);
|
||||||
const mk = getActiveMasterKey(newInfo);
|
await saveLocalSyncInfo(newInfo);
|
||||||
await setupAndEnableEncryption(this.encryptionService(), mk);
|
await this.lockHandler().releaseLock(LockType.Exclusive, this.appType_, this.clientId_);
|
||||||
} else {
|
|
||||||
await setupAndDisableEncryption(this.encryptionService());
|
// console.info('NEW', newInfo);
|
||||||
}
|
|
||||||
|
if (newInfo.e2ee !== previousE2EE) {
|
||||||
|
if (newInfo.e2ee) {
|
||||||
|
const mk = getActiveMasterKey(newInfo);
|
||||||
|
await setupAndEnableEncryption(this.encryptionService(), mk);
|
||||||
|
} else {
|
||||||
|
await setupAndDisableEncryption(this.encryptionService());
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Set it to remote anyway so that timestamps are the same
|
|
||||||
// Note: that's probably not needed anymore?
|
|
||||||
// await uploadSyncInfo(this.api(), remoteInfo);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Set it to remote anyway so that timestamps are the same
|
||||||
|
// Note: that's probably not needed anymore?
|
||||||
|
// await uploadSyncInfo(this.api(), remoteInfo);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'outdatedSyncTarget') {
|
if (error.code === 'outdatedSyncTarget') {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||||
import * as historyBackward from './historyBackward';
|
import * as historyBackward from './historyBackward';
|
||||||
import * as historyForward from './historyForward';
|
import * as historyForward from './historyForward';
|
||||||
|
import * as openMasterPasswordDialog from './openMasterPasswordDialog';
|
||||||
import * as synchronize from './synchronize';
|
import * as synchronize from './synchronize';
|
||||||
|
|
||||||
const index:any[] = [
|
const index:any[] = [
|
||||||
historyBackward,
|
historyBackward,
|
||||||
historyForward,
|
historyForward,
|
||||||
|
openMasterPasswordDialog,
|
||||||
synchronize,
|
synchronize,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
20
packages/lib/commands/openMasterPasswordDialog.ts
Normal file
20
packages/lib/commands/openMasterPasswordDialog.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
|
||||||
|
import { _ } from '../locale';
|
||||||
|
|
||||||
|
export const declaration: CommandDeclaration = {
|
||||||
|
name: 'openMasterPasswordDialog',
|
||||||
|
label: () => _('Manage master password...'),
|
||||||
|
iconName: 'fas fa-key',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runtime = (): CommandRuntime => {
|
||||||
|
return {
|
||||||
|
execute: async (context: CommandContext, isOpen: boolean = true) => {
|
||||||
|
context.dispatch({
|
||||||
|
type: 'DIALOG_OPEN',
|
||||||
|
name: 'masterPassword',
|
||||||
|
isOpen: isOpen,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -67,7 +67,7 @@ class Shared {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const password = comp.state.passwords[masterKey.id];
|
const password = comp.state.passwords[masterKey.id];
|
||||||
const newMasterKey = await EncryptionService.instance().upgradeMasterKey(masterKey, password);
|
const newMasterKey = await EncryptionService.instance().reencryptMasterKey(masterKey, password, password);
|
||||||
await MasterKey.save(newMasterKey);
|
await MasterKey.save(newMasterKey);
|
||||||
void reg.waitForSyncFinishedThenSync();
|
void reg.waitForSyncFinishedThenSync();
|
||||||
alert(_('The master key has been upgraded successfully!'));
|
alert(_('The master key has been upgraded successfully!'));
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export default class FileApiDriverJoplinServer {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get requiresPublicPrivateKeyPair() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public requestRepeatCount() {
|
public requestRepeatCount() {
|
||||||
return 3;
|
return 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ class FileApi {
|
|||||||
return !!this.driver().supportsAccurateTimestamp;
|
return !!this.driver().supportsAccurateTimestamp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get requiresPublicPrivateKeyPair(): boolean {
|
||||||
|
return !!this.driver().requiresPublicPrivateKeyPair;
|
||||||
|
}
|
||||||
|
|
||||||
async fetchRemoteDateOffset_() {
|
async fetchRemoteDateOffset_() {
|
||||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import ItemChange from './ItemChange';
|
|||||||
import ShareService from '../services/share/ShareService';
|
import ShareService from '../services/share/ShareService';
|
||||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||||
const JoplinError = require('../JoplinError.js');
|
import JoplinError from '../JoplinError';
|
||||||
const { sprintf } = require('sprintf-js');
|
const { sprintf } = require('sprintf-js');
|
||||||
const moment = require('moment');
|
const moment = require('moment');
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
|
|||||||
type_: ModelType;
|
type_: ModelType;
|
||||||
updated_time: number;
|
updated_time: number;
|
||||||
encryption_applied: number;
|
encryption_applied: number;
|
||||||
|
share_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ItemsThatNeedSyncResult {
|
export interface ItemsThatNeedSyncResult {
|
||||||
@@ -409,6 +410,7 @@ export default class BaseItem extends BaseModel {
|
|||||||
const shownKeys = ItemClass.fieldNames();
|
const shownKeys = ItemClass.fieldNames();
|
||||||
shownKeys.push('type_');
|
shownKeys.push('type_');
|
||||||
|
|
||||||
|
const share = item.share_id ? await this.shareService().shareById(item.share_id) : null;
|
||||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||||
|
|
||||||
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
|
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
|
||||||
@@ -426,7 +428,9 @@ export default class BaseItem extends BaseModel {
|
|||||||
let cipherText = null;
|
let cipherText = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
cipherText = await this.encryptionService().encryptString(serialized);
|
cipherText = await this.encryptionService().encryptString(serialized, {
|
||||||
|
masterKeyId: share && share.master_key_id ? share.master_key_id : '',
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = [`Could not encrypt item ${item.id}`];
|
const msg = [`Could not encrypt item ${item.id}`];
|
||||||
if (error && error.message) msg.push(error.message);
|
if (error && error.message) msg.push(error.message);
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export default class MasterKey extends BaseItem {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], method: number) {
|
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], methods: number[]) {
|
||||||
return masterKeys.filter(m => m.encryption_method !== method);
|
return masterKeys.filter(m => !methods.includes(m.encryption_method));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async all(): Promise<MasterKeyEntity[]> {
|
public static async all(): Promise<MasterKeyEntity[]> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BaseItemEntity } from '../../services/database/types';
|
import { BaseItemEntity } from '../../services/database/types';
|
||||||
|
|
||||||
export default function(resource: BaseItemEntity): boolean {
|
export default function(_resource: BaseItemEntity): boolean {
|
||||||
return !resource.is_shared && !resource.share_id;
|
return true;
|
||||||
|
// return !resource.is_shared && !resource.share_id;
|
||||||
}
|
}
|
||||||
|
|||||||
36
packages/lib/package-lock.json
generated
36
packages/lib/package-lock.json
generated
@@ -41,6 +41,7 @@
|
|||||||
"node-fetch": "^1.7.1",
|
"node-fetch": "^1.7.1",
|
||||||
"node-notifier": "^8.0.0",
|
"node-notifier": "^8.0.0",
|
||||||
"node-persist": "^2.1.0",
|
"node-persist": "^2.1.0",
|
||||||
|
"node-rsa": "^1.1.1",
|
||||||
"promise": "^7.1.1",
|
"promise": "^7.1.1",
|
||||||
"query-string": "4.3.4",
|
"query-string": "4.3.4",
|
||||||
"re-reselect": "^4.0.0",
|
"re-reselect": "^4.0.0",
|
||||||
@@ -67,6 +68,7 @@
|
|||||||
"@types/fs-extra": "^9.0.6",
|
"@types/fs-extra": "^9.0.6",
|
||||||
"@types/jest": "^26.0.15",
|
"@types/jest": "^26.0.15",
|
||||||
"@types/node": "^14.14.6",
|
"@types/node": "^14.14.6",
|
||||||
|
"@types/node-rsa": "^1.1.1",
|
||||||
"clean-html": "^1.5.0",
|
"clean-html": "^1.5.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"sharp": "^0.26.2",
|
"sharp": "^0.26.2",
|
||||||
@@ -1061,6 +1063,15 @@
|
|||||||
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
|
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node-rsa": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-itzxtaBgk4OMbrCawVCvas934waMZWjW17v7EYgFVlfYS/cl0/P7KZdojWCq9SDJMI5cnLQLUP8ayhVCTY8TEg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/normalize-package-data": {
|
"node_modules/@types/normalize-package-data": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
|
||||||
@@ -5750,6 +5761,14 @@
|
|||||||
"nopt": "bin/nopt.js"
|
"nopt": "bin/nopt.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-rsa": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1": "^0.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/noop-logger": {
|
"node_modules/noop-logger": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
||||||
@@ -9746,6 +9765,15 @@
|
|||||||
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
|
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@types/node-rsa": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node-rsa/-/node-rsa-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-itzxtaBgk4OMbrCawVCvas934waMZWjW17v7EYgFVlfYS/cl0/P7KZdojWCq9SDJMI5cnLQLUP8ayhVCTY8TEg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/normalize-package-data": {
|
"@types/normalize-package-data": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
|
||||||
@@ -13511,6 +13539,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node-rsa": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-rsa/-/node-rsa-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==",
|
||||||
|
"requires": {
|
||||||
|
"asn1": "^0.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"noop-logger": {
|
"noop-logger": {
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/fs-extra": "^9.0.6",
|
"@types/fs-extra": "^9.0.6",
|
||||||
"@types/jest": "^26.0.15",
|
"@types/jest": "^26.0.15",
|
||||||
"@types/node": "^14.14.6",
|
"@types/node": "^14.14.6",
|
||||||
|
"@types/node-rsa": "^1.1.1",
|
||||||
"clean-html": "^1.5.0",
|
"clean-html": "^1.5.0",
|
||||||
"jest": "^26.6.3",
|
"jest": "^26.6.3",
|
||||||
"sharp": "^0.26.2",
|
"sharp": "^0.26.2",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"node-fetch": "^1.7.1",
|
"node-fetch": "^1.7.1",
|
||||||
"node-notifier": "^8.0.0",
|
"node-notifier": "^8.0.0",
|
||||||
"node-persist": "^2.1.0",
|
"node-persist": "^2.1.0",
|
||||||
|
"node-rsa": "^1.1.1",
|
||||||
"promise": "^7.1.1",
|
"promise": "^7.1.1",
|
||||||
"query-string": "4.3.4",
|
"query-string": "4.3.4",
|
||||||
"re-reselect": "^4.0.0",
|
"re-reselect": "^4.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Note from '../../models/Note';
|
|||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import BaseItem from '../../models/BaseItem';
|
import BaseItem from '../../models/BaseItem';
|
||||||
import MasterKey from '../../models/MasterKey';
|
import MasterKey from '../../models/MasterKey';
|
||||||
import EncryptionService from './EncryptionService';
|
import EncryptionService, { EncryptionMethod } from './EncryptionService';
|
||||||
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
|
|
||||||
let service: EncryptionService = null;
|
let service: EncryptionService = null;
|
||||||
@@ -22,7 +22,7 @@ describe('services_EncryptionService', function() {
|
|||||||
|
|
||||||
it('should encode and decode header', (async () => {
|
it('should encode and decode header', (async () => {
|
||||||
const header = {
|
const header = {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
encryptionMethod: EncryptionMethod.SJCL,
|
||||||
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,33 +39,33 @@ describe('services_EncryptionService', function() {
|
|||||||
|
|
||||||
let hasThrown = false;
|
let hasThrown = false;
|
||||||
try {
|
try {
|
||||||
await service.decryptMasterKey_(masterKey, 'wrongpassword');
|
await service.decryptMasterKeyContent(masterKey, 'wrongpassword');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasThrown = true;
|
hasThrown = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(hasThrown).toBe(true);
|
expect(hasThrown).toBe(true);
|
||||||
|
|
||||||
const decryptedMasterKey = await service.decryptMasterKey_(masterKey, '123456');
|
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||||
expect(decryptedMasterKey.length).toBe(512);
|
expect(decryptedMasterKey.length).toBe(512);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should upgrade a master key', (async () => {
|
it('should upgrade a master key', (async () => {
|
||||||
// Create an old style master key
|
// Create an old style master key
|
||||||
let masterKey = await service.generateMasterKey('123456', {
|
let masterKey = await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
encryptionMethod: EncryptionMethod.SJCL2,
|
||||||
});
|
});
|
||||||
masterKey = await MasterKey.save(masterKey);
|
masterKey = await MasterKey.save(masterKey);
|
||||||
|
|
||||||
let upgradedMasterKey = await service.upgradeMasterKey(masterKey, '123456');
|
let upgradedMasterKey = await service.reencryptMasterKey(masterKey, '123456', '123456');
|
||||||
upgradedMasterKey = await MasterKey.save(upgradedMasterKey);
|
upgradedMasterKey = await MasterKey.save(upgradedMasterKey);
|
||||||
|
|
||||||
// Check that master key has been upgraded (different ciphertext)
|
// Check that master key has been upgraded (different ciphertext)
|
||||||
expect(masterKey.content).not.toBe(upgradedMasterKey.content);
|
expect(masterKey.content).not.toBe(upgradedMasterKey.content);
|
||||||
|
|
||||||
// Check that master key plain text is still the same
|
// Check that master key plain text is still the same
|
||||||
const plainTextOld = await service.decryptMasterKey_(masterKey, '123456');
|
const plainTextOld = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||||
const plainTextNew = await service.decryptMasterKey_(upgradedMasterKey, '123456');
|
const plainTextNew = await service.decryptMasterKeyContent(upgradedMasterKey, '123456');
|
||||||
expect(plainTextOld).toBe(plainTextNew);
|
expect(plainTextOld).toBe(plainTextNew);
|
||||||
|
|
||||||
// Check that old content can be decrypted with new master key
|
// Check that old content can be decrypted with new master key
|
||||||
@@ -81,15 +81,15 @@ describe('services_EncryptionService', function() {
|
|||||||
|
|
||||||
it('should not upgrade master key if invalid password', (async () => {
|
it('should not upgrade master key if invalid password', (async () => {
|
||||||
const masterKey = await service.generateMasterKey('123456', {
|
const masterKey = await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
encryptionMethod: EncryptionMethod.SJCL2,
|
||||||
});
|
});
|
||||||
|
|
||||||
await checkThrowAsync(async () => await service.upgradeMasterKey(masterKey, '777'));
|
await checkThrowAsync(async () => await service.reencryptMasterKey(masterKey, '777', '777'));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should require a checksum only for old master keys', (async () => {
|
it('should require a checksum only for old master keys', (async () => {
|
||||||
const masterKey = await service.generateMasterKey('123456', {
|
const masterKey = await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
encryptionMethod: EncryptionMethod.SJCL2,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(!!masterKey.checksum).toBe(true);
|
expect(!!masterKey.checksum).toBe(true);
|
||||||
@@ -98,33 +98,33 @@ describe('services_EncryptionService', function() {
|
|||||||
|
|
||||||
it('should not require a checksum for new master keys', (async () => {
|
it('should not require a checksum for new master keys', (async () => {
|
||||||
const masterKey = await service.generateMasterKey('123456', {
|
const masterKey = await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_4,
|
encryptionMethod: EncryptionMethod.SJCL4,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(!masterKey.checksum).toBe(true);
|
expect(!masterKey.checksum).toBe(true);
|
||||||
expect(!!masterKey.content).toBe(true);
|
expect(!!masterKey.content).toBe(true);
|
||||||
|
|
||||||
const decryptedMasterKey = await service.decryptMasterKey_(masterKey, '123456');
|
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||||
expect(decryptedMasterKey.length).toBe(512);
|
expect(decryptedMasterKey.length).toBe(512);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should throw an error if master key decryption fails', (async () => {
|
it('should throw an error if master key decryption fails', (async () => {
|
||||||
const masterKey = await service.generateMasterKey('123456', {
|
const masterKey = await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_4,
|
encryptionMethod: EncryptionMethod.SJCL4,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKey_(masterKey, 'wrong'));
|
const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKeyContent(masterKey, 'wrong'));
|
||||||
|
|
||||||
expect(hasThrown).toBe(true);
|
expect(hasThrown).toBe(true);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
it('should return the master keys that need an upgrade', (async () => {
|
it('should return the master keys that need an upgrade', (async () => {
|
||||||
const masterKey1 = await MasterKey.save(await service.generateMasterKey('123456', {
|
const masterKey1 = await MasterKey.save(await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
encryptionMethod: EncryptionMethod.SJCL2,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const masterKey2 = await MasterKey.save(await service.generateMasterKey('123456', {
|
const masterKey2 = await MasterKey.save(await service.generateMasterKey('123456', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
encryptionMethod: EncryptionMethod.SJCL,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await MasterKey.save(await service.generateMasterKey('123456'));
|
await MasterKey.save(await service.generateMasterKey('123456'));
|
||||||
@@ -164,22 +164,22 @@ describe('services_EncryptionService', function() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
const cipherText = await service.encryptString('some secret', {
|
const cipherText = await service.encryptString('some secret', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
encryptionMethod: EncryptionMethod.SJCL2,
|
||||||
});
|
});
|
||||||
const plainText = await service.decryptString(cipherText);
|
const plainText = await service.decryptString(cipherText);
|
||||||
expect(plainText).toBe('some secret');
|
expect(plainText).toBe('some secret');
|
||||||
const header = await service.decodeHeaderString(cipherText);
|
const header = await service.decodeHeaderString(cipherText);
|
||||||
expect(header.encryptionMethod).toBe(EncryptionService.METHOD_SJCL_2);
|
expect(header.encryptionMethod).toBe(EncryptionMethod.SJCL2);
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
const cipherText = await service.encryptString('some secret', {
|
const cipherText = await service.encryptString('some secret', {
|
||||||
encryptionMethod: EncryptionService.METHOD_SJCL_3,
|
encryptionMethod: EncryptionMethod.SJCL3,
|
||||||
});
|
});
|
||||||
const plainText = await service.decryptString(cipherText);
|
const plainText = await service.decryptString(cipherText);
|
||||||
expect(plainText).toBe('some secret');
|
expect(plainText).toBe('some secret');
|
||||||
const header = await service.decodeHeaderString(cipherText);
|
const header = await service.decodeHeaderString(cipherText);
|
||||||
expect(header.encryptionMethod).toBe(EncryptionService.METHOD_SJCL_3);
|
expect(header.encryptionMethod).toBe(EncryptionMethod.SJCL3);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -267,12 +267,12 @@ describe('services_EncryptionService', function() {
|
|||||||
await service.loadMasterKey(masterKey, '123456', true);
|
await service.loadMasterKey(masterKey, '123456', true);
|
||||||
|
|
||||||
// First check that we can replicate the error with the old encryption method
|
// First check that we can replicate the error with the old encryption method
|
||||||
service.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL;
|
service.defaultEncryptionMethod_ = EncryptionMethod.SJCL;
|
||||||
const hasThrown = await checkThrowAsync(async () => await service.encryptString('🐶🐶🐶'.substr(0,5)));
|
const hasThrown = await checkThrowAsync(async () => await service.encryptString('🐶🐶🐶'.substr(0,5)));
|
||||||
expect(hasThrown).toBe(true);
|
expect(hasThrown).toBe(true);
|
||||||
|
|
||||||
// Now check that the new one fixes the problem
|
// Now check that the new one fixes the problem
|
||||||
service.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
|
service.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
||||||
const cipherText = await service.encryptString('🐶🐶🐶'.substr(0,5));
|
const cipherText = await service.encryptString('🐶🐶🐶'.substr(0,5));
|
||||||
const plainText = await service.decryptString(cipherText);
|
const plainText = await service.decryptString(cipherText);
|
||||||
expect(plainText).toBe('🐶🐶🐶'.substr(0,5));
|
expect(plainText).toBe('🐶🐶🐶'.substr(0,5));
|
||||||
@@ -293,4 +293,5 @@ describe('services_EncryptionService', function() {
|
|||||||
masterKey = await MasterKey.save(masterKey);
|
masterKey = await MasterKey.save(masterKey);
|
||||||
expect(service.isMasterKeyLoaded(masterKey)).toBe(false);
|
expect(service.isMasterKeyLoaded(masterKey)).toBe(false);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,16 +25,32 @@ interface DecryptedMasterKey {
|
|||||||
plainText: string;
|
plainText: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EncryptionCustomHandler {
|
||||||
|
context?: any;
|
||||||
|
encrypt(context: any, hexaBytes: string, password: string): Promise<string>;
|
||||||
|
decrypt(context: any, hexaBytes: string, password: string): Promise<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum EncryptionMethod {
|
||||||
|
SJCL = 1,
|
||||||
|
SJCL2 = 2,
|
||||||
|
SJCL3 = 3,
|
||||||
|
SJCL4 = 4,
|
||||||
|
SJCL1a = 5,
|
||||||
|
Custom = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncryptOptions {
|
||||||
|
encryptionMethod?: EncryptionMethod;
|
||||||
|
onProgress?: Function;
|
||||||
|
encryptionHandler?: EncryptionCustomHandler;
|
||||||
|
masterKeyId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default class EncryptionService {
|
export default class EncryptionService {
|
||||||
|
|
||||||
public static instance_: EncryptionService = null;
|
public static instance_: EncryptionService = null;
|
||||||
|
|
||||||
public static METHOD_SJCL_2 = 2;
|
|
||||||
public static METHOD_SJCL_3 = 3;
|
|
||||||
public static METHOD_SJCL_4 = 4;
|
|
||||||
public static METHOD_SJCL_1A = 5;
|
|
||||||
public static METHOD_SJCL = 1;
|
|
||||||
|
|
||||||
public static fsDriver_: any = null;
|
public static fsDriver_: any = null;
|
||||||
|
|
||||||
// Note: 1 MB is very slow with Node and probably even worse on mobile.
|
// Note: 1 MB is very slow with Node and probably even worse on mobile.
|
||||||
@@ -52,8 +68,8 @@ export default class EncryptionService {
|
|||||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||||
private chunkSize_ = 5000;
|
private chunkSize_ = 5000;
|
||||||
private decryptedMasterKeys_: Record<string, DecryptedMasterKey> = {};
|
private decryptedMasterKeys_: Record<string, DecryptedMasterKey> = {};
|
||||||
public defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A; // public because used in tests
|
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
|
||||||
private defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||||
|
|
||||||
private headerTemplates_ = {
|
private headerTemplates_ = {
|
||||||
// Template version 1
|
// Template version 1
|
||||||
@@ -79,8 +95,8 @@ export default class EncryptionService {
|
|||||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||||
this.chunkSize_ = 5000;
|
this.chunkSize_ = 5000;
|
||||||
this.decryptedMasterKeys_ = {};
|
this.decryptedMasterKeys_ = {};
|
||||||
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
|
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
||||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
this.defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||||
|
|
||||||
this.headerTemplates_ = {
|
this.headerTemplates_ = {
|
||||||
// Template version 1
|
// Template version 1
|
||||||
@@ -97,6 +113,10 @@ export default class EncryptionService {
|
|||||||
return this.instance_;
|
return this.instance_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get defaultMasterKeyEncryptionMethod() {
|
||||||
|
return this.defaultMasterKeyEncryptionMethod_;
|
||||||
|
}
|
||||||
|
|
||||||
loadedMasterKeysCount() {
|
loadedMasterKeysCount() {
|
||||||
return Object.keys(this.decryptedMasterKeys_).length;
|
return Object.keys(this.decryptedMasterKeys_).length;
|
||||||
}
|
}
|
||||||
@@ -135,7 +155,7 @@ export default class EncryptionService {
|
|||||||
logger.info(`Loading master key: ${model.id}. Make active:`, makeActive);
|
logger.info(`Loading master key: ${model.id}. Make active:`, makeActive);
|
||||||
|
|
||||||
this.decryptedMasterKeys_[model.id] = {
|
this.decryptedMasterKeys_[model.id] = {
|
||||||
plainText: await this.decryptMasterKey_(model, password),
|
plainText: await this.decryptMasterKeyContent(model, password),
|
||||||
updatedTime: model.updated_time,
|
updatedTime: model.updated_time,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +195,7 @@ export default class EncryptionService {
|
|||||||
return await this.randomHexString(64);
|
return await this.randomHexString(64);
|
||||||
}
|
}
|
||||||
|
|
||||||
async randomHexString(byteCount: number) {
|
private async randomHexString(byteCount: number) {
|
||||||
const bytes: any[] = await shim.randomBytes(byteCount);
|
const bytes: any[] = await shim.randomBytes(byteCount);
|
||||||
return bytes
|
return bytes
|
||||||
.map(a => {
|
.map(a => {
|
||||||
@@ -184,32 +204,39 @@ export default class EncryptionService {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
masterKeysThatNeedUpgrading(masterKeys: MasterKeyEntity[]) {
|
public masterKeysThatNeedUpgrading(masterKeys: MasterKeyEntity[]) {
|
||||||
const output = MasterKey.allWithoutEncryptionMethod(masterKeys, this.defaultMasterKeyEncryptionMethod_);
|
return MasterKey.allWithoutEncryptionMethod(masterKeys, [this.defaultMasterKeyEncryptionMethod_, EncryptionMethod.Custom]);
|
||||||
// Anything below 5 is a new encryption method and doesn't need an upgrade
|
|
||||||
return output.filter(mk => mk.encryption_method <= 5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradeMasterKey(model: MasterKeyEntity, decryptionPassword: string) {
|
public async reencryptMasterKey(model: MasterKeyEntity, decryptionPassword: string, encryptionPassword: string, decryptOptions: EncryptOptions = null, encryptOptions: EncryptOptions = null): Promise<MasterKeyEntity> {
|
||||||
const newEncryptionMethod = this.defaultMasterKeyEncryptionMethod_;
|
const newEncryptionMethod = this.defaultMasterKeyEncryptionMethod_;
|
||||||
const plainText = await this.decryptMasterKey_(model, decryptionPassword);
|
const plainText = await this.decryptMasterKeyContent(model, decryptionPassword, decryptOptions);
|
||||||
const newContent = await this.encryptMasterKeyContent_(newEncryptionMethod, plainText, decryptionPassword);
|
const newContent = await this.encryptMasterKeyContent(newEncryptionMethod, plainText, encryptionPassword, encryptOptions);
|
||||||
return { ...model, ...newContent };
|
return { ...model, ...newContent };
|
||||||
}
|
}
|
||||||
|
|
||||||
async encryptMasterKeyContent_(encryptionMethod: number, hexaBytes: any, password: string): Promise<MasterKeyEntity> {
|
public async encryptMasterKeyContent(encryptionMethod: EncryptionMethod, hexaBytes: string, password: string, options: EncryptOptions = null): Promise<MasterKeyEntity> {
|
||||||
// Checksum is not necessary since decryption will already fail if data is invalid
|
options = { ...options };
|
||||||
const checksum = encryptionMethod === EncryptionService.METHOD_SJCL_2 ? this.sha256(hexaBytes) : '';
|
|
||||||
const cipherText = await this.encrypt(encryptionMethod, password, hexaBytes);
|
|
||||||
|
|
||||||
return {
|
if (encryptionMethod === null) encryptionMethod = this.defaultMasterKeyEncryptionMethod_;
|
||||||
checksum: checksum,
|
|
||||||
encryption_method: encryptionMethod,
|
if (options.encryptionHandler) {
|
||||||
content: cipherText,
|
return {
|
||||||
};
|
checksum: '',
|
||||||
|
encryption_method: EncryptionMethod.Custom,
|
||||||
|
content: await options.encryptionHandler.encrypt(options.encryptionHandler.context, hexaBytes, password),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
// Checksum is not necessary since decryption will already fail if data is invalid
|
||||||
|
checksum: encryptionMethod === EncryptionMethod.SJCL2 ? this.sha256(hexaBytes) : '',
|
||||||
|
encryption_method: encryptionMethod,
|
||||||
|
content: await this.encrypt(encryptionMethod, password, hexaBytes),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMasterKeyContent_(password: string, options: any = null) {
|
private async generateMasterKeyContent_(password: string, options: EncryptOptions = null) {
|
||||||
options = Object.assign({}, {
|
options = Object.assign({}, {
|
||||||
encryptionMethod: this.defaultMasterKeyEncryptionMethod_,
|
encryptionMethod: this.defaultMasterKeyEncryptionMethod_,
|
||||||
}, options);
|
}, options);
|
||||||
@@ -217,10 +244,10 @@ export default class EncryptionService {
|
|||||||
const bytes: any[] = await shim.randomBytes(256);
|
const bytes: any[] = await shim.randomBytes(256);
|
||||||
const hexaBytes = bytes.map(a => hexPad(a.toString(16), 2)).join('');
|
const hexaBytes = bytes.map(a => hexPad(a.toString(16), 2)).join('');
|
||||||
|
|
||||||
return this.encryptMasterKeyContent_(options.encryptionMethod, hexaBytes, password);
|
return this.encryptMasterKeyContent(options.encryptionMethod, hexaBytes, password, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMasterKey(password: string, options: any = null) {
|
public async generateMasterKey(password: string, options: EncryptOptions = null) {
|
||||||
const model = await this.generateMasterKeyContent_(password, options);
|
const model = await this.generateMasterKeyContent_(password, options);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -231,9 +258,16 @@ export default class EncryptionService {
|
|||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async decryptMasterKey_(model: MasterKeyEntity, password: string): Promise<string> {
|
public async decryptMasterKeyContent(model: MasterKeyEntity, password: string, options: EncryptOptions = null): Promise<string> {
|
||||||
|
options = options || {};
|
||||||
|
|
||||||
|
if (model.encryption_method === EncryptionMethod.Custom) {
|
||||||
|
if (!options.encryptionHandler) throw new Error('Master key was encrypted using a custom method, but no encryptionHandler is provided');
|
||||||
|
return options.encryptionHandler.decrypt(options.encryptionHandler.context, model.content, password);
|
||||||
|
}
|
||||||
|
|
||||||
const plainText = await this.decrypt(model.encryption_method, password, model.content);
|
const plainText = await this.decrypt(model.encryption_method, password, model.content);
|
||||||
if (model.encryption_method === EncryptionService.METHOD_SJCL_2) {
|
if (model.encryption_method === EncryptionMethod.SJCL2) {
|
||||||
const checksum = this.sha256(plainText);
|
const checksum = this.sha256(plainText);
|
||||||
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
||||||
}
|
}
|
||||||
@@ -243,7 +277,7 @@ export default class EncryptionService {
|
|||||||
|
|
||||||
public async checkMasterKeyPassword(model: MasterKeyEntity, password: string) {
|
public async checkMasterKeyPassword(model: MasterKeyEntity, password: string) {
|
||||||
try {
|
try {
|
||||||
await this.decryptMasterKey_(model, password);
|
await this.decryptMasterKeyContent(model, password);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -257,14 +291,14 @@ export default class EncryptionService {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async encrypt(method: number, key: string, plainText: string) {
|
public async encrypt(method: EncryptionMethod, key: string, plainText: string): Promise<string> {
|
||||||
if (!method) throw new Error('Encryption method is required');
|
if (!method) throw new Error('Encryption method is required');
|
||||||
if (!key) throw new Error('Encryption key is required');
|
if (!key) throw new Error('Encryption key is required');
|
||||||
|
|
||||||
const sjcl = shim.sjclModule;
|
const sjcl = shim.sjclModule;
|
||||||
|
|
||||||
// 2020-01-23: Deprecated and no longer secure due to the use og OCB2 mode - do not use.
|
// 2020-01-23: Deprecated and no longer secure due to the use og OCB2 mode - do not use.
|
||||||
if (method === EncryptionService.METHOD_SJCL) {
|
if (method === EncryptionMethod.SJCL) {
|
||||||
try {
|
try {
|
||||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||||
return sjcl.json.encrypt(key, plainText, {
|
return sjcl.json.encrypt(key, plainText, {
|
||||||
@@ -283,7 +317,7 @@ export default class EncryptionService {
|
|||||||
|
|
||||||
// 2020-03-06: Added method to fix https://github.com/laurent22/joplin/issues/2591
|
// 2020-03-06: Added method to fix https://github.com/laurent22/joplin/issues/2591
|
||||||
// Also took the opportunity to change number of key derivations, per Isaac Potoczny's suggestion
|
// Also took the opportunity to change number of key derivations, per Isaac Potoczny's suggestion
|
||||||
if (method === EncryptionService.METHOD_SJCL_1A) {
|
if (method === EncryptionMethod.SJCL1a) {
|
||||||
try {
|
try {
|
||||||
// We need to escape the data because SJCL uses encodeURIComponent to process the data and it only
|
// We need to escape the data because SJCL uses encodeURIComponent to process the data and it only
|
||||||
// accepts UTF-8 data, or else it throws an error. And the notes might occasionally contain
|
// accepts UTF-8 data, or else it throws an error. And the notes might occasionally contain
|
||||||
@@ -304,7 +338,7 @@ export default class EncryptionService {
|
|||||||
|
|
||||||
// 2020-01-23: Deprectated - see above.
|
// 2020-01-23: Deprectated - see above.
|
||||||
// Was used to encrypt master keys
|
// Was used to encrypt master keys
|
||||||
if (method === EncryptionService.METHOD_SJCL_2) {
|
if (method === EncryptionMethod.SJCL2) {
|
||||||
try {
|
try {
|
||||||
return sjcl.json.encrypt(key, plainText, {
|
return sjcl.json.encrypt(key, plainText, {
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -319,7 +353,7 @@ export default class EncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (method === EncryptionService.METHOD_SJCL_3) {
|
if (method === EncryptionMethod.SJCL3) {
|
||||||
try {
|
try {
|
||||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||||
return sjcl.json.encrypt(key, plainText, {
|
return sjcl.json.encrypt(key, plainText, {
|
||||||
@@ -337,7 +371,7 @@ export default class EncryptionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same as above but more secure (but slower) to encrypt master keys
|
// Same as above but more secure (but slower) to encrypt master keys
|
||||||
if (method === EncryptionService.METHOD_SJCL_4) {
|
if (method === EncryptionMethod.SJCL4) {
|
||||||
try {
|
try {
|
||||||
return sjcl.json.encrypt(key, plainText, {
|
return sjcl.json.encrypt(key, plainText, {
|
||||||
v: 1,
|
v: 1,
|
||||||
@@ -355,7 +389,7 @@ export default class EncryptionService {
|
|||||||
throw new Error(`Unknown encryption method: ${method}`);
|
throw new Error(`Unknown encryption method: ${method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt(method: number, key: string, cipherText: string) {
|
async decrypt(method: EncryptionMethod, key: string, cipherText: string) {
|
||||||
if (!method) throw new Error('Encryption method is required');
|
if (!method) throw new Error('Encryption method is required');
|
||||||
if (!key) throw new Error('Encryption key is required');
|
if (!key) throw new Error('Encryption key is required');
|
||||||
|
|
||||||
@@ -365,7 +399,7 @@ export default class EncryptionService {
|
|||||||
try {
|
try {
|
||||||
const output = sjcl.json.decrypt(key, cipherText);
|
const output = sjcl.json.decrypt(key, cipherText);
|
||||||
|
|
||||||
if (method === EncryptionService.METHOD_SJCL_1A) {
|
if (method === EncryptionMethod.SJCL1a) {
|
||||||
return unescape(output);
|
return unescape(output);
|
||||||
} else {
|
} else {
|
||||||
return output;
|
return output;
|
||||||
@@ -376,13 +410,13 @@ export default class EncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async encryptAbstract_(source: any, destination: any, options: any = null) {
|
async encryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
||||||
options = Object.assign({}, {
|
options = Object.assign({}, {
|
||||||
encryptionMethod: this.defaultEncryptionMethod(),
|
encryptionMethod: this.defaultEncryptionMethod(),
|
||||||
}, options);
|
}, options);
|
||||||
|
|
||||||
const method = options.encryptionMethod;
|
const method = options.encryptionMethod;
|
||||||
const masterKeyId = this.activeMasterKeyId();
|
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||||
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
||||||
|
|
||||||
const header = {
|
const header = {
|
||||||
@@ -412,7 +446,7 @@ export default class EncryptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptAbstract_(source: any, destination: any, options: any = null) {
|
async decryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
||||||
if (!options) options = {};
|
if (!options) options = {};
|
||||||
|
|
||||||
const header: any = await this.decodeHeaderSource_(source);
|
const header: any = await this.decodeHeaderSource_(source);
|
||||||
@@ -489,21 +523,21 @@ export default class EncryptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async encryptString(plainText: any, options: any = null) {
|
public async encryptString(plainText: any, options: EncryptOptions = null): Promise<string> {
|
||||||
const source = this.stringReader_(plainText);
|
const source = this.stringReader_(plainText);
|
||||||
const destination = this.stringWriter_();
|
const destination = this.stringWriter_();
|
||||||
await this.encryptAbstract_(source, destination, options);
|
await this.encryptAbstract_(source, destination, options);
|
||||||
return destination.result();
|
return destination.result();
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptString(cipherText: any, options: any = null) {
|
public async decryptString(cipherText: any, options: EncryptOptions = null): Promise<string> {
|
||||||
const source = this.stringReader_(cipherText);
|
const source = this.stringReader_(cipherText);
|
||||||
const destination = this.stringWriter_();
|
const destination = this.stringWriter_();
|
||||||
await this.decryptAbstract_(source, destination, options);
|
await this.decryptAbstract_(source, destination, options);
|
||||||
return destination.data.join('');
|
return destination.data.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async encryptFile(srcPath: string, destPath: string, options: any = null) {
|
async encryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
|
||||||
let source = await this.fileReader_(srcPath, 'base64');
|
let source = await this.fileReader_(srcPath, 'base64');
|
||||||
let destination = await this.fileWriter_(destPath, 'ascii');
|
let destination = await this.fileWriter_(destPath, 'ascii');
|
||||||
|
|
||||||
@@ -528,7 +562,7 @@ export default class EncryptionService {
|
|||||||
await cleanUp();
|
await cleanUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
async decryptFile(srcPath: string, destPath: string, options: any = null) {
|
async decryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
|
||||||
let source = await this.fileReader_(srcPath, 'ascii');
|
let source = await this.fileReader_(srcPath, 'ascii');
|
||||||
let destination = await this.fileWriter_(destPath, 'base64');
|
let destination = await this.fileWriter_(destPath, 'base64');
|
||||||
|
|
||||||
@@ -617,8 +651,8 @@ export default class EncryptionService {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
isValidEncryptionMethod(method: number) {
|
isValidEncryptionMethod(method: EncryptionMethod) {
|
||||||
return [EncryptionService.METHOD_SJCL, EncryptionService.METHOD_SJCL_1A, EncryptionService.METHOD_SJCL_2, EncryptionService.METHOD_SJCL_3, EncryptionService.METHOD_SJCL_4].indexOf(method) >= 0;
|
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4].indexOf(method) >= 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
async itemIsEncrypted(item: any) {
|
async itemIsEncrypted(item: any) {
|
||||||
|
|||||||
85
packages/lib/services/e2ee/ppk.test.ts
Normal file
85
packages/lib/services/e2ee/ppk.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { afterAllCleanUp, encryptionService, expectThrow, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||||
|
import { decryptPrivateKey, generateKeyPair, ppkDecryptMasterKeyContent, ppkGenerateMasterKey, ppkPasswordIsValid, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from './ppk';
|
||||||
|
|
||||||
|
describe('e2ee/ppk', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
await switchClient(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await afterAllCleanUp();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a public private key pair', async () => {
|
||||||
|
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||||
|
|
||||||
|
const privateKey = await decryptPrivateKey(encryptionService(), ppk.privateKey, '111111');
|
||||||
|
const publicKey = ppk.publicKey;
|
||||||
|
|
||||||
|
expect(privateKey).toContain('BEGIN RSA PRIVATE KEY');
|
||||||
|
expect(privateKey).toContain('END RSA PRIVATE KEY');
|
||||||
|
expect(privateKey.length).toBeGreaterThan(350);
|
||||||
|
|
||||||
|
expect(publicKey).toContain('BEGIN RSA PUBLIC KEY');
|
||||||
|
expect(publicKey).toContain('END RSA PUBLIC KEY');
|
||||||
|
expect(publicKey.length).toBeGreaterThan(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create different key pairs every time', async () => {
|
||||||
|
const ppk1 = await generateKeyPair(encryptionService(), '111111');
|
||||||
|
const ppk2 = await generateKeyPair(encryptionService(), '111111');
|
||||||
|
|
||||||
|
const privateKey1 = await decryptPrivateKey(encryptionService(), ppk1.privateKey, '111111');
|
||||||
|
const privateKey2 = await decryptPrivateKey(encryptionService(), ppk2.privateKey, '111111');
|
||||||
|
const publicKey1 = ppk1.publicKey;
|
||||||
|
const publicKey2 = ppk2.publicKey;
|
||||||
|
|
||||||
|
expect(privateKey1).not.toBe(privateKey2);
|
||||||
|
expect(publicKey1).not.toBe(publicKey2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should encrypt a master key using PPK', (async () => {
|
||||||
|
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||||
|
const masterKey = await ppkGenerateMasterKey(encryptionService(), ppk, '111111');
|
||||||
|
const plainText = await ppkDecryptMasterKeyContent(encryptionService(), masterKey, ppk, '111111');
|
||||||
|
expect(plainText.length).toBeGreaterThan(50); // Just checking it's not empty
|
||||||
|
expect(plainText).not.toBe(masterKey.content);
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should check if a PPK password is valid', (async () => {
|
||||||
|
const ppk = await generateKeyPair(encryptionService(), '111111');
|
||||||
|
expect(await ppkPasswordIsValid(encryptionService(), ppk, '222')).toBe(false);
|
||||||
|
expect(await ppkPasswordIsValid(encryptionService(), ppk, '111111')).toBe(true);
|
||||||
|
await expectThrow(async () => ppkPasswordIsValid(encryptionService(), null, '111111'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should transmit key using a public-private key', (async () => {
|
||||||
|
// This simulate sending a key from one user to another using
|
||||||
|
// public-private key encryption. For example used when sharing a
|
||||||
|
// notebook while E2EE is enabled.
|
||||||
|
|
||||||
|
// User 1 generates a master key
|
||||||
|
const key1 = await encryptionService().generateMasterKey('mk_1111');
|
||||||
|
|
||||||
|
// Using user 2 private key, he reencrypts the master key
|
||||||
|
const ppk2 = await generateKeyPair(encryptionService(), 'ppk_1111');
|
||||||
|
const ppkEncrypted = await mkReencryptFromPasswordToPublicKey(encryptionService(), key1, 'mk_1111', ppk2);
|
||||||
|
|
||||||
|
// Once user 2 gets the master key, he can decrypt it using his private key
|
||||||
|
const key2 = await mkReencryptFromPublicKeyToPassword(encryptionService(), ppkEncrypted, ppk2, 'ppk_1111', 'mk_2222');
|
||||||
|
|
||||||
|
// Once it's done, both users should have the same master key
|
||||||
|
const plaintext1 = await encryptionService().decryptMasterKeyContent(key1, 'mk_1111');
|
||||||
|
const plaintext2 = await encryptionService().decryptMasterKeyContent(key2, 'mk_2222');
|
||||||
|
|
||||||
|
expect(plaintext1).toBe(plaintext2);
|
||||||
|
|
||||||
|
// We should make sure that the keys are also different when encrypted
|
||||||
|
// since they should be using different passwords.
|
||||||
|
expect(key1.content).not.toBe(key2.content);
|
||||||
|
}));
|
||||||
|
|
||||||
|
});
|
||||||
194
packages/lib/services/e2ee/ppk.ts
Normal file
194
packages/lib/services/e2ee/ppk.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import * as NodeRSA from 'node-rsa';
|
||||||
|
import uuid from '../../uuid';
|
||||||
|
import { getActiveMasterKey, saveLocalSyncInfo, SyncInfo } from '../synchronizer/syncInfoUtils';
|
||||||
|
import EncryptionService, { EncryptionCustomHandler, EncryptionMethod } from './EncryptionService';
|
||||||
|
import { MasterKeyEntity } from './types';
|
||||||
|
import { getMasterPassword } from './utils';
|
||||||
|
|
||||||
|
interface PrivateKey {
|
||||||
|
encryptionMethod: EncryptionMethod;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicKey = string;
|
||||||
|
|
||||||
|
export interface PublicPrivateKeyPair {
|
||||||
|
id: string;
|
||||||
|
publicKey: PublicKey;
|
||||||
|
privateKey: PrivateKey;
|
||||||
|
createdTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptPrivateKey(encryptionService: EncryptionService, password: string, plainText: string): Promise<PrivateKey> {
|
||||||
|
return {
|
||||||
|
encryptionMethod: EncryptionMethod.SJCL4,
|
||||||
|
ciphertext: await encryptionService.encrypt(EncryptionMethod.SJCL4, password, plainText),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPrivateKey(encryptionService: EncryptionService, encryptedKey: PrivateKey, password: string): Promise<string> {
|
||||||
|
return encryptionService.decrypt(encryptedKey.encryptionMethod, password, encryptedKey.ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeRSAEncryptionScheme = 'pkcs1_oaep';
|
||||||
|
|
||||||
|
function nodeRSAOptions(): NodeRSA.Options {
|
||||||
|
return {
|
||||||
|
encryptionScheme: nodeRSAEncryptionScheme,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair(encryptionService: EncryptionService, password: string): Promise<PublicPrivateKeyPair> {
|
||||||
|
const keys = new NodeRSA();
|
||||||
|
keys.setOptions(nodeRSAOptions());
|
||||||
|
keys.generateKeyPair(2048, 65537);
|
||||||
|
|
||||||
|
// Sanity check
|
||||||
|
if (!keys.isPrivate()) throw new Error('No private key was generated');
|
||||||
|
if (!keys.isPublic()) throw new Error('No public key was generated');
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuid.createNano(),
|
||||||
|
privateKey: await encryptPrivateKey(encryptionService, password, keys.exportKey('pkcs1-private-pem')),
|
||||||
|
publicKey: keys.exportKey('pkcs1-public-pem'),
|
||||||
|
createdTime: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pkReencryptPrivateKey(encryptionService: EncryptionService, ppk: PublicPrivateKeyPair, decryptionPassword: string, encryptionPassword: string): Promise<PublicPrivateKeyPair> {
|
||||||
|
const decryptedPrivate = await decryptPrivateKey(encryptionService, ppk.privateKey, decryptionPassword);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...ppk,
|
||||||
|
privateKey: await encryptPrivateKey(encryptionService, encryptionPassword, decryptedPrivate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPairAndSave(encryptionService: EncryptionService, localInfo: SyncInfo, password: string): Promise<PublicPrivateKeyPair> {
|
||||||
|
localInfo.ppk = await generateKeyPair(encryptionService, password);
|
||||||
|
saveLocalSyncInfo(localInfo);
|
||||||
|
return localInfo.ppk;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPpkIfNotExist(service: EncryptionService, localInfo: SyncInfo, remoteInfo: SyncInfo) {
|
||||||
|
if (localInfo.ppk || remoteInfo.ppk) return;
|
||||||
|
|
||||||
|
const masterKey = getActiveMasterKey(localInfo);
|
||||||
|
if (!masterKey) return;
|
||||||
|
|
||||||
|
const password = getMasterPassword(false);
|
||||||
|
if (!password) return;
|
||||||
|
|
||||||
|
await generateKeyPairAndSave(service, localInfo, getMasterPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ppkPasswordIsValid(service: EncryptionService, ppk: PublicPrivateKeyPair, password: string): Promise<boolean> {
|
||||||
|
if (!ppk) throw new Error('PPK is undefined');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loadPpk(service, ppk, password);
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPpk(service: EncryptionService, ppk: PublicPrivateKeyPair, password: string): Promise<NodeRSA> {
|
||||||
|
const keys = new NodeRSA();
|
||||||
|
keys.setOptions(nodeRSAOptions());
|
||||||
|
keys.importKey(ppk.publicKey, 'pkcs1-public-pem');
|
||||||
|
keys.importKey(await decryptPrivateKey(service, ppk.privateKey, password), 'pkcs1-private-pem');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicKey(publicKey: PublicKey): Promise<NodeRSA> {
|
||||||
|
const keys = new NodeRSA();
|
||||||
|
keys.setOptions(nodeRSAOptions());
|
||||||
|
keys.importKey(publicKey, 'pkcs1-public-pem');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ppkEncryptionHandler(ppkId: string, nodeRSA: NodeRSA): EncryptionCustomHandler {
|
||||||
|
interface Context {
|
||||||
|
nodeRSA: NodeRSA;
|
||||||
|
ppkId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
context: {
|
||||||
|
nodeRSA,
|
||||||
|
ppkId,
|
||||||
|
},
|
||||||
|
encrypt: async (context: Context, hexaBytes: string, _password: string): Promise<string> => {
|
||||||
|
return JSON.stringify({
|
||||||
|
ppkId: context.ppkId,
|
||||||
|
scheme: nodeRSAEncryptionScheme,
|
||||||
|
ciphertext: context.nodeRSA.encrypt(hexaBytes, 'hex'),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
decrypt: async (context: Context, ciphertext: string, _password: string): Promise<string> => {
|
||||||
|
const parsed = JSON.parse(ciphertext);
|
||||||
|
if (parsed.ppkId !== context.ppkId) throw new Error(`Needs private key ${parsed.ppkId} to decrypt, but using ${context.ppkId}`);
|
||||||
|
return context.nodeRSA.decrypt(Buffer.from(parsed.ciphertext, 'hex'), 'utf8');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generates a master key and encrypts it using the provided PPK
|
||||||
|
export async function ppkGenerateMasterKey(service: EncryptionService, ppk: PublicPrivateKeyPair, password: string): Promise<MasterKeyEntity> {
|
||||||
|
const nodeRSA = await loadPpk(service, ppk, password);
|
||||||
|
const handler = ppkEncryptionHandler(ppk.id, nodeRSA);
|
||||||
|
|
||||||
|
return service.generateMasterKey('', {
|
||||||
|
encryptionMethod: EncryptionMethod.Custom,
|
||||||
|
encryptionHandler: handler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the content of a master key that was encrypted using ppkGenerateMasterKey()
|
||||||
|
export async function ppkDecryptMasterKeyContent(service: EncryptionService, masterKey: MasterKeyEntity, ppk: PublicPrivateKeyPair, password: string): Promise<string> {
|
||||||
|
const nodeRSA = await loadPpk(service, ppk, password);
|
||||||
|
const handler = ppkEncryptionHandler(ppk.id, nodeRSA);
|
||||||
|
|
||||||
|
return service.decryptMasterKeyContent(masterKey, '', {
|
||||||
|
encryptionHandler: handler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mkReencryptFromPasswordToPublicKey(service: EncryptionService, masterKey: MasterKeyEntity, decryptionPassword: string, encryptionPublicKey: PublicPrivateKeyPair): Promise<MasterKeyEntity> {
|
||||||
|
const encryptionHandler = ppkEncryptionHandler(encryptionPublicKey.id, await loadPublicKey(encryptionPublicKey.publicKey));
|
||||||
|
|
||||||
|
const plainText = await service.decryptMasterKeyContent(masterKey, decryptionPassword);
|
||||||
|
const newContent = await service.encryptMasterKeyContent(EncryptionMethod.Custom, plainText, '', { encryptionHandler });
|
||||||
|
|
||||||
|
return { ...masterKey, ...newContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mkReencryptFromPublicKeyToPassword(service: EncryptionService, masterKey: MasterKeyEntity, decryptionPpk: PublicPrivateKeyPair, decryptionPassword: string, encryptionPassword: string): Promise<MasterKeyEntity> {
|
||||||
|
const decryptionHandler = ppkEncryptionHandler(decryptionPpk.id, await loadPpk(service, decryptionPpk, decryptionPassword));
|
||||||
|
|
||||||
|
const plainText = await service.decryptMasterKeyContent(masterKey, '', { encryptionHandler: decryptionHandler });
|
||||||
|
const newContent = await service.encryptMasterKeyContent(null, plainText, encryptionPassword);
|
||||||
|
|
||||||
|
return { ...masterKey, ...newContent };
|
||||||
|
}
|
||||||
|
|
||||||
|
// export async function reencryptFromPasswordToPassword(service: EncryptionService, masterKey: MasterKeyEntity, decryptionPassword: string, encryptionPassword: string): Promise<MasterKeyEntity> {
|
||||||
|
// const plainText = await service.decryptMasterKeyContent(masterKey, decryptionPassword);
|
||||||
|
// const newContent = await service.encryptMasterKeyContent(null, plainText, encryptionPassword);
|
||||||
|
|
||||||
|
// return { ...masterKey, ...newContent };
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// export async function ppkReencryptMasterKey(service: EncryptionService, masterKey: MasterKeyEntity, decryptionPpk: PublicPrivateKeyPair, decryptionPassword: string, encryptionPublicKey: PublicPrivateKeyPair): Promise<MasterKeyEntity> {
|
||||||
|
// const encryptionHandler = ppkEncryptionHandler(encryptionPublicKey.id, await loadPublicKey(encryptionPublicKey.publicKey));
|
||||||
|
// const decryptionHandler = ppkEncryptionHandler(decryptionPpk.id, await loadPpk(service, decryptionPpk, decryptionPassword));
|
||||||
|
|
||||||
|
// return service.reencryptMasterKey(masterKey, '', '', {
|
||||||
|
// encryptionHandler: decryptionHandler,
|
||||||
|
// }, {
|
||||||
|
// encryptionHandler: encryptionHandler,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, expectNotThrow } from '../../testing/test-utils';
|
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, expectNotThrow, expectThrow } from '../../testing/test-utils';
|
||||||
import MasterKey from '../../models/MasterKey';
|
import MasterKey from '../../models/MasterKey';
|
||||||
import { migrateMasterPassword, showMissingMasterKeyMessage } from './utils';
|
import { migrateMasterPassword, showMissingMasterKeyMessage, updateMasterPassword } from './utils';
|
||||||
import { localSyncInfo, setActiveMasterKeyId, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
|
import { localSyncInfo, setActiveMasterKeyId, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
|
import { generateKeyPairAndSave, ppkPasswordIsValid } from './ppk';
|
||||||
|
|
||||||
describe('e2ee/utils', function() {
|
describe('e2ee/utils', function() {
|
||||||
|
|
||||||
@@ -71,4 +72,32 @@ describe('e2ee/utils', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should update the master password', async () => {
|
||||||
|
const masterPassword1 = '111111';
|
||||||
|
const masterPassword2 = '222222';
|
||||||
|
Setting.setValue('encryption.masterPassword', masterPassword1);
|
||||||
|
const mk1 = await MasterKey.save(await encryptionService().generateMasterKey(masterPassword1));
|
||||||
|
const mk2 = await MasterKey.save(await encryptionService().generateMasterKey(masterPassword1));
|
||||||
|
await generateKeyPairAndSave(encryptionService(), localSyncInfo(), masterPassword1);
|
||||||
|
|
||||||
|
await updateMasterPassword(masterPassword1, masterPassword2);
|
||||||
|
|
||||||
|
expect(Setting.value('encryption.masterPassword')).toBe(masterPassword2);
|
||||||
|
expect(await ppkPasswordIsValid(encryptionService(), localSyncInfo().ppk, masterPassword1)).toBe(false);
|
||||||
|
expect(await ppkPasswordIsValid(encryptionService(), localSyncInfo().ppk, masterPassword2)).toBe(true);
|
||||||
|
expect(await encryptionService().checkMasterKeyPassword(await MasterKey.load(mk1.id), masterPassword1)).toBe(false);
|
||||||
|
expect(await encryptionService().checkMasterKeyPassword(await MasterKey.load(mk2.id), masterPassword1)).toBe(false);
|
||||||
|
expect(await encryptionService().checkMasterKeyPassword(await MasterKey.load(mk1.id), masterPassword2)).toBe(true);
|
||||||
|
expect(await encryptionService().checkMasterKeyPassword(await MasterKey.load(mk2.id), masterPassword2)).toBe(true);
|
||||||
|
|
||||||
|
await expectThrow(async () => updateMasterPassword('wrong', masterPassword1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set the master password and generate a PPK if not already set', async () => {
|
||||||
|
expect(localSyncInfo().ppk).toBeFalsy();
|
||||||
|
await updateMasterPassword('', '111111');
|
||||||
|
expect(Setting.value('encryption.masterPassword')).toBe('111111');
|
||||||
|
expect(await ppkPasswordIsValid(encryptionService(), localSyncInfo().ppk, '111111')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import MasterKey from '../../models/MasterKey';
|
|||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import { MasterKeyEntity } from './types';
|
import { MasterKeyEntity } from './types';
|
||||||
import EncryptionService from './EncryptionService';
|
import EncryptionService from './EncryptionService';
|
||||||
import { getActiveMasterKey, getActiveMasterKeyId, masterKeyEnabled, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';
|
import { getActiveMasterKey, getActiveMasterKeyId, localSyncInfo, masterKeyEnabled, saveLocalSyncInfo, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';
|
||||||
|
import JoplinError from '../../JoplinError';
|
||||||
|
import { generateKeyPairAndSave, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk';
|
||||||
|
|
||||||
const logger = Logger.create('e2ee/utils');
|
const logger = Logger.create('e2ee/utils');
|
||||||
|
|
||||||
@@ -165,3 +167,108 @@ export function getDefaultMasterKey(): MasterKeyEntity {
|
|||||||
if (mk) return mk;
|
if (mk) return mk;
|
||||||
return MasterKey.latest();
|
return MasterKey.latest();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the master password if set, or throw an exception. This ensures that
|
||||||
|
// things aren't accidentally encrypted with an empty string. Calling code
|
||||||
|
// should look for "undefinedMasterPassword" code and prompt for password.
|
||||||
|
export function getMasterPassword(throwIfNotSet: boolean = true): string {
|
||||||
|
const password = Setting.value('encryption.masterPassword');
|
||||||
|
if (!password && throwIfNotSet) throw new JoplinError('Master password is not set', 'undefinedMasterPassword');
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMasterPassword(currentPassword: string, newPassword: string, waitForSyncFinishedThenSync: Function = null) {
|
||||||
|
const syncInfo = localSyncInfo();
|
||||||
|
|
||||||
|
if (currentPassword) {
|
||||||
|
const reencryptedMasterKeys: MasterKeyEntity[] = [];
|
||||||
|
let reencryptedPpk = null;
|
||||||
|
|
||||||
|
for (const mk of localSyncInfo().masterKeys) {
|
||||||
|
try {
|
||||||
|
reencryptedMasterKeys.push(await EncryptionService.instance().reencryptMasterKey(mk, currentPassword, newPassword));
|
||||||
|
} catch (error) {
|
||||||
|
error.message = `Master key ${mk.id} could not be reencrypted - this is most likely due to an incorrect password. Please try again. Error was: ${error.message}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (localSyncInfo().ppk) {
|
||||||
|
try {
|
||||||
|
reencryptedPpk = await pkReencryptPrivateKey(EncryptionService.instance(), localSyncInfo().ppk, currentPassword, newPassword);
|
||||||
|
} catch (error) {
|
||||||
|
error.message = `Private key could not be reencrypted - this is most likely due to an incorrect password. Please try again. Error was: ${error.message}`;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting.setValue('encryption.masterPassword', newPassword);
|
||||||
|
|
||||||
|
for (const mk of reencryptedMasterKeys) {
|
||||||
|
await MasterKey.save(mk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reencryptedPpk) {
|
||||||
|
const syncInfo = localSyncInfo();
|
||||||
|
syncInfo.ppk = reencryptedPpk;
|
||||||
|
saveLocalSyncInfo(syncInfo);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (syncInfo.ppk || syncInfo.masterKeys?.length) throw new Error('Previous password must be provided in order to reencrypt the encryption keys');
|
||||||
|
await generateKeyPairAndSave(EncryptionService.instance(), syncInfo, newPassword);
|
||||||
|
Setting.setValue('encryption.masterPassword', newPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (waitForSyncFinishedThenSync) void waitForSyncFinishedThenSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MasterPasswordStatus {
|
||||||
|
Unknown = 0,
|
||||||
|
Loaded = 1,
|
||||||
|
NotSet = 2,
|
||||||
|
Invalid = 3,
|
||||||
|
Valid = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMasterPasswordStatus(): Promise<MasterPasswordStatus> {
|
||||||
|
const password = getMasterPassword(false);
|
||||||
|
if (!password) return MasterPasswordStatus.NotSet;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isValid = await masterPasswordIsValid(password);
|
||||||
|
return isValid ? MasterPasswordStatus.Valid : MasterPasswordStatus.Invalid;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'noKeyToDecrypt') return MasterPasswordStatus.Loaded;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterPasswordStatusMessages = {
|
||||||
|
[MasterPasswordStatus.Unknown]: 'Checking...',
|
||||||
|
[MasterPasswordStatus.Loaded]: 'Loaded',
|
||||||
|
[MasterPasswordStatus.NotSet]: 'Not set',
|
||||||
|
[MasterPasswordStatus.Valid]: '✓ ' + 'Valid',
|
||||||
|
[MasterPasswordStatus.Invalid]: '❌ ' + 'Invalid',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMasterPasswordStatusMessage(status: MasterPasswordStatus): string {
|
||||||
|
return masterPasswordStatusMessages[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function masterPasswordIsValid(masterPassword: string): Promise<boolean> {
|
||||||
|
// A valid password is basically one that decrypts the private key, but due
|
||||||
|
// to backward compatibility not all users have a PPK yet, so we also check
|
||||||
|
// based on the active master key.
|
||||||
|
|
||||||
|
const ppk = localSyncInfo().ppk;
|
||||||
|
if (ppk) {
|
||||||
|
return ppkPasswordIsValid(EncryptionService.instance(), ppk, masterPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterKey = getDefaultMasterKey();
|
||||||
|
if (masterKey) {
|
||||||
|
return EncryptionService.instance().checkMasterKeyPassword(masterKey, masterPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new JoplinError('Cannot check master password validity as no key is present', 'noKeyToDecrypt');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,26 +1,19 @@
|
|||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import { msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
import { encryptionService, msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||||
import ShareService from './ShareService';
|
import ShareService from './ShareService';
|
||||||
import reducer from '../../reducer';
|
import reducer from '../../reducer';
|
||||||
import { createStore } from 'redux';
|
import { createStore } from 'redux';
|
||||||
import { NoteEntity } from '../database/types';
|
import { NoteEntity } from '../database/types';
|
||||||
|
import Folder from '../../models/Folder';
|
||||||
|
import { localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
|
import { generateKeyPair, generateKeyPairAndSave } from '../e2ee/ppk';
|
||||||
|
import MasterKey from '../../models/MasterKey';
|
||||||
|
import { MasterKeyEntity } from '../e2ee/types';
|
||||||
|
|
||||||
function mockApi() {
|
function mockService(api: any) {
|
||||||
return {
|
|
||||||
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
|
||||||
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
personalizedUserContentBaseUrl(_userId: string) {
|
|
||||||
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mockService() {
|
|
||||||
const service = new ShareService();
|
const service = new ShareService();
|
||||||
const store = createStore(reducer as any);
|
const store = createStore(reducer as any);
|
||||||
service.initialize(store, mockApi() as any);
|
service.initialize(store, encryptionService(), api);
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +25,17 @@ describe('ShareService', function() {
|
|||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not change the note user timestamps when sharing or unsharing', (async () => {
|
it('should not change the note user timestamps when sharing or unsharing', async () => {
|
||||||
let note = await Note.save({});
|
let note = await Note.save({});
|
||||||
const service = mockService();
|
const service = mockService({
|
||||||
|
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
||||||
|
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
personalizedUserContentBaseUrl(_userId: string) {
|
||||||
|
|
||||||
|
},
|
||||||
|
});
|
||||||
await msleep(1);
|
await msleep(1);
|
||||||
await service.shareNote(note.id);
|
await service.shareNote(note.id);
|
||||||
|
|
||||||
@@ -61,6 +62,86 @@ describe('ShareService', function() {
|
|||||||
const noteReloaded = await Note.load(note.id);
|
const noteReloaded = await Note.load(note.id);
|
||||||
checkTimestamps(note, noteReloaded);
|
checkTimestamps(note, noteReloaded);
|
||||||
}
|
}
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
function testShareFolderService(extraExecHandlers: Record<string, Function> = {}) {
|
||||||
|
return mockService({
|
||||||
|
exec: async (method: string, path: string, query: Record<string, any>, body: any) => {
|
||||||
|
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
|
||||||
|
|
||||||
|
if (method === 'POST' && path === 'api/shares') {
|
||||||
|
return {
|
||||||
|
id: 'share_1',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unhandled: ${method} ${path}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testShareFolder(service: ShareService) {
|
||||||
|
const folder = await Folder.save({});
|
||||||
|
const note = await Note.save({ parent_id: folder.id });
|
||||||
|
|
||||||
|
const share = await service.shareFolder(folder.id);
|
||||||
|
expect(share.id).toBe('share_1');
|
||||||
|
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
|
||||||
|
expect((await Note.load(note.id)).share_id).toBe('share_1');
|
||||||
|
|
||||||
|
return share;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should share a folder', async () => {
|
||||||
|
await testShareFolder(testShareFolderService());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should share a folder - E2EE', async () => {
|
||||||
|
setEncryptionEnabled(true);
|
||||||
|
const ppk = await generateKeyPairAndSave(encryptionService(), localSyncInfo(), '111111');
|
||||||
|
|
||||||
|
await testShareFolder(testShareFolderService());
|
||||||
|
|
||||||
|
expect((await MasterKey.all()).length).toBe(1);
|
||||||
|
|
||||||
|
const mk = (await MasterKey.all())[0];
|
||||||
|
const content = JSON.parse(mk.content);
|
||||||
|
expect(content.ppkId).toBe(ppk.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a recipient', async () => {
|
||||||
|
setEncryptionEnabled(true);
|
||||||
|
const ppk = await generateKeyPairAndSave(encryptionService(), localSyncInfo(), '111111');
|
||||||
|
const recipientPpk = await generateKeyPair(encryptionService(), '222222');
|
||||||
|
expect(ppk.id).not.toBe(recipientPpk.id);
|
||||||
|
|
||||||
|
let uploadedEmail: string = '';
|
||||||
|
let uploadedMasterKey: MasterKeyEntity = null;
|
||||||
|
|
||||||
|
const service = testShareFolderService({
|
||||||
|
'POST api/shares': (_query: Record<string, any>, body: any) => {
|
||||||
|
return {
|
||||||
|
id: 'share_1',
|
||||||
|
master_key_id: body.master_key_id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'GET api/users/toto%40example.com/public_key': async (_query: Record<string, any>, _body: any) => {
|
||||||
|
return recipientPpk;
|
||||||
|
},
|
||||||
|
'POST api/shares/share_1/users': async (_query: Record<string, any>, body: any) => {
|
||||||
|
uploadedEmail = body.email;
|
||||||
|
uploadedMasterKey = JSON.parse(body.master_key);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const share = await testShareFolder(service);
|
||||||
|
|
||||||
|
await service.addShareRecipient(share.id, share.master_key_id, 'toto@example.com');
|
||||||
|
|
||||||
|
expect(uploadedEmail).toBe('toto@example.com');
|
||||||
|
|
||||||
|
const content = JSON.parse(uploadedMasterKey.content);
|
||||||
|
expect(content.ppkId).toBe(recipientPpk.id);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
import { Store } from 'redux';
|
import { Store } from 'redux';
|
||||||
import JoplinServerApi from '../../JoplinServerApi';
|
import JoplinServerApi from '../../JoplinServerApi';
|
||||||
|
import { _ } from '../../locale';
|
||||||
import Logger from '../../Logger';
|
import Logger from '../../Logger';
|
||||||
import Folder from '../../models/Folder';
|
import Folder from '../../models/Folder';
|
||||||
|
import MasterKey from '../../models/MasterKey';
|
||||||
import Note from '../../models/Note';
|
import Note from '../../models/Note';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import { State, stateRootKey, StateShare } from './reducer';
|
import { FolderEntity } from '../database/types';
|
||||||
|
import EncryptionService from '../e2ee/EncryptionService';
|
||||||
|
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk';
|
||||||
|
import { MasterKeyEntity } from '../e2ee/types';
|
||||||
|
import { getMasterPassword } from '../e2ee/utils';
|
||||||
|
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||||
|
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
|
||||||
|
|
||||||
const logger = Logger.create('ShareService');
|
const logger = Logger.create('ShareService');
|
||||||
|
|
||||||
|
export interface ApiShare {
|
||||||
|
id: string;
|
||||||
|
master_key_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShareInvitations(invitations: any[]): ShareInvitation[] {
|
||||||
|
return invitations.map(inv => {
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
master_key: inv.master_key ? JSON.parse(inv.master_key) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default class ShareService {
|
export default class ShareService {
|
||||||
|
|
||||||
private static instance_: ShareService;
|
private static instance_: ShareService;
|
||||||
private api_: JoplinServerApi = null;
|
private api_: JoplinServerApi = null;
|
||||||
private store_: Store<any> = null;
|
private store_: Store<any> = null;
|
||||||
|
private encryptionService_: EncryptionService = null;
|
||||||
|
|
||||||
public static instance(): ShareService {
|
public static instance(): ShareService {
|
||||||
if (this.instance_) return this.instance_;
|
if (this.instance_) return this.instance_;
|
||||||
@@ -20,8 +43,9 @@ export default class ShareService {
|
|||||||
return this.instance_;
|
return this.instance_;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(store: Store<any>, api: JoplinServerApi = null) {
|
public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
|
||||||
this.store_ = store;
|
this.store_ = store;
|
||||||
|
this.encryptionService_ = encryptionService;
|
||||||
this.api_ = api;
|
this.api_ = api;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,15 +80,40 @@ export default class ShareService {
|
|||||||
return this.api_;
|
return this.api_;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shareFolder(folderId: string) {
|
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||||
const folder = await Folder.load(folderId);
|
const folder = await Folder.load(folderId);
|
||||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||||
|
|
||||||
if (folder.parent_id) {
|
let folderMasterKey: MasterKeyEntity = null;
|
||||||
await Folder.save({ id: folder.id, parent_id: '' });
|
|
||||||
|
if (getEncryptionEnabled()) {
|
||||||
|
const syncInfo = localSyncInfo();
|
||||||
|
|
||||||
|
// Shouldn't happen
|
||||||
|
if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');
|
||||||
|
|
||||||
|
folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
|
||||||
|
folderMasterKey = await MasterKey.save(folderMasterKey);
|
||||||
|
|
||||||
|
addMasterKey(syncInfo, folderMasterKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
const newFolderProps: FolderEntity = {};
|
||||||
|
|
||||||
|
if (folder.parent_id) newFolderProps.parent_id = '';
|
||||||
|
if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;
|
||||||
|
|
||||||
|
if (Object.keys(newFolderProps).length) {
|
||||||
|
await Folder.save({
|
||||||
|
id: folder.id,
|
||||||
|
...newFolderProps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||||
|
folder_id: folderId,
|
||||||
|
master_key_id: folderMasterKey ? folderMasterKey.id : '',
|
||||||
|
});
|
||||||
|
|
||||||
// Note: race condition if the share is created but the app crashes
|
// Note: race condition if the share is created but the app crashes
|
||||||
// before setting share_id on the folder. See unshareFolder() for info.
|
// before setting share_id on the folder. See unshareFolder() for info.
|
||||||
@@ -174,9 +223,34 @@ export default class ShareService {
|
|||||||
return this.state.shareInvitations;
|
return this.state.shareInvitations;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair> {
|
||||||
|
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string) {
|
||||||
|
let recipientMasterKey: MasterKeyEntity = null;
|
||||||
|
|
||||||
|
if (getEncryptionEnabled()) {
|
||||||
|
const syncInfo = localSyncInfo();
|
||||||
|
const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
|
||||||
|
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
|
||||||
|
|
||||||
|
const recipientPublicKey: PublicPrivateKeyPair = await this.userPublicKey(recipientEmail);
|
||||||
|
if (!recipientPublicKey) throw new Error(_('Cannot share notebook with recipient %s because they do not have a public key. Ask them to create one from the menu "%s"', recipientEmail, 'Tools > Generate Public-Private Key pair'));
|
||||||
|
|
||||||
|
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);
|
||||||
|
|
||||||
|
recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
|
||||||
|
this.encryptionService_,
|
||||||
|
masterKey,
|
||||||
|
getMasterPassword(),
|
||||||
|
recipientPublicKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||||
email: recipientEmail,
|
email: recipientEmail,
|
||||||
|
master_key: JSON.stringify(recipientMasterKey),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,8 +274,24 @@ export default class ShareService {
|
|||||||
return this.api().exec('GET', 'api/share_users');
|
return this.api().exec('GET', 'api/share_users');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async respondInvitation(shareUserId: string, accept: boolean) {
|
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||||
|
logger.info('respondInvitation: ', shareUserId, accept);
|
||||||
|
|
||||||
if (accept) {
|
if (accept) {
|
||||||
|
if (masterKey) {
|
||||||
|
const reencryptedMasterKey = await mkReencryptFromPublicKeyToPassword(
|
||||||
|
this.encryptionService_,
|
||||||
|
masterKey,
|
||||||
|
localSyncInfo().ppk,
|
||||||
|
getMasterPassword(),
|
||||||
|
getMasterPassword()
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);
|
||||||
|
|
||||||
|
await MasterKey.save(reencryptedMasterKey);
|
||||||
|
}
|
||||||
|
|
||||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||||
} else {
|
} else {
|
||||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||||
@@ -211,15 +301,57 @@ export default class ShareService {
|
|||||||
public async refreshShareInvitations() {
|
public async refreshShareInvitations() {
|
||||||
const result = await this.loadShareInvitations();
|
const result = await this.loadShareInvitations();
|
||||||
|
|
||||||
|
const invitations = formatShareInvitations(result.items);
|
||||||
|
logger.info('Refresh share invitations:', invitations);
|
||||||
|
|
||||||
this.store.dispatch({
|
this.store.dispatch({
|
||||||
type: 'SHARE_INVITATION_SET',
|
type: 'SHARE_INVITATION_SET',
|
||||||
shareInvitations: result.items,
|
shareInvitations: invitations,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async shareById(id: string) {
|
||||||
|
const stateShare = this.state.shares.find(s => s.id === id);
|
||||||
|
if (stateShare) return stateShare;
|
||||||
|
|
||||||
|
const refreshedShares = await this.refreshShares();
|
||||||
|
const refreshedShare = refreshedShares.find(s => s.id === id);
|
||||||
|
if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
|
||||||
|
return refreshedShare;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In most cases the share objects will already be part of the state, so
|
||||||
|
// this function checks there first. If the required share objects are not
|
||||||
|
// present, it refreshes them from the API.
|
||||||
|
public async sharesByIds(ids: string[]) {
|
||||||
|
const buildOutput = async (shares: StateShare[]) => {
|
||||||
|
const output: Record<string, StateShare> = {};
|
||||||
|
for (const share of shares) {
|
||||||
|
if (ids.includes(share.id)) output[share.id] = share;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
};
|
||||||
|
|
||||||
|
let output = await buildOutput(this.state.shares);
|
||||||
|
if (Object.keys(output).length === ids.length) return output;
|
||||||
|
|
||||||
|
const refreshedShares = await this.refreshShares();
|
||||||
|
output = await buildOutput(refreshedShares);
|
||||||
|
|
||||||
|
if (Object.keys(output).length !== ids.length) {
|
||||||
|
logger.error('sharesByIds: Need:', ids);
|
||||||
|
logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
|
||||||
|
throw new Error('Could not retrieve required share objects');
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
public async refreshShares(): Promise<StateShare[]> {
|
public async refreshShares(): Promise<StateShare[]> {
|
||||||
const result = await this.loadShares();
|
const result = await this.loadShares();
|
||||||
|
|
||||||
|
logger.info('Refreshed shares:', result);
|
||||||
|
|
||||||
this.store.dispatch({
|
this.store.dispatch({
|
||||||
type: 'SHARE_SET',
|
type: 'SHARE_SET',
|
||||||
shares: result.items,
|
shares: result.items,
|
||||||
@@ -231,6 +363,8 @@ export default class ShareService {
|
|||||||
public async refreshShareUsers(shareId: string) {
|
public async refreshShareUsers(shareId: string) {
|
||||||
const result = await this.loadShareUsers(shareId);
|
const result = await this.loadShareUsers(shareId);
|
||||||
|
|
||||||
|
logger.info('Refreshed share users:', result);
|
||||||
|
|
||||||
this.store.dispatch({
|
this.store.dispatch({
|
||||||
type: 'SHARE_USER_SET',
|
type: 'SHARE_USER_SET',
|
||||||
shareId: shareId,
|
shareId: shareId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { State as RootState } from '../../reducer';
|
import { State as RootState } from '../../reducer';
|
||||||
import { Draft } from 'immer';
|
import { Draft } from 'immer';
|
||||||
import { FolderEntity } from '../database/types';
|
import { FolderEntity } from '../database/types';
|
||||||
|
import { MasterKeyEntity } from '../e2ee/types';
|
||||||
|
|
||||||
interface StateShareUserUser {
|
interface StateShareUserUser {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,11 +26,13 @@ export interface StateShare {
|
|||||||
type: number;
|
type: number;
|
||||||
folder_id: string;
|
folder_id: string;
|
||||||
note_id: string;
|
note_id: string;
|
||||||
|
master_key_id: string;
|
||||||
user?: StateShareUserUser;
|
user?: StateShareUserUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShareInvitation {
|
export interface ShareInvitation {
|
||||||
id: string;
|
id: string;
|
||||||
|
master_key: MasterKeyEntity;
|
||||||
share: StateShare;
|
share: StateShare;
|
||||||
status: ShareUserStatus;
|
status: ShareUserStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
56
packages/lib/services/synchronizer/Synchronizer.ppk.test.ts
Normal file
56
packages/lib/services/synchronizer/Synchronizer.ppk.test.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { synchronizerStart, setupDatabaseAndSynchronizer, fileApi, switchClient, loadEncryptionMasterKey } from '../../testing/test-utils';
|
||||||
|
import Folder from '../../models/Folder';
|
||||||
|
import { fetchSyncInfo, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||||
|
import { EncryptionMethod } from '../e2ee/EncryptionService';
|
||||||
|
|
||||||
|
describe('Synchronizer.ppk', function() {
|
||||||
|
|
||||||
|
beforeEach(async (done) => {
|
||||||
|
await setupDatabaseAndSynchronizer(1);
|
||||||
|
await setupDatabaseAndSynchronizer(2);
|
||||||
|
await switchClient(1);
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not create a public private key pair if not using E2EE', async () => {
|
||||||
|
await Folder.save({});
|
||||||
|
expect(localSyncInfo().ppk).toBeFalsy();
|
||||||
|
await synchronizerStart();
|
||||||
|
const remoteInfo = await fetchSyncInfo(fileApi());
|
||||||
|
expect(localSyncInfo().ppk).toBeFalsy();
|
||||||
|
expect(remoteInfo.ppk).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a public private key pair if it does not exist', async () => {
|
||||||
|
setEncryptionEnabled(true);
|
||||||
|
await loadEncryptionMasterKey();
|
||||||
|
|
||||||
|
const beforeTime = Date.now();
|
||||||
|
|
||||||
|
await Folder.save({});
|
||||||
|
expect(localSyncInfo().ppk).toBeFalsy();
|
||||||
|
await synchronizerStart();
|
||||||
|
const remoteInfo = await fetchSyncInfo(fileApi());
|
||||||
|
expect(localSyncInfo().ppk).toBeTruthy();
|
||||||
|
expect(remoteInfo.ppk).toBeTruthy();
|
||||||
|
const clientLocalPPK1 = localSyncInfo().ppk;
|
||||||
|
expect(clientLocalPPK1.createdTime).toBeGreaterThanOrEqual(beforeTime);
|
||||||
|
expect(clientLocalPPK1.privateKey.encryptionMethod).toBe(EncryptionMethod.SJCL4);
|
||||||
|
|
||||||
|
// Rather arbitrary length check - it's just to make sure there's
|
||||||
|
// something there. Other tests should ensure the content is valid or
|
||||||
|
// not.
|
||||||
|
expect(clientLocalPPK1.privateKey.ciphertext.length).toBeGreaterThan(320);
|
||||||
|
expect(clientLocalPPK1.publicKey.length).toBeGreaterThan(320);
|
||||||
|
|
||||||
|
await switchClient(2);
|
||||||
|
|
||||||
|
expect(localSyncInfo().ppk).toBeFalsy();
|
||||||
|
await synchronizerStart();
|
||||||
|
expect(localSyncInfo().ppk).toBeTruthy();
|
||||||
|
const clientLocalPPK2 = localSyncInfo().ppk;
|
||||||
|
expect(clientLocalPPK1.privateKey.ciphertext).toBe(clientLocalPPK2.privateKey.ciphertext);
|
||||||
|
expect(clientLocalPPK1.publicKey).toBe(clientLocalPPK2.publicKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { FileApi } from '../../file-api';
|
|||||||
import JoplinDatabase from '../../JoplinDatabase';
|
import JoplinDatabase from '../../JoplinDatabase';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import { State } from '../../reducer';
|
import { State } from '../../reducer';
|
||||||
|
import { PublicPrivateKeyPair } from '../e2ee/ppk';
|
||||||
import { MasterKeyEntity } from '../e2ee/types';
|
import { MasterKeyEntity } from '../e2ee/types';
|
||||||
|
|
||||||
export interface SyncInfoValueBoolean {
|
export interface SyncInfoValueBoolean {
|
||||||
@@ -14,6 +15,11 @@ export interface SyncInfoValueString {
|
|||||||
updatedTime: number;
|
updatedTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SyncInfoValuePublicPrivateKeyPair {
|
||||||
|
value: PublicPrivateKeyPair;
|
||||||
|
updatedTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrateLocalSyncInfo(db: JoplinDatabase) {
|
export async function migrateLocalSyncInfo(db: JoplinDatabase) {
|
||||||
if (Setting.value('syncInfoCache')) return; // Already initialized
|
if (Setting.value('syncInfoCache')) return; // Already initialized
|
||||||
|
|
||||||
@@ -88,6 +94,7 @@ export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
|
|||||||
|
|
||||||
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
|
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
|
||||||
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
|
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
|
||||||
|
output.setWithTimestamp(s1.keyTimestamp('ppk') > s2.keyTimestamp('ppk') ? s1 : s2, 'ppk');
|
||||||
output.version = s1.version > s2.version ? s1.version : s2.version;
|
output.version = s1.version > s2.version ? s1.version : s2.version;
|
||||||
|
|
||||||
output.masterKeys = s1.masterKeys.slice();
|
output.masterKeys = s1.masterKeys.slice();
|
||||||
@@ -115,10 +122,12 @@ export class SyncInfo {
|
|||||||
private e2ee_: SyncInfoValueBoolean;
|
private e2ee_: SyncInfoValueBoolean;
|
||||||
private activeMasterKeyId_: SyncInfoValueString;
|
private activeMasterKeyId_: SyncInfoValueString;
|
||||||
private masterKeys_: MasterKeyEntity[] = [];
|
private masterKeys_: MasterKeyEntity[] = [];
|
||||||
|
private ppk_: SyncInfoValuePublicPrivateKeyPair;
|
||||||
|
|
||||||
public constructor(serialized: string = null) {
|
public constructor(serialized: string = null) {
|
||||||
this.e2ee_ = { value: false, updatedTime: 0 };
|
this.e2ee_ = { value: false, updatedTime: 0 };
|
||||||
this.activeMasterKeyId_ = { value: '', updatedTime: 0 };
|
this.activeMasterKeyId_ = { value: '', updatedTime: 0 };
|
||||||
|
this.ppk_ = { value: null, updatedTime: 0 };
|
||||||
|
|
||||||
if (serialized) this.load(serialized);
|
if (serialized) this.load(serialized);
|
||||||
}
|
}
|
||||||
@@ -129,6 +138,7 @@ export class SyncInfo {
|
|||||||
e2ee: this.e2ee_,
|
e2ee: this.e2ee_,
|
||||||
activeMasterKeyId: this.activeMasterKeyId_,
|
activeMasterKeyId: this.activeMasterKeyId_,
|
||||||
masterKeys: this.masterKeys,
|
masterKeys: this.masterKeys,
|
||||||
|
ppk: this.ppk_,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +152,7 @@ export class SyncInfo {
|
|||||||
this.e2ee_ = 'e2ee' in s ? s.e2ee : { value: false, updatedTime: 0 };
|
this.e2ee_ = 'e2ee' in s ? s.e2ee : { value: false, updatedTime: 0 };
|
||||||
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
||||||
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
||||||
|
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
|
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
|
||||||
@@ -161,6 +172,16 @@ export class SyncInfo {
|
|||||||
this.version_ = v;
|
this.version_ = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get ppk() {
|
||||||
|
return this.ppk_.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set ppk(v: PublicPrivateKeyPair) {
|
||||||
|
if (v === this.ppk_.value) return;
|
||||||
|
|
||||||
|
this.ppk_ = { value: v, updatedTime: Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
public get e2ee(): boolean {
|
public get e2ee(): boolean {
|
||||||
return this.e2ee_.value;
|
return this.e2ee_.value;
|
||||||
}
|
}
|
||||||
@@ -257,3 +278,11 @@ export function masterKeyEnabled(mk: MasterKeyEntity): boolean {
|
|||||||
if ('enabled' in mk) return !!mk.enabled;
|
if ('enabled' in mk) return !!mk.enabled;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addMasterKey(syncInfo: SyncInfo, masterKey: MasterKeyEntity) {
|
||||||
|
// Sanity check - because shouldn't happen
|
||||||
|
if (syncInfo.masterKeys.find(mk => mk.id === masterKey.id)) throw new Error('Master key is already present');
|
||||||
|
|
||||||
|
syncInfo.masterKeys.push(masterKey);
|
||||||
|
saveLocalSyncInfo(syncInfo);
|
||||||
|
}
|
||||||
|
|||||||
@@ -505,11 +505,12 @@ function resourceFetcher(id: number = null) {
|
|||||||
|
|
||||||
async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
||||||
const service = encryptionService(id);
|
const service = encryptionService(id);
|
||||||
|
const password = '123456';
|
||||||
|
|
||||||
let masterKey = null;
|
let masterKey = null;
|
||||||
|
|
||||||
if (!useExisting) { // Create it
|
if (!useExisting) { // Create it
|
||||||
masterKey = await service.generateMasterKey('123456');
|
masterKey = await service.generateMasterKey(password);
|
||||||
masterKey = await MasterKey.save(masterKey);
|
masterKey = await MasterKey.save(masterKey);
|
||||||
} else { // Use the one already available
|
} else { // Use the one already available
|
||||||
const masterKeys = await MasterKey.all();
|
const masterKeys = await MasterKey.all();
|
||||||
@@ -517,7 +518,12 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
|||||||
masterKey = masterKeys[0];
|
masterKey = masterKeys[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.loadMasterKey(masterKey, '123456', true);
|
const passwordCache = Setting.value('encryption.passwordCache');
|
||||||
|
passwordCache[masterKey.id] = password;
|
||||||
|
Setting.setValue('encryption.passwordCache', passwordCache);
|
||||||
|
await Setting.saveAll();
|
||||||
|
|
||||||
|
await service.loadMasterKey(masterKey, password, true);
|
||||||
|
|
||||||
setActiveMasterKeyId(masterKey.id);
|
setActiveMasterKeyId(masterKey.id);
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ function addExtraStyles(style: any) {
|
|||||||
backgroundColor: style.backgroundColor4,
|
backgroundColor: style.backgroundColor4,
|
||||||
borderColor: style.borderColor4,
|
borderColor: style.borderColor4,
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'pointer',
|
// cursor: 'pointer',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const theme: Theme = {
|
|||||||
oddBackgroundColor: '#141517',
|
oddBackgroundColor: '#141517',
|
||||||
color: '#dddddd',
|
color: '#dddddd',
|
||||||
colorError: 'red',
|
colorError: 'red',
|
||||||
|
colorCorrect: '#72b972',
|
||||||
colorWarn: '#9A5B00',
|
colorWarn: '#9A5B00',
|
||||||
colorWarnUrl: '#ffff82',
|
colorWarnUrl: '#ffff82',
|
||||||
colorFaded: '#999999', // For less important text
|
colorFaded: '#999999', // For less important text
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const theme: Theme = {
|
|||||||
oddBackgroundColor: '#eeeeee',
|
oddBackgroundColor: '#eeeeee',
|
||||||
color: '#32373F', // For regular text
|
color: '#32373F', // For regular text
|
||||||
colorError: 'red',
|
colorError: 'red',
|
||||||
|
colorCorrect: 'green', // Opposite of colorError
|
||||||
colorWarn: 'rgb(228,86,0)',
|
colorWarn: 'rgb(228,86,0)',
|
||||||
colorWarnUrl: '#155BDA',
|
colorWarnUrl: '#155BDA',
|
||||||
colorFaded: '#7C8B9E', // For less important text
|
colorFaded: '#7C8B9E', // For less important text
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface Theme {
|
|||||||
oddBackgroundColor: string;
|
oddBackgroundColor: string;
|
||||||
color: string; // For regular text
|
color: string; // For regular text
|
||||||
colorError: string;
|
colorError: string;
|
||||||
|
colorCorrect: string;
|
||||||
colorWarn: string;
|
colorWarn: string;
|
||||||
colorWarnUrl: string; // For URL displayed over a warningBackgroundColor
|
colorWarnUrl: string; // For URL displayed over a warningBackgroundColor
|
||||||
colorFaded: string; // For less important text
|
colorFaded: string; // For less important text
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@joplin/server",
|
"name": "@joplin/server",
|
||||||
"version": "2.4.3",
|
"version": "2.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||||
|
|||||||
Binary file not shown.
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal file
22
packages/server/src/migrations/20210824174024_share_users.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { Knex } from 'knex';
|
||||||
|
import { DbConnection } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
|
||||||
|
table.text('master_key', 'mediumtext').defaultTo('').notNullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
|
||||||
|
table.string('master_key_id', 32).defaultTo('').notNullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: DbConnection): Promise<any> {
|
||||||
|
await db.schema.alterTable('share_users', (table: Knex.CreateTableBuilder) => {
|
||||||
|
table.dropColumn('master_key');
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.schema.alterTable('shares', (table: Knex.CreateTableBuilder) => {
|
||||||
|
table.dropColumn('master_key_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -67,6 +67,20 @@ describe('ShareModel', function() {
|
|||||||
|
|
||||||
expect(shares3.length).toBe(1);
|
expect(shares3.length).toBe(1);
|
||||||
expect(shares3.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy();
|
expect(shares3.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy();
|
||||||
|
|
||||||
|
const participatedShares1 = await models().share().participatedSharesByUser(user1.id, ShareType.Folder);
|
||||||
|
const participatedShares2 = await models().share().participatedSharesByUser(user2.id, ShareType.Folder);
|
||||||
|
const participatedShares3 = await models().share().participatedSharesByUser(user3.id, ShareType.Folder);
|
||||||
|
|
||||||
|
expect(participatedShares1.length).toBe(1);
|
||||||
|
expect(participatedShares1[0].owner_id).toBe(user2.id);
|
||||||
|
expect(participatedShares1[0].folder_id).toBe('000000000000000000000000000000F2');
|
||||||
|
|
||||||
|
expect(participatedShares2.length).toBe(0);
|
||||||
|
|
||||||
|
expect(participatedShares3.length).toBe(1);
|
||||||
|
expect(participatedShares3[0].owner_id).toBe(user1.id);
|
||||||
|
expect(participatedShares3[0].folder_id).toBe('000000000000000000000000000000F1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should generate only one link per shared note', async function() {
|
test('should generate only one link per shared note', async function() {
|
||||||
@@ -78,8 +92,8 @@ describe('ShareModel', function() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||||
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||||
|
|
||||||
expect(share1.id).toBe(share2.id);
|
expect(share1.id).toBe(share2.id);
|
||||||
});
|
});
|
||||||
@@ -93,7 +107,7 @@ describe('ShareModel', function() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await models().share().shareNote(user1, '00000000000000000000000000000001');
|
await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||||
const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
|
const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
|
||||||
await models().item().delete(noteItem.id);
|
await models().item().delete(noteItem.id);
|
||||||
expect(await models().item().load(noteItem.id)).toBeFalsy();
|
expect(await models().item().load(noteItem.id)).toBeFalsy();
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default class ShareModel extends BaseModel<Share> {
|
|||||||
if (object.folder_id) output.folder_id = object.folder_id;
|
if (object.folder_id) output.folder_id = object.folder_id;
|
||||||
if (object.owner_id) output.owner_id = object.owner_id;
|
if (object.owner_id) output.owner_id = object.owner_id;
|
||||||
if (object.note_id) output.note_id = object.note_id;
|
if (object.note_id) output.note_id = object.note_id;
|
||||||
|
if (object.master_key_id) output.master_key_id = object.master_key_id;
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@@ -148,6 +149,20 @@ export default class ShareModel extends BaseModel<Share> {
|
|||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
|
||||||
|
const query = this.db(this.tableName)
|
||||||
|
.select(this.defaultFields)
|
||||||
|
.whereIn('id', this.db('share_users')
|
||||||
|
.select('share_id')
|
||||||
|
.where('user_id', '=', userId)
|
||||||
|
.andWhere('status', '=', ShareUserStatus.Accepted
|
||||||
|
));
|
||||||
|
|
||||||
|
if (type) void query.andWhere('type', '=', type);
|
||||||
|
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
// Returns all user IDs concerned by the share. That includes all the users
|
// Returns all user IDs concerned by the share. That includes all the users
|
||||||
// the folder has been shared with, as well as the folder owner.
|
// the folder has been shared with, as well as the folder owner.
|
||||||
public async allShareUserIds(share: Share): Promise<Uuid[]> {
|
public async allShareUserIds(share: Share): Promise<Uuid[]> {
|
||||||
@@ -318,36 +333,38 @@ export default class ShareModel extends BaseModel<Share> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shareFolder(owner: User, folderId: string): Promise<Share> {
|
public async shareFolder(owner: User, folderId: string, masterKeyId: string): Promise<Share> {
|
||||||
const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
|
const folderItem = await this.models().item().loadByJopId(owner.id, folderId);
|
||||||
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
|
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
|
||||||
|
|
||||||
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
|
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
|
||||||
if (share) return share;
|
if (share) return share;
|
||||||
|
|
||||||
const shareToSave = {
|
const shareToSave: Share = {
|
||||||
type: ShareType.Folder,
|
type: ShareType.Folder,
|
||||||
item_id: folderItem.id,
|
item_id: folderItem.id,
|
||||||
owner_id: owner.id,
|
owner_id: owner.id,
|
||||||
folder_id: folderId,
|
folder_id: folderId,
|
||||||
|
master_key_id: masterKeyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||||
return super.save(shareToSave);
|
return super.save(shareToSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shareNote(owner: User, noteId: string): Promise<Share> {
|
public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise<Share> {
|
||||||
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
|
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
|
||||||
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
|
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
|
||||||
|
|
||||||
const existingShare = await this.byItemId(noteItem.id);
|
const existingShare = await this.byItemId(noteItem.id);
|
||||||
if (existingShare) return existingShare;
|
if (existingShare) return existingShare;
|
||||||
|
|
||||||
const shareToSave = {
|
const shareToSave: Share = {
|
||||||
type: ShareType.Note,
|
type: ShareType.Note,
|
||||||
item_id: noteItem.id,
|
item_id: noteItem.id,
|
||||||
owner_id: owner.id,
|
owner_id: owner.id,
|
||||||
note_id: noteId,
|
note_id: noteId,
|
||||||
|
master_key_id: masterKeyId,
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||||
|
|||||||
@@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
|||||||
return this.db(this.tableName).where(link).first();
|
return this.db(this.tableName).where(link).first();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
|
public async shareWithUserAndAccept(share: Share, shareeId: Uuid, masterKey: string = '') {
|
||||||
await this.models().shareUser().addById(share.id, shareeId);
|
await this.models().shareUser().addById(share.id, shareeId, masterKey);
|
||||||
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
|
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
|
public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise<ShareUser> {
|
||||||
const user = await this.models().user().load(userId);
|
const user = await this.models().user().load(userId);
|
||||||
return this.addByEmail(shareId, user.email);
|
return this.addByEmail(shareId, user.email, masterKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||||
@@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
|||||||
.first();
|
.first();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
public async addByEmail(shareId: Uuid, userEmail: string, masterKey: string): Promise<ShareUser> {
|
||||||
const share = await this.models().share().load(shareId);
|
const share = await this.models().share().load(shareId);
|
||||||
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
|
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
|
||||||
|
|
||||||
@@ -110,6 +110,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
|||||||
return this.save({
|
return this.save({
|
||||||
share_id: shareId,
|
share_id: shareId,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
master_key: masterKey,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
|
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, expectThrow } from '../utils/testing/testUtils';
|
||||||
import { EmailSender, User, UserFlagType } from '../services/database/types';
|
import { EmailSender, User, UserFlagType } from '../services/database/types';
|
||||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||||
@@ -267,4 +267,56 @@ describe('UserModel', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should get the user public key', async function() {
|
||||||
|
const { user: user1 } = await createUserAndSession(1);
|
||||||
|
const { user: user2 } = await createUserAndSession(2);
|
||||||
|
const { user: user3 } = await createUserAndSession(3);
|
||||||
|
const { user: user4 } = await createUserAndSession(4);
|
||||||
|
|
||||||
|
const syncInfo1: any = {
|
||||||
|
'version': 3,
|
||||||
|
'e2ee': {
|
||||||
|
'value': false,
|
||||||
|
'updatedTime': 0,
|
||||||
|
},
|
||||||
|
'ppk': {
|
||||||
|
'value': {
|
||||||
|
publicKey: 'PUBLIC_KEY_1',
|
||||||
|
privateKey: {
|
||||||
|
encryptionMode: 4,
|
||||||
|
ciphertext: 'PRIVATE_KEY',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'updatedTime': 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncInfo2: any = JSON.parse(JSON.stringify(syncInfo1));
|
||||||
|
syncInfo2.ppk.value.publicKey = 'PUBLIC_KEY_2';
|
||||||
|
|
||||||
|
const syncInfo3: any = JSON.parse(JSON.stringify(syncInfo1));
|
||||||
|
delete syncInfo3.ppk;
|
||||||
|
|
||||||
|
await models().item().saveForUser(user1.id, {
|
||||||
|
content: Buffer.from(JSON.stringify(syncInfo1)),
|
||||||
|
name: 'info.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
await models().item().saveForUser(user2.id, {
|
||||||
|
content: Buffer.from(JSON.stringify(syncInfo2)),
|
||||||
|
name: 'info.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
await models().item().saveForUser(user3.id, {
|
||||||
|
content: Buffer.from(JSON.stringify(syncInfo3)),
|
||||||
|
name: 'info.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await models().user().publicKey(user1.id)).toBe('PUBLIC_KEY_1');
|
||||||
|
expect(await models().user().publicKey(user2.id)).toBe('PUBLIC_KEY_2');
|
||||||
|
expect(await models().user().publicKey(user3.id)).toBe('');
|
||||||
|
|
||||||
|
await expectThrow(async () => models().user().publicKey(user4.id));
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import resetPasswordTemplate from '../views/emails/resetPasswordTemplate';
|
|||||||
import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
|
import { betaStartSubUrl, betaUserDateRange, betaUserTrialPeriodDays, isBetaUser, stripeConfig } from '../utils/stripe';
|
||||||
import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
|
import endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
|
||||||
import Logger from '@joplin/lib/Logger';
|
import Logger from '@joplin/lib/Logger';
|
||||||
|
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
||||||
import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate';
|
import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate';
|
||||||
import oversizedAccount1 from '../views/emails/oversizedAccount1';
|
import oversizedAccount1 from '../views/emails/oversizedAccount1';
|
||||||
import oversizedAccount2 from '../views/emails/oversizedAccount2';
|
import oversizedAccount2 from '../views/emails/oversizedAccount2';
|
||||||
@@ -439,6 +440,18 @@ export default class UserModel extends BaseModel<User> {
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async syncInfo(userId: Uuid): Promise<any> {
|
||||||
|
const item = await this.models().item().loadByName(userId, 'info.json');
|
||||||
|
if (!item) throw new Error('Cannot find info.json file');
|
||||||
|
const withContent = await this.models().item().loadWithContent(item.id);
|
||||||
|
return JSON.parse(withContent.content.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async publicPrivateKey(userId: string): Promise<PublicPrivateKeyPair> {
|
||||||
|
const syncInfo = await this.syncInfo(userId);
|
||||||
|
return syncInfo.ppk?.value || null;// syncInfo.ppk?.value.publicKey || '';
|
||||||
|
}
|
||||||
|
|
||||||
// Note that when the "password" property is provided, it is going to be
|
// Note that when the "password" property is provided, it is going to be
|
||||||
// hashed automatically. It means that it is not safe to do:
|
// hashed automatically. It means that it is not safe to do:
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ router.get('api/share_users', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
items.push({
|
items.push({
|
||||||
id: su.id,
|
id: su.id,
|
||||||
status: su.status,
|
status: su.status,
|
||||||
|
master_key: su.master_key,
|
||||||
share: {
|
share: {
|
||||||
id: share.id,
|
id: share.id,
|
||||||
folder_id: share.folder_id,
|
folder_id: share.folder_id,
|
||||||
|
|||||||
@@ -19,11 +19,18 @@ router.public = true;
|
|||||||
router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||||
ownerRequired(ctx);
|
ownerRequired(ctx);
|
||||||
|
|
||||||
|
interface Fields {
|
||||||
|
folder_id?: string;
|
||||||
|
note_id?: string;
|
||||||
|
master_key_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const shareModel = ctx.joplin.models.share();
|
const shareModel = ctx.joplin.models.share();
|
||||||
const fields = await bodyFields<any>(ctx.req);
|
const fields = await bodyFields<Fields>(ctx.req);
|
||||||
const shareInput: ShareApiInput = shareModel.fromApiInput(fields) as ShareApiInput;
|
const shareInput: ShareApiInput = shareModel.fromApiInput(fields) as ShareApiInput;
|
||||||
if (fields.folder_id) shareInput.folder_id = fields.folder_id;
|
if (fields.folder_id) shareInput.folder_id = fields.folder_id;
|
||||||
if (fields.note_id) shareInput.note_id = fields.note_id;
|
if (fields.note_id) shareInput.note_id = fields.note_id;
|
||||||
|
const masterKeyId = fields.master_key_id || '';
|
||||||
|
|
||||||
// - The API end point should only expose two ways of sharing:
|
// - The API end point should only expose two ways of sharing:
|
||||||
// - By folder_id (JoplinRootFolder)
|
// - By folder_id (JoplinRootFolder)
|
||||||
@@ -31,9 +38,9 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
|||||||
// - Additionally, the App method is available, but not exposed via the API.
|
// - Additionally, the App method is available, but not exposed via the API.
|
||||||
|
|
||||||
if (shareInput.folder_id) {
|
if (shareInput.folder_id) {
|
||||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id);
|
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id, masterKeyId);
|
||||||
} else if (shareInput.note_id) {
|
} else if (shareInput.note_id) {
|
||||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id);
|
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId);
|
||||||
} else {
|
} else {
|
||||||
throw new ErrorBadRequest('Either folder_id or note_id must be provided');
|
throw new ErrorBadRequest('Either folder_id or note_id must be provided');
|
||||||
}
|
}
|
||||||
@@ -44,20 +51,23 @@ router.post('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
|||||||
|
|
||||||
interface UserInput {
|
interface UserInput {
|
||||||
email: string;
|
email: string;
|
||||||
|
master_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = await bodyFields(ctx.req) as UserInput;
|
const fields = await bodyFields(ctx.req) as UserInput;
|
||||||
const user = await ctx.joplin.models.user().loadByEmail(fields.email);
|
const user = await ctx.joplin.models.user().loadByEmail(fields.email);
|
||||||
if (!user) throw new ErrorNotFound('User not found');
|
if (!user) throw new ErrorNotFound('User not found');
|
||||||
|
|
||||||
|
const masterKey = fields.master_key || '';
|
||||||
const shareId = path.id;
|
const shareId = path.id;
|
||||||
|
|
||||||
await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, {
|
await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, {
|
||||||
share_id: shareId,
|
share_id: shareId,
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
|
master_key: masterKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email);
|
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email, masterKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
router.get('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||||
@@ -102,13 +112,17 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
|||||||
throw new ErrorNotFound();
|
throw new ErrorNotFound();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// This end points returns both the shares owned by the user, and those they
|
||||||
|
// participate in.
|
||||||
router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||||
ownerRequired(ctx);
|
ownerRequired(ctx);
|
||||||
|
|
||||||
const shares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
const ownedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||||
|
const participatedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().participatedSharesByUser(ctx.joplin.owner.id));
|
||||||
|
|
||||||
// Fake paginated results so that it can be added later on, if needed.
|
// Fake paginated results so that it can be added later on, if needed.
|
||||||
return {
|
return {
|
||||||
items: shares.map(share => {
|
items: ownedShares.concat(participatedShares).map(share => {
|
||||||
return {
|
return {
|
||||||
...share,
|
...share,
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@@ -26,6 +26,21 @@ router.get('api/users/:id', async (path: SubPath, ctx: AppContext) => {
|
|||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.publicSchemas.push('api/users/:id/public_key');
|
||||||
|
|
||||||
|
// "id" in this case is actually the email address
|
||||||
|
router.get('api/users/:id/public_key', async (path: SubPath, ctx: AppContext) => {
|
||||||
|
const user = await ctx.joplin.models.user().loadByEmail(path.id);
|
||||||
|
if (!user) return ''; // Don't throw an error to prevent polling the end point
|
||||||
|
|
||||||
|
const ppk = await ctx.joplin.models.user().publicPrivateKey(user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ppk.id,
|
||||||
|
publicKey: ppk.publicKey,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
|
router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
|
||||||
await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create);
|
await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create);
|
||||||
const user = await postedUserFromContext(ctx);
|
const user = await postedUserFromContext(ctx);
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ export interface ShareUser extends WithDates, WithUuid {
|
|||||||
share_id?: Uuid;
|
share_id?: Uuid;
|
||||||
user_id?: Uuid;
|
user_id?: Uuid;
|
||||||
status?: ShareUserStatus;
|
status?: ShareUserStatus;
|
||||||
|
master_key?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Item extends WithDates, WithUuid {
|
export interface Item extends WithDates, WithUuid {
|
||||||
@@ -177,6 +178,7 @@ export interface Share extends WithDates, WithUuid {
|
|||||||
type?: ShareType;
|
type?: ShareType;
|
||||||
folder_id?: Uuid;
|
folder_id?: Uuid;
|
||||||
note_id?: Uuid;
|
note_id?: Uuid;
|
||||||
|
master_key_id?: Uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Change extends WithDates, WithUuid {
|
export interface Change extends WithDates, WithUuid {
|
||||||
@@ -293,6 +295,7 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
status: { type: 'number' },
|
status: { type: 'number' },
|
||||||
updated_time: { type: 'string' },
|
updated_time: { type: 'string' },
|
||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
|
master_key: { type: 'string' },
|
||||||
},
|
},
|
||||||
items: {
|
items: {
|
||||||
id: { type: 'string' },
|
id: { type: 'string' },
|
||||||
@@ -336,6 +339,7 @@ export const databaseSchema: DatabaseTables = {
|
|||||||
created_time: { type: 'string' },
|
created_time: { type: 'string' },
|
||||||
folder_id: { type: 'string' },
|
folder_id: { type: 'string' },
|
||||||
note_id: { type: 'string' },
|
note_id: { type: 'string' },
|
||||||
|
master_key_id: { type: 'string' },
|
||||||
},
|
},
|
||||||
changes: {
|
changes: {
|
||||||
counter: { type: 'number' },
|
counter: { type: 'number' },
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
|||||||
import { ErrorTooManyRequests } from '../errors';
|
import { ErrorTooManyRequests } from '../errors';
|
||||||
|
|
||||||
const limiterSlowBruteByIP = new RateLimiterMemory({
|
const limiterSlowBruteByIP = new RateLimiterMemory({
|
||||||
points: 3, // Up to 3 requests per IP
|
points: 10, // Up to 10 requests per IP
|
||||||
duration: 30, // Per 30 seconds
|
duration: 60, // Per 60 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
export default async function(ip: string) {
|
export default async function(ip: string) {
|
||||||
|
|||||||
@@ -135,12 +135,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
|
|||||||
if (colonIndex2 < 0) {
|
if (colonIndex2 < 0) {
|
||||||
throw new ErrorBadRequest(`Invalid path format: ${p}`);
|
throw new ErrorBadRequest(`Invalid path format: ${p}`);
|
||||||
} else {
|
} else {
|
||||||
output.id = p.substr(0, colonIndex2 + 1);
|
output.id = decodeURIComponent(p.substr(0, colonIndex2 + 1));
|
||||||
output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
|
output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const s = p.split('/');
|
const s = p.split('/');
|
||||||
if (s.length >= 1) output.id = s[0];
|
if (s.length >= 1) output.id = decodeURIComponent(s[0]);
|
||||||
if (s.length >= 2) output.link = s[1];
|
if (s.length >= 2) output.link = s[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
readme/spec/server_sharing_e2ee.md
Normal file
11
readme/spec/server_sharing_e2ee.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Sharing a notebook with E2EE enabled
|
||||||
|
|
||||||
|
- When sharing the notebook, a key (NOTEBOOK_KEY) is automatically generated and encrypted with the sender master password.
|
||||||
|
- That key ID is then associated with the notebook
|
||||||
|
- When adding a recipient, the key is decrypted using the sender master password, and reencrypted using the recipient public key
|
||||||
|
- That encrypted key is then attached to the share_user object (the invitation)
|
||||||
|
- When the recipient receives the invitation, the key is retrieved from it, then decrypted using the private key, and reencrypted using the recipient master password.
|
||||||
|
|
||||||
|
Once the key exchange is done, each user has their own copy of NOTEBOOK_KEY encrypted with their own master password. Public/Private Keys are only used to transfer NOTEBOOK_KEY.
|
||||||
|
|
||||||
|
Whenever any item within the notebook is encrypted, it is done with NOTEBOOK_KEY.
|
||||||
Reference in New Issue
Block a user