You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
||||
|
@@ -190,7 +190,7 @@ module.exports = {
|
||||
selector: 'enumMember',
|
||||
format: null,
|
||||
'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,
|
||||
},
|
||||
},
|
||||
|
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
packages/lib/services/synchronizer/Synchronizer.resources.test.js.map
|
||||
|
@@ -17,7 +17,7 @@ class Command extends BaseCommand {
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -151,6 +151,19 @@ class Command extends BaseCommand {
|
||||
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') {
|
||||
const fs = require('fs-extra');
|
||||
|
||||
|
@@ -535,12 +535,12 @@ class Application extends BaseApplication {
|
||||
// }, 2000);
|
||||
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
// name: 'syncWizard',
|
||||
// });
|
||||
// }, 2000);
|
||||
setTimeout(() => {
|
||||
this.dispatch({
|
||||
type: 'DIALOG_OPEN',
|
||||
name: 'masterPassword',
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
|
@@ -27,11 +27,12 @@ const DialogRoot = styled.div`
|
||||
|
||||
interface Props {
|
||||
renderContent: Function;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Dialog(props: Props) {
|
||||
return (
|
||||
<DialogModalLayer>
|
||||
<DialogModalLayer className={props.className}>
|
||||
<DialogRoot>
|
||||
{props.renderContent()}
|
||||
</DialogRoot>
|
||||
|
@@ -17,10 +17,13 @@ export type ClickEventHandler = (event: ClickEvent)=> void;
|
||||
interface Props {
|
||||
themeId: number;
|
||||
onClick?: ClickEventHandler;
|
||||
okButtonShow?: boolean;
|
||||
cancelButtonShow?: boolean;
|
||||
cancelButtonLabel?: string;
|
||||
cancelButtonDisabled?: boolean;
|
||||
okButtonShow?: boolean;
|
||||
okButtonLabel?: string;
|
||||
okButtonRef?: any;
|
||||
okButtonDisabled?: boolean;
|
||||
customButtons?: ButtonSpec[];
|
||||
}
|
||||
|
||||
@@ -68,15 +71,15 @@ export default function DialogButtonRow(props: Props) {
|
||||
|
||||
if (props.okButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
{_('OK')}
|
||||
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.cancelButtonShow !== false) {
|
||||
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')}
|
||||
</button>
|
||||
);
|
||||
|
@@ -2,13 +2,13 @@ import styled from 'styled-components';
|
||||
|
||||
const Root = styled.div`
|
||||
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-size: ${props => props.theme.fontSize * 1.5}px;
|
||||
line-height: 1.6em;
|
||||
color: ${props => props.theme.color};
|
||||
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 { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
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 StyledInput from './style/StyledInput';
|
||||
import Button, { ButtonLevel } from './Button/Button';
|
||||
@@ -218,7 +218,7 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -230,24 +230,41 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
if (this.state.passwordChecks['master']) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<span style={theme.textStyle}>{_('Master password:')}</span>
|
||||
<span style={{ ...theme.textStyle, fontWeight: 'bold' }}>✔ {_('Loaded')}</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>
|
||||
);
|
||||
}
|
||||
// const status = this.getMasterPasswordStatus();
|
||||
|
||||
// const statusMessages = {
|
||||
// [MasterPasswordStatus.NotSet]: 'Not set',
|
||||
// [MasterPasswordStatus.Valid]: '✓ ' + 'Valid',
|
||||
// [MasterPasswordStatus.Invalid]: '❌ ' + 'Invalid',
|
||||
// };
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<span style={theme.textStyle}>{_('Master password:')}</span>
|
||||
<span style={{ ...theme.textStyle, fontWeight: 'bold' }}>{statusMessages[status]}</span>
|
||||
</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() {
|
||||
@@ -359,11 +376,13 @@ class EncryptionConfigScreenComponent extends React.Component<Props> {
|
||||
</p>
|
||||
</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}>
|
||||
{_('Encryption is:')} <strong>{this.props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
|
||||
</p>
|
||||
{this.renderMasterPassword()}
|
||||
{decryptedItemsInfo}
|
||||
{toggleButton}
|
||||
{needUpgradeSection}
|
||||
|
@@ -37,6 +37,7 @@ import { reg } from '@joplin/lib/registry';
|
||||
import removeKeylessItems from '../ResizableLayout/utils/removeKeylessItems';
|
||||
import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
|
||||
import commands from './commands/index';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
@@ -545,8 +546,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||
const onInvitationRespond = async (shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
void reg.scheduleSync(1000);
|
||||
};
|
||||
@@ -593,9 +594,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
||||
_('Accept'),
|
||||
() => onInvitationRespond(invitation.id, true),
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, true),
|
||||
_('Reject'),
|
||||
() => onInvitationRespond(invitation.id, false)
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, false)
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
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.tools.submenu.push(menuItemDic.commandPalette);
|
||||
rootMenus.tools.submenu.push(menuItemDic.openMasterPasswordDialog);
|
||||
|
||||
for (const view of props.pluginMenuItems) {
|
||||
const location: MenuItemLocation = view.location;
|
||||
|
@@ -20,6 +20,7 @@ import DialogTitle from './DialogTitle';
|
||||
import DialogButtonRow, { ButtonSpec, ClickEvent, ClickEventHandler } from './DialogButtonRow';
|
||||
import Dialog from './Dialog';
|
||||
import SyncWizardDialog from './SyncWizard/Dialog';
|
||||
import MasterPasswordDialog from './MasterPasswordDialog/Dialog';
|
||||
import StyleSheetContainer from './StyleSheets/StyleSheetContainer';
|
||||
const { ImportScreen } = require('./ImportScreen.min.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}/>;
|
||||
},
|
||||
},
|
||||
|
||||
masterPassword: {
|
||||
render: (props: RegisteredDialogProps) => {
|
||||
return <MasterPasswordDialog key={props.key} dispatch={props.dispatch} themeId={props.themeId}/>;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const GlobalStyle = createGlobalStyle`
|
||||
|
@@ -171,7 +171,7 @@ function ShareFolderDialog(props: Props) {
|
||||
try {
|
||||
setLatestError(null);
|
||||
const share = await ShareService.instance().shareFolder(props.folderId);
|
||||
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
||||
await ShareService.instance().addShareRecipient(share.id, share.master_key_id, recipientEmail);
|
||||
await Promise.all([
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
|
@@ -49,5 +49,6 @@ export default function() {
|
||||
'showShareFolderDialog',
|
||||
'gotoAnything',
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
];
|
||||
}
|
||||
|
@@ -3,6 +3,11 @@
|
||||
# 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.
|
||||
|
||||
# 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
|
||||
|
||||
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.10.path http://api.joplincloud.local:22300" >> "$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
|
||||
|
||||
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
|
||||
|
||||
echo "Unknown command: $CMD"
|
||||
|
@@ -143,4 +143,87 @@ a {
|
||||
|
||||
*:focus {
|
||||
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
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
await ShareService.instance().initialize(store);
|
||||
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||
|
||||
reg.logger().info('Loading folders...');
|
||||
|
||||
|
@@ -630,7 +630,7 @@ export default class BaseApplication {
|
||||
BaseSyncTarget.dispatch = this.store().dispatch;
|
||||
DecryptionWorker.instance().dispatch = this.store().dispatch;
|
||||
ResourceFetcher.instance().dispatch = this.store().dispatch;
|
||||
ShareService.instance().initialize(this.store());
|
||||
ShareService.instance().initialize(this.store(), EncryptionService.instance());
|
||||
}
|
||||
|
||||
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 ""');
|
||||
}
|
||||
|
||||
// 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] };
|
||||
|
||||
queries.push(updateVersionQuery);
|
||||
|
@@ -142,7 +142,7 @@ export default class JoplinServerApi {
|
||||
}
|
||||
|
||||
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 = {};
|
||||
fetchOptions.headers = headers;
|
||||
|
@@ -24,6 +24,7 @@ import { FileApi } from './file-api';
|
||||
import JoplinDatabase from './JoplinDatabase';
|
||||
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||
import { setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||
import { setPpkIfNotExist } from './services/e2ee/ppk';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const { Dirnames } = require('./services/synchronizer/utils/types');
|
||||
|
||||
@@ -420,49 +421,52 @@ export default class Synchronizer {
|
||||
this.api().setTempDirName(Dirnames.Temp);
|
||||
|
||||
try {
|
||||
const remoteInfo = await fetchSyncInfo(this.api());
|
||||
let remoteInfo = await fetchSyncInfo(this.api());
|
||||
logger.info('Sync target remote info:', remoteInfo);
|
||||
|
||||
if (!remoteInfo.version) {
|
||||
logger.info('Sync target is new - setting it up...');
|
||||
await this.migrationHandler().upgrade(Setting.value('syncVersion'));
|
||||
} else {
|
||||
logger.info('Sync target is already setup - checking it...');
|
||||
remoteInfo = await fetchSyncInfo(this.api());
|
||||
}
|
||||
|
||||
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);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
logger.info('Sync target local info:', localInfo);
|
||||
|
||||
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());
|
||||
await setPpkIfNotExist(this.encryptionService(), localInfo, remoteInfo);
|
||||
|
||||
await this.lockHandler().acquireLock(LockType.Exclusive, this.appType_, this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
|
||||
await uploadSyncInfo(this.api(), newInfo);
|
||||
await saveLocalSyncInfo(newInfo);
|
||||
await this.lockHandler().releaseLock(LockType.Exclusive, this.appType_, this.clientId_);
|
||||
// console.info('LOCAL', localInfo);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
|
||||
// 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) {
|
||||
if (newInfo.e2ee) {
|
||||
const mk = getActiveMasterKey(newInfo);
|
||||
await setupAndEnableEncryption(this.encryptionService(), mk);
|
||||
} else {
|
||||
await setupAndDisableEncryption(this.encryptionService());
|
||||
}
|
||||
await this.lockHandler().acquireLock(LockType.Exclusive, this.appType_, this.clientId_, { clearExistingSyncLocksFromTheSameClient: true });
|
||||
await uploadSyncInfo(this.api(), newInfo);
|
||||
await saveLocalSyncInfo(newInfo);
|
||||
await this.lockHandler().releaseLock(LockType.Exclusive, this.appType_, this.clientId_);
|
||||
|
||||
// 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) {
|
||||
if (error.code === 'outdatedSyncTarget') {
|
||||
|
@@ -1,11 +1,13 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as historyBackward from './historyBackward';
|
||||
import * as historyForward from './historyForward';
|
||||
import * as openMasterPasswordDialog from './openMasterPasswordDialog';
|
||||
import * as synchronize from './synchronize';
|
||||
|
||||
const index:any[] = [
|
||||
historyBackward,
|
||||
historyForward,
|
||||
openMasterPasswordDialog,
|
||||
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 {
|
||||
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);
|
||||
void reg.waitForSyncFinishedThenSync();
|
||||
alert(_('The master key has been upgraded successfully!'));
|
||||
|
@@ -40,6 +40,10 @@ export default class FileApiDriverJoplinServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
public get requiresPublicPrivateKeyPair() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public requestRepeatCount() {
|
||||
return 3;
|
||||
}
|
||||
|
@@ -106,6 +106,10 @@ class FileApi {
|
||||
return !!this.driver().supportsAccurateTimestamp;
|
||||
}
|
||||
|
||||
public get requiresPublicPrivateKeyPair(): boolean {
|
||||
return !!this.driver().requiresPublicPrivateKeyPair;
|
||||
}
|
||||
|
||||
async fetchRemoteDateOffset_() {
|
||||
const tempFile = `${this.tempDirName()}/timeCheck${Math.round(Math.random() * 1000000)}.txt`;
|
||||
const startTime = Date.now();
|
||||
|
@@ -10,7 +10,7 @@ import ItemChange from './ItemChange';
|
||||
import ShareService from '../services/share/ShareService';
|
||||
import itemCanBeEncrypted from './utils/itemCanBeEncrypted';
|
||||
import { getEncryptionEnabled } from '../services/synchronizer/syncInfoUtils';
|
||||
const JoplinError = require('../JoplinError.js');
|
||||
import JoplinError from '../JoplinError';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface ItemThatNeedSync {
|
||||
type_: ModelType;
|
||||
updated_time: number;
|
||||
encryption_applied: number;
|
||||
share_id: string;
|
||||
}
|
||||
|
||||
export interface ItemsThatNeedSyncResult {
|
||||
@@ -409,6 +410,7 @@ export default class BaseItem extends BaseModel {
|
||||
const shownKeys = ItemClass.fieldNames();
|
||||
shownKeys.push('type_');
|
||||
|
||||
const share = item.share_id ? await this.shareService().shareById(item.share_id) : null;
|
||||
const serialized = await ItemClass.serialize(item, shownKeys);
|
||||
|
||||
if (!getEncryptionEnabled() || !ItemClass.encryptionSupported() || !itemCanBeEncrypted(item)) {
|
||||
@@ -426,7 +428,9 @@ export default class BaseItem extends BaseModel {
|
||||
let cipherText = null;
|
||||
|
||||
try {
|
||||
cipherText = await this.encryptionService().encryptString(serialized);
|
||||
cipherText = await this.encryptionService().encryptString(serialized, {
|
||||
masterKeyId: share && share.master_key_id ? share.master_key_id : '',
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = [`Could not encrypt item ${item.id}`];
|
||||
if (error && error.message) msg.push(error.message);
|
||||
|
@@ -28,8 +28,8 @@ export default class MasterKey extends BaseItem {
|
||||
return output;
|
||||
}
|
||||
|
||||
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], method: number) {
|
||||
return masterKeys.filter(m => m.encryption_method !== method);
|
||||
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], methods: number[]) {
|
||||
return masterKeys.filter(m => !methods.includes(m.encryption_method));
|
||||
}
|
||||
|
||||
public static async all(): Promise<MasterKeyEntity[]> {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { BaseItemEntity } from '../../services/database/types';
|
||||
|
||||
export default function(resource: BaseItemEntity): boolean {
|
||||
return !resource.is_shared && !resource.share_id;
|
||||
export default function(_resource: BaseItemEntity): boolean {
|
||||
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-notifier": "^8.0.0",
|
||||
"node-persist": "^2.1.0",
|
||||
"node-rsa": "^1.1.1",
|
||||
"promise": "^7.1.1",
|
||||
"query-string": "4.3.4",
|
||||
"re-reselect": "^4.0.0",
|
||||
@@ -67,6 +68,7 @@
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/node-rsa": "^1.1.1",
|
||||
"clean-html": "^1.5.0",
|
||||
"jest": "^26.6.3",
|
||||
"sharp": "^0.26.2",
|
||||
@@ -1061,6 +1063,15 @@
|
||||
"integrity": "sha512-6QlRuqsQ/Ox/aJEQWBEJG7A9+u7oSYl3mem/K8IzxXG/kAGbV1YPD9Bg9Zw3vyxC/YP+zONKwy8hGkSt1jxFMw==",
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz",
|
||||
@@ -5750,6 +5761,14 @@
|
||||
"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": {
|
||||
"version": "0.1.1",
|
||||
"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==",
|
||||
"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": {
|
||||
"version": "2.4.0",
|
||||
"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": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/noop-logger/-/noop-logger-0.1.1.tgz",
|
||||
|
@@ -19,6 +19,7 @@
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/node-rsa": "^1.1.1",
|
||||
"clean-html": "^1.5.0",
|
||||
"jest": "^26.6.3",
|
||||
"sharp": "^0.26.2",
|
||||
@@ -62,6 +63,7 @@
|
||||
"node-fetch": "^1.7.1",
|
||||
"node-notifier": "^8.0.0",
|
||||
"node-persist": "^2.1.0",
|
||||
"node-rsa": "^1.1.1",
|
||||
"promise": "^7.1.1",
|
||||
"query-string": "4.3.4",
|
||||
"re-reselect": "^4.0.0",
|
||||
|
@@ -4,7 +4,7 @@ import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import EncryptionService from './EncryptionService';
|
||||
import EncryptionService, { EncryptionMethod } from './EncryptionService';
|
||||
import { setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||
|
||||
let service: EncryptionService = null;
|
||||
@@ -22,7 +22,7 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
it('should encode and decode header', (async () => {
|
||||
const header = {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
||||
encryptionMethod: EncryptionMethod.SJCL,
|
||||
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
||||
};
|
||||
|
||||
@@ -39,33 +39,33 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
let hasThrown = false;
|
||||
try {
|
||||
await service.decryptMasterKey_(masterKey, 'wrongpassword');
|
||||
await service.decryptMasterKeyContent(masterKey, 'wrongpassword');
|
||||
} catch (error) {
|
||||
hasThrown = true;
|
||||
}
|
||||
|
||||
expect(hasThrown).toBe(true);
|
||||
|
||||
const decryptedMasterKey = await service.decryptMasterKey_(masterKey, '123456');
|
||||
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||
expect(decryptedMasterKey.length).toBe(512);
|
||||
}));
|
||||
|
||||
it('should upgrade a master key', (async () => {
|
||||
// Create an old style master key
|
||||
let masterKey = await service.generateMasterKey('123456', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
encryptionMethod: EncryptionMethod.SJCL2,
|
||||
});
|
||||
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);
|
||||
|
||||
// Check that master key has been upgraded (different ciphertext)
|
||||
expect(masterKey.content).not.toBe(upgradedMasterKey.content);
|
||||
|
||||
// Check that master key plain text is still the same
|
||||
const plainTextOld = await service.decryptMasterKey_(masterKey, '123456');
|
||||
const plainTextNew = await service.decryptMasterKey_(upgradedMasterKey, '123456');
|
||||
const plainTextOld = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||
const plainTextNew = await service.decryptMasterKeyContent(upgradedMasterKey, '123456');
|
||||
expect(plainTextOld).toBe(plainTextNew);
|
||||
|
||||
// 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 () => {
|
||||
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 () => {
|
||||
const masterKey = await service.generateMasterKey('123456', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
encryptionMethod: EncryptionMethod.SJCL2,
|
||||
});
|
||||
|
||||
expect(!!masterKey.checksum).toBe(true);
|
||||
@@ -98,33 +98,33 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
it('should not require a checksum for new master keys', (async () => {
|
||||
const masterKey = await service.generateMasterKey('123456', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_4,
|
||||
encryptionMethod: EncryptionMethod.SJCL4,
|
||||
});
|
||||
|
||||
expect(!masterKey.checksum).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);
|
||||
}));
|
||||
|
||||
it('should throw an error if master key decryption fails', (async () => {
|
||||
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);
|
||||
}));
|
||||
|
||||
it('should return the master keys that need an upgrade', (async () => {
|
||||
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', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
||||
encryptionMethod: EncryptionMethod.SJCL,
|
||||
}));
|
||||
|
||||
await MasterKey.save(await service.generateMasterKey('123456'));
|
||||
@@ -164,22 +164,22 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
{
|
||||
const cipherText = await service.encryptString('some secret', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
encryptionMethod: EncryptionMethod.SJCL2,
|
||||
});
|
||||
const plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('some secret');
|
||||
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', {
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_3,
|
||||
encryptionMethod: EncryptionMethod.SJCL3,
|
||||
});
|
||||
const plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('some secret');
|
||||
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);
|
||||
|
||||
// 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)));
|
||||
expect(hasThrown).toBe(true);
|
||||
|
||||
// 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 plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('🐶🐶🐶'.substr(0,5));
|
||||
@@ -293,4 +293,5 @@ describe('services_EncryptionService', function() {
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
expect(service.isMasterKeyLoaded(masterKey)).toBe(false);
|
||||
}));
|
||||
|
||||
});
|
||||
|
@@ -25,16 +25,32 @@ interface DecryptedMasterKey {
|
||||
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 {
|
||||
|
||||
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;
|
||||
|
||||
// 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.
|
||||
private chunkSize_ = 5000;
|
||||
private decryptedMasterKeys_: Record<string, DecryptedMasterKey> = {};
|
||||
public defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
||||
public defaultEncryptionMethod_ = EncryptionMethod.SJCL1a; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||
|
||||
private headerTemplates_ = {
|
||||
// Template version 1
|
||||
@@ -79,8 +95,8 @@ export default class EncryptionService {
|
||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||
this.chunkSize_ = 5000;
|
||||
this.decryptedMasterKeys_ = {};
|
||||
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
|
||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
||||
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||
|
||||
this.headerTemplates_ = {
|
||||
// Template version 1
|
||||
@@ -97,6 +113,10 @@ export default class EncryptionService {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public get defaultMasterKeyEncryptionMethod() {
|
||||
return this.defaultMasterKeyEncryptionMethod_;
|
||||
}
|
||||
|
||||
loadedMasterKeysCount() {
|
||||
return Object.keys(this.decryptedMasterKeys_).length;
|
||||
}
|
||||
@@ -135,7 +155,7 @@ export default class EncryptionService {
|
||||
logger.info(`Loading master key: ${model.id}. Make active:`, makeActive);
|
||||
|
||||
this.decryptedMasterKeys_[model.id] = {
|
||||
plainText: await this.decryptMasterKey_(model, password),
|
||||
plainText: await this.decryptMasterKeyContent(model, password),
|
||||
updatedTime: model.updated_time,
|
||||
};
|
||||
|
||||
@@ -175,7 +195,7 @@ export default class EncryptionService {
|
||||
return await this.randomHexString(64);
|
||||
}
|
||||
|
||||
async randomHexString(byteCount: number) {
|
||||
private async randomHexString(byteCount: number) {
|
||||
const bytes: any[] = await shim.randomBytes(byteCount);
|
||||
return bytes
|
||||
.map(a => {
|
||||
@@ -184,32 +204,39 @@ export default class EncryptionService {
|
||||
.join('');
|
||||
}
|
||||
|
||||
masterKeysThatNeedUpgrading(masterKeys: MasterKeyEntity[]) {
|
||||
const output = MasterKey.allWithoutEncryptionMethod(masterKeys, this.defaultMasterKeyEncryptionMethod_);
|
||||
// Anything below 5 is a new encryption method and doesn't need an upgrade
|
||||
return output.filter(mk => mk.encryption_method <= 5);
|
||||
public masterKeysThatNeedUpgrading(masterKeys: MasterKeyEntity[]) {
|
||||
return MasterKey.allWithoutEncryptionMethod(masterKeys, [this.defaultMasterKeyEncryptionMethod_, EncryptionMethod.Custom]);
|
||||
}
|
||||
|
||||
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 plainText = await this.decryptMasterKey_(model, decryptionPassword);
|
||||
const newContent = await this.encryptMasterKeyContent_(newEncryptionMethod, plainText, decryptionPassword);
|
||||
const plainText = await this.decryptMasterKeyContent(model, decryptionPassword, decryptOptions);
|
||||
const newContent = await this.encryptMasterKeyContent(newEncryptionMethod, plainText, encryptionPassword, encryptOptions);
|
||||
return { ...model, ...newContent };
|
||||
}
|
||||
|
||||
async encryptMasterKeyContent_(encryptionMethod: number, hexaBytes: any, password: string): Promise<MasterKeyEntity> {
|
||||
// Checksum is not necessary since decryption will already fail if data is invalid
|
||||
const checksum = encryptionMethod === EncryptionService.METHOD_SJCL_2 ? this.sha256(hexaBytes) : '';
|
||||
const cipherText = await this.encrypt(encryptionMethod, password, hexaBytes);
|
||||
public async encryptMasterKeyContent(encryptionMethod: EncryptionMethod, hexaBytes: string, password: string, options: EncryptOptions = null): Promise<MasterKeyEntity> {
|
||||
options = { ...options };
|
||||
|
||||
return {
|
||||
checksum: checksum,
|
||||
encryption_method: encryptionMethod,
|
||||
content: cipherText,
|
||||
};
|
||||
if (encryptionMethod === null) encryptionMethod = this.defaultMasterKeyEncryptionMethod_;
|
||||
|
||||
if (options.encryptionHandler) {
|
||||
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({}, {
|
||||
encryptionMethod: this.defaultMasterKeyEncryptionMethod_,
|
||||
}, options);
|
||||
@@ -217,10 +244,10 @@ export default class EncryptionService {
|
||||
const bytes: any[] = await shim.randomBytes(256);
|
||||
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 now = Date.now();
|
||||
@@ -231,9 +258,16 @@ export default class EncryptionService {
|
||||
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);
|
||||
if (model.encryption_method === EncryptionService.METHOD_SJCL_2) {
|
||||
if (model.encryption_method === EncryptionMethod.SJCL2) {
|
||||
const checksum = this.sha256(plainText);
|
||||
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) {
|
||||
try {
|
||||
await this.decryptMasterKey_(model, password);
|
||||
await this.decryptMasterKeyContent(model, password);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@@ -257,14 +291,14 @@ export default class EncryptionService {
|
||||
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 (!key) throw new Error('Encryption key is required');
|
||||
|
||||
const sjcl = shim.sjclModule;
|
||||
|
||||
// 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 {
|
||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||
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
|
||||
// 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 {
|
||||
// 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
|
||||
@@ -304,7 +338,7 @@ export default class EncryptionService {
|
||||
|
||||
// 2020-01-23: Deprectated - see above.
|
||||
// Was used to encrypt master keys
|
||||
if (method === EncryptionService.METHOD_SJCL_2) {
|
||||
if (method === EncryptionMethod.SJCL2) {
|
||||
try {
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
v: 1,
|
||||
@@ -319,7 +353,7 @@ export default class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (method === EncryptionService.METHOD_SJCL_3) {
|
||||
if (method === EncryptionMethod.SJCL3) {
|
||||
try {
|
||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||
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
|
||||
if (method === EncryptionService.METHOD_SJCL_4) {
|
||||
if (method === EncryptionMethod.SJCL4) {
|
||||
try {
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
v: 1,
|
||||
@@ -355,7 +389,7 @@ export default class EncryptionService {
|
||||
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 (!key) throw new Error('Encryption key is required');
|
||||
|
||||
@@ -365,7 +399,7 @@ export default class EncryptionService {
|
||||
try {
|
||||
const output = sjcl.json.decrypt(key, cipherText);
|
||||
|
||||
if (method === EncryptionService.METHOD_SJCL_1A) {
|
||||
if (method === EncryptionMethod.SJCL1a) {
|
||||
return unescape(output);
|
||||
} else {
|
||||
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({}, {
|
||||
encryptionMethod: this.defaultEncryptionMethod(),
|
||||
}, options);
|
||||
|
||||
const method = options.encryptionMethod;
|
||||
const masterKeyId = this.activeMasterKeyId();
|
||||
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
||||
|
||||
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 = {};
|
||||
|
||||
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 destination = this.stringWriter_();
|
||||
await this.encryptAbstract_(source, destination, options);
|
||||
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 destination = this.stringWriter_();
|
||||
await this.decryptAbstract_(source, destination, options);
|
||||
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 destination = await this.fileWriter_(destPath, 'ascii');
|
||||
|
||||
@@ -528,7 +562,7 @@ export default class EncryptionService {
|
||||
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 destination = await this.fileWriter_(destPath, 'base64');
|
||||
|
||||
@@ -617,8 +651,8 @@ export default class EncryptionService {
|
||||
return output;
|
||||
}
|
||||
|
||||
isValidEncryptionMethod(method: number) {
|
||||
return [EncryptionService.METHOD_SJCL, EncryptionService.METHOD_SJCL_1A, EncryptionService.METHOD_SJCL_2, EncryptionService.METHOD_SJCL_3, EncryptionService.METHOD_SJCL_4].indexOf(method) >= 0;
|
||||
isValidEncryptionMethod(method: EncryptionMethod) {
|
||||
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4].indexOf(method) >= 0;
|
||||
}
|
||||
|
||||
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 { migrateMasterPassword, showMissingMasterKeyMessage } from './utils';
|
||||
import { migrateMasterPassword, showMissingMasterKeyMessage, updateMasterPassword } from './utils';
|
||||
import { localSyncInfo, setActiveMasterKeyId, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import Setting from '../../models/Setting';
|
||||
import { generateKeyPairAndSave, ppkPasswordIsValid } from './ppk';
|
||||
|
||||
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 { MasterKeyEntity } from './types';
|
||||
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');
|
||||
|
||||
@@ -165,3 +167,108 @@ export function getDefaultMasterKey(): MasterKeyEntity {
|
||||
if (mk) return mk;
|
||||
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 { msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import { encryptionService, msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import ShareService from './ShareService';
|
||||
import reducer from '../../reducer';
|
||||
import { createStore } from 'redux';
|
||||
import { NoteEntity } from '../database/types';
|
||||
import 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() {
|
||||
return {
|
||||
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
||||
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
||||
return null;
|
||||
},
|
||||
personalizedUserContentBaseUrl(_userId: string) {
|
||||
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockService() {
|
||||
function mockService(api: any) {
|
||||
const service = new ShareService();
|
||||
const store = createStore(reducer as any);
|
||||
service.initialize(store, mockApi() as any);
|
||||
service.initialize(store, encryptionService(), api);
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -32,9 +25,17 @@ describe('ShareService', function() {
|
||||
done();
|
||||
});
|
||||
|
||||
it('should not change the note user timestamps when sharing or unsharing', (async () => {
|
||||
it('should not change the note user timestamps when sharing or unsharing', async () => {
|
||||
let note = await Note.save({});
|
||||
const service = mockService();
|
||||
const service = mockService({
|
||||
exec: (method: string, path: string = '', _query: Record<string, any> = null, _body: any = null, _headers: any = null, _options: any = null): Promise<any> => {
|
||||
if (method === 'GET' && path === 'api/shares') return { items: [] } as any;
|
||||
return null;
|
||||
},
|
||||
personalizedUserContentBaseUrl(_userId: string) {
|
||||
|
||||
},
|
||||
});
|
||||
await msleep(1);
|
||||
await service.shareNote(note.id);
|
||||
|
||||
@@ -61,6 +62,86 @@ describe('ShareService', function() {
|
||||
const noteReloaded = await Note.load(note.id);
|
||||
checkTimestamps(note, noteReloaded);
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
function testShareFolderService(extraExecHandlers: Record<string, Function> = {}) {
|
||||
return mockService({
|
||||
exec: async (method: string, path: string, query: Record<string, any>, body: any) => {
|
||||
if (extraExecHandlers[`${method} ${path}`]) return extraExecHandlers[`${method} ${path}`](query, body);
|
||||
|
||||
if (method === 'POST' && path === 'api/shares') {
|
||||
return {
|
||||
id: 'share_1',
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled: ${method} ${path}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function testShareFolder(service: ShareService) {
|
||||
const folder = await Folder.save({});
|
||||
const note = await Note.save({ parent_id: folder.id });
|
||||
|
||||
const share = await service.shareFolder(folder.id);
|
||||
expect(share.id).toBe('share_1');
|
||||
expect((await Folder.load(folder.id)).share_id).toBe('share_1');
|
||||
expect((await Note.load(note.id)).share_id).toBe('share_1');
|
||||
|
||||
return share;
|
||||
}
|
||||
|
||||
it('should share a folder', async () => {
|
||||
await testShareFolder(testShareFolderService());
|
||||
});
|
||||
|
||||
it('should share a folder - E2EE', async () => {
|
||||
setEncryptionEnabled(true);
|
||||
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 JoplinServerApi from '../../JoplinServerApi';
|
||||
import { _ } from '../../locale';
|
||||
import Logger from '../../Logger';
|
||||
import Folder from '../../models/Folder';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import Note from '../../models/Note';
|
||||
import Setting from '../../models/Setting';
|
||||
import { State, stateRootKey, StateShare } from './reducer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import EncryptionService from '../e2ee/EncryptionService';
|
||||
import { PublicPrivateKeyPair, mkReencryptFromPasswordToPublicKey, mkReencryptFromPublicKeyToPassword } from '../e2ee/ppk';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
import { getMasterPassword } from '../e2ee/utils';
|
||||
import { addMasterKey, getEncryptionEnabled, localSyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
import { ShareInvitation, State, stateRootKey, StateShare } from './reducer';
|
||||
|
||||
const logger = Logger.create('ShareService');
|
||||
|
||||
export interface ApiShare {
|
||||
id: string;
|
||||
master_key_id: string;
|
||||
}
|
||||
|
||||
function formatShareInvitations(invitations: any[]): ShareInvitation[] {
|
||||
return invitations.map(inv => {
|
||||
return {
|
||||
...inv,
|
||||
master_key: inv.master_key ? JSON.parse(inv.master_key) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default class ShareService {
|
||||
|
||||
private static instance_: ShareService;
|
||||
private api_: JoplinServerApi = null;
|
||||
private store_: Store<any> = null;
|
||||
private encryptionService_: EncryptionService = null;
|
||||
|
||||
public static instance(): ShareService {
|
||||
if (this.instance_) return this.instance_;
|
||||
@@ -20,8 +43,9 @@ export default class ShareService {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public initialize(store: Store<any>, api: JoplinServerApi = null) {
|
||||
public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
|
||||
this.store_ = store;
|
||||
this.encryptionService_ = encryptionService;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
@@ -56,15 +80,40 @@ export default class ShareService {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string) {
|
||||
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
if (folder.parent_id) {
|
||||
await Folder.save({ id: folder.id, parent_id: '' });
|
||||
let folderMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
const syncInfo = localSyncInfo();
|
||||
|
||||
// Shouldn't happen
|
||||
if (!syncInfo.ppk) throw new Error('Cannot share notebook because E2EE is enabled and no Public Private Key pair exists.');
|
||||
|
||||
folderMasterKey = await this.encryptionService_.generateMasterKey(getMasterPassword());
|
||||
folderMasterKey = await MasterKey.save(folderMasterKey);
|
||||
|
||||
addMasterKey(syncInfo, folderMasterKey);
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
||||
const newFolderProps: FolderEntity = {};
|
||||
|
||||
if (folder.parent_id) newFolderProps.parent_id = '';
|
||||
if (folderMasterKey) newFolderProps.master_key_id = folderMasterKey.id;
|
||||
|
||||
if (Object.keys(newFolderProps).length) {
|
||||
await Folder.save({
|
||||
id: folder.id,
|
||||
...newFolderProps,
|
||||
});
|
||||
}
|
||||
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, {
|
||||
folder_id: folderId,
|
||||
master_key_id: folderMasterKey ? folderMasterKey.id : '',
|
||||
});
|
||||
|
||||
// Note: race condition if the share is created but the app crashes
|
||||
// before setting share_id on the folder. See unshareFolder() for info.
|
||||
@@ -174,9 +223,34 @@ export default class ShareService {
|
||||
return this.state.shareInvitations;
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
||||
private async userPublicKey(userEmail: string): Promise<PublicPrivateKeyPair> {
|
||||
return this.api().exec('GET', `api/users/${encodeURIComponent(userEmail)}/public_key`);
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, masterKeyId: string, recipientEmail: string) {
|
||||
let recipientMasterKey: MasterKeyEntity = null;
|
||||
|
||||
if (getEncryptionEnabled()) {
|
||||
const syncInfo = localSyncInfo();
|
||||
const masterKey = syncInfo.masterKeys.find(m => m.id === masterKeyId);
|
||||
if (!masterKey) throw new Error(`Cannot find master key with ID "${masterKeyId}"`);
|
||||
|
||||
const recipientPublicKey: PublicPrivateKeyPair = await this.userPublicKey(recipientEmail);
|
||||
if (!recipientPublicKey) throw new Error(_('Cannot share notebook with recipient %s because they do not have a public key. Ask them to create one from the menu "%s"', recipientEmail, 'Tools > Generate Public-Private Key pair'));
|
||||
|
||||
logger.info('Reencrypting master key with recipient public key', recipientPublicKey);
|
||||
|
||||
recipientMasterKey = await mkReencryptFromPasswordToPublicKey(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
getMasterPassword(),
|
||||
recipientPublicKey
|
||||
);
|
||||
}
|
||||
|
||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
master_key: JSON.stringify(recipientMasterKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -200,8 +274,24 @@ export default class ShareService {
|
||||
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 (masterKey) {
|
||||
const reencryptedMasterKey = await mkReencryptFromPublicKeyToPassword(
|
||||
this.encryptionService_,
|
||||
masterKey,
|
||||
localSyncInfo().ppk,
|
||||
getMasterPassword(),
|
||||
getMasterPassword()
|
||||
);
|
||||
|
||||
logger.info('respondInvitation: Key has been reencrypted using master password', reencryptedMasterKey);
|
||||
|
||||
await MasterKey.save(reencryptedMasterKey);
|
||||
}
|
||||
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 1 });
|
||||
} else {
|
||||
await this.api().exec('PATCH', `api/share_users/${shareUserId}`, null, { status: 2 });
|
||||
@@ -211,15 +301,57 @@ export default class ShareService {
|
||||
public async refreshShareInvitations() {
|
||||
const result = await this.loadShareInvitations();
|
||||
|
||||
const invitations = formatShareInvitations(result.items);
|
||||
logger.info('Refresh share invitations:', invitations);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_INVITATION_SET',
|
||||
shareInvitations: result.items,
|
||||
shareInvitations: invitations,
|
||||
});
|
||||
}
|
||||
|
||||
public async shareById(id: string) {
|
||||
const stateShare = this.state.shares.find(s => s.id === id);
|
||||
if (stateShare) return stateShare;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
const refreshedShare = refreshedShares.find(s => s.id === id);
|
||||
if (!refreshedShare) throw new Error(`Could not find share with ID: ${id}`);
|
||||
return refreshedShare;
|
||||
}
|
||||
|
||||
// In most cases the share objects will already be part of the state, so
|
||||
// this function checks there first. If the required share objects are not
|
||||
// present, it refreshes them from the API.
|
||||
public async sharesByIds(ids: string[]) {
|
||||
const buildOutput = async (shares: StateShare[]) => {
|
||||
const output: Record<string, StateShare> = {};
|
||||
for (const share of shares) {
|
||||
if (ids.includes(share.id)) output[share.id] = share;
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
let output = await buildOutput(this.state.shares);
|
||||
if (Object.keys(output).length === ids.length) return output;
|
||||
|
||||
const refreshedShares = await this.refreshShares();
|
||||
output = await buildOutput(refreshedShares);
|
||||
|
||||
if (Object.keys(output).length !== ids.length) {
|
||||
logger.error('sharesByIds: Need:', ids);
|
||||
logger.error('sharesByIds: Got:', Object.keys(refreshedShares));
|
||||
throw new Error('Could not retrieve required share objects');
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
public async refreshShares(): Promise<StateShare[]> {
|
||||
const result = await this.loadShares();
|
||||
|
||||
logger.info('Refreshed shares:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_SET',
|
||||
shares: result.items,
|
||||
@@ -231,6 +363,8 @@ export default class ShareService {
|
||||
public async refreshShareUsers(shareId: string) {
|
||||
const result = await this.loadShareUsers(shareId);
|
||||
|
||||
logger.info('Refreshed share users:', result);
|
||||
|
||||
this.store.dispatch({
|
||||
type: 'SHARE_USER_SET',
|
||||
shareId: shareId,
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import { State as RootState } from '../../reducer';
|
||||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
@@ -25,11 +26,13 @@ export interface StateShare {
|
||||
type: number;
|
||||
folder_id: string;
|
||||
note_id: string;
|
||||
master_key_id: string;
|
||||
user?: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string;
|
||||
master_key: MasterKeyEntity;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
}
|
||||
|
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 Setting from '../../models/Setting';
|
||||
import { State } from '../../reducer';
|
||||
import { PublicPrivateKeyPair } from '../e2ee/ppk';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
|
||||
export interface SyncInfoValueBoolean {
|
||||
@@ -14,6 +15,11 @@ export interface SyncInfoValueString {
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
export interface SyncInfoValuePublicPrivateKeyPair {
|
||||
value: PublicPrivateKeyPair;
|
||||
updatedTime: number;
|
||||
}
|
||||
|
||||
export async function migrateLocalSyncInfo(db: JoplinDatabase) {
|
||||
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('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.masterKeys = s1.masterKeys.slice();
|
||||
@@ -115,10 +122,12 @@ export class SyncInfo {
|
||||
private e2ee_: SyncInfoValueBoolean;
|
||||
private activeMasterKeyId_: SyncInfoValueString;
|
||||
private masterKeys_: MasterKeyEntity[] = [];
|
||||
private ppk_: SyncInfoValuePublicPrivateKeyPair;
|
||||
|
||||
public constructor(serialized: string = null) {
|
||||
this.e2ee_ = { value: false, updatedTime: 0 };
|
||||
this.activeMasterKeyId_ = { value: '', updatedTime: 0 };
|
||||
this.ppk_ = { value: null, updatedTime: 0 };
|
||||
|
||||
if (serialized) this.load(serialized);
|
||||
}
|
||||
@@ -129,6 +138,7 @@ export class SyncInfo {
|
||||
e2ee: this.e2ee_,
|
||||
activeMasterKeyId: this.activeMasterKeyId_,
|
||||
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.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
||||
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
||||
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
|
||||
}
|
||||
|
||||
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
|
||||
@@ -161,6 +172,16 @@ export class SyncInfo {
|
||||
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 {
|
||||
return this.e2ee_.value;
|
||||
}
|
||||
@@ -257,3 +278,11 @@ export function masterKeyEnabled(mk: MasterKeyEntity): boolean {
|
||||
if ('enabled' in mk) return !!mk.enabled;
|
||||
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) {
|
||||
const service = encryptionService(id);
|
||||
const password = '123456';
|
||||
|
||||
let masterKey = null;
|
||||
|
||||
if (!useExisting) { // Create it
|
||||
masterKey = await service.generateMasterKey('123456');
|
||||
masterKey = await service.generateMasterKey(password);
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
} else { // Use the one already available
|
||||
const masterKeys = await MasterKey.all();
|
||||
@@ -517,7 +518,12 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
||||
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);
|
||||
|
||||
|
@@ -209,7 +209,7 @@ function addExtraStyles(style: any) {
|
||||
backgroundColor: style.backgroundColor4,
|
||||
borderColor: style.borderColor4,
|
||||
userSelect: 'none',
|
||||
cursor: 'pointer',
|
||||
// cursor: 'pointer',
|
||||
}
|
||||
);
|
||||
|
||||
|
@@ -14,6 +14,7 @@ const theme: Theme = {
|
||||
oddBackgroundColor: '#141517',
|
||||
color: '#dddddd',
|
||||
colorError: 'red',
|
||||
colorCorrect: '#72b972',
|
||||
colorWarn: '#9A5B00',
|
||||
colorWarnUrl: '#ffff82',
|
||||
colorFaded: '#999999', // For less important text
|
||||
|
@@ -11,6 +11,7 @@ const theme: Theme = {
|
||||
oddBackgroundColor: '#eeeeee',
|
||||
color: '#32373F', // For regular text
|
||||
colorError: 'red',
|
||||
colorCorrect: 'green', // Opposite of colorError
|
||||
colorWarn: 'rgb(228,86,0)',
|
||||
colorWarnUrl: '#155BDA',
|
||||
colorFaded: '#7C8B9E', // For less important text
|
||||
|
@@ -13,6 +13,7 @@ export interface Theme {
|
||||
oddBackgroundColor: string;
|
||||
color: string; // For regular text
|
||||
colorError: string;
|
||||
colorCorrect: string;
|
||||
colorWarn: string;
|
||||
colorWarnUrl: string; // For URL displayed over a warningBackgroundColor
|
||||
colorFaded: string; // For less important text
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.4.3",
|
||||
"version": "2.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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.find(s => s.folder_id === '000000000000000000000000000000F1')).toBeTruthy();
|
||||
|
||||
const participatedShares1 = await models().share().participatedSharesByUser(user1.id, ShareType.Folder);
|
||||
const participatedShares2 = await models().share().participatedSharesByUser(user2.id, ShareType.Folder);
|
||||
const participatedShares3 = await models().share().participatedSharesByUser(user3.id, ShareType.Folder);
|
||||
|
||||
expect(participatedShares1.length).toBe(1);
|
||||
expect(participatedShares1[0].owner_id).toBe(user2.id);
|
||||
expect(participatedShares1[0].folder_id).toBe('000000000000000000000000000000F2');
|
||||
|
||||
expect(participatedShares2.length).toBe(0);
|
||||
|
||||
expect(participatedShares3.length).toBe(1);
|
||||
expect(participatedShares3[0].owner_id).toBe(user1.id);
|
||||
expect(participatedShares3[0].folder_id).toBe('000000000000000000000000000000F1');
|
||||
});
|
||||
|
||||
test('should generate only one link per shared note', async function() {
|
||||
@@ -78,8 +92,8 @@ describe('ShareModel', function() {
|
||||
},
|
||||
});
|
||||
|
||||
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
const share1 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
const share2 = await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
|
||||
expect(share1.id).toBe(share2.id);
|
||||
});
|
||||
@@ -93,7 +107,7 @@ describe('ShareModel', function() {
|
||||
},
|
||||
});
|
||||
|
||||
await models().share().shareNote(user1, '00000000000000000000000000000001');
|
||||
await models().share().shareNote(user1, '00000000000000000000000000000001', '');
|
||||
const noteItem = await models().item().loadByJopId(user1.id, '00000000000000000000000000000001');
|
||||
await models().item().delete(noteItem.id);
|
||||
expect(await models().item().load(noteItem.id)).toBeFalsy();
|
||||
|
@@ -60,6 +60,7 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
if (object.folder_id) output.folder_id = object.folder_id;
|
||||
if (object.owner_id) output.owner_id = object.owner_id;
|
||||
if (object.note_id) output.note_id = object.note_id;
|
||||
if (object.master_key_id) output.master_key_id = object.master_key_id;
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -148,6 +149,20 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
return query;
|
||||
}
|
||||
|
||||
public async participatedSharesByUser(userId: Uuid, type: ShareType = null): Promise<Share[]> {
|
||||
const query = this.db(this.tableName)
|
||||
.select(this.defaultFields)
|
||||
.whereIn('id', this.db('share_users')
|
||||
.select('share_id')
|
||||
.where('user_id', '=', userId)
|
||||
.andWhere('status', '=', ShareUserStatus.Accepted
|
||||
));
|
||||
|
||||
if (type) void query.andWhere('type', '=', type);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
// Returns all user IDs concerned by the share. That includes all the users
|
||||
// the folder has been shared with, as well as the folder owner.
|
||||
public async allShareUserIds(share: Share): Promise<Uuid[]> {
|
||||
@@ -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);
|
||||
if (!folderItem) throw new ErrorNotFound(`No such folder: ${folderId}`);
|
||||
|
||||
const share = await this.models().share().byUserAndItemId(owner.id, folderItem.id);
|
||||
if (share) return share;
|
||||
|
||||
const shareToSave = {
|
||||
const shareToSave: Share = {
|
||||
type: ShareType.Folder,
|
||||
item_id: folderItem.id,
|
||||
owner_id: owner.id,
|
||||
folder_id: folderId,
|
||||
master_key_id: masterKeyId,
|
||||
};
|
||||
|
||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||
return super.save(shareToSave);
|
||||
}
|
||||
|
||||
public async shareNote(owner: User, noteId: string): Promise<Share> {
|
||||
public async shareNote(owner: User, noteId: string, masterKeyId: string): Promise<Share> {
|
||||
const noteItem = await this.models().item().loadByJopId(owner.id, noteId);
|
||||
if (!noteItem) throw new ErrorNotFound(`No such note: ${noteId}`);
|
||||
|
||||
const existingShare = await this.byItemId(noteItem.id);
|
||||
if (existingShare) return existingShare;
|
||||
|
||||
const shareToSave = {
|
||||
const shareToSave: Share = {
|
||||
type: ShareType.Note,
|
||||
item_id: noteItem.id,
|
||||
owner_id: owner.id,
|
||||
note_id: noteId,
|
||||
master_key_id: masterKeyId,
|
||||
};
|
||||
|
||||
await this.checkIfAllowed(owner, AclAction.Create, shareToSave);
|
||||
|
@@ -80,14 +80,14 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
return this.db(this.tableName).where(link).first();
|
||||
}
|
||||
|
||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
|
||||
await this.models().shareUser().addById(share.id, shareeId);
|
||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid, masterKey: string = '') {
|
||||
await this.models().shareUser().addById(share.id, shareeId, masterKey);
|
||||
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
|
||||
}
|
||||
|
||||
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
|
||||
public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise<ShareUser> {
|
||||
const user = await this.models().user().load(userId);
|
||||
return this.addByEmail(shareId, user.email);
|
||||
return this.addByEmail(shareId, user.email, masterKey);
|
||||
}
|
||||
|
||||
public async byShareAndEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||
@@ -100,7 +100,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
.first();
|
||||
}
|
||||
|
||||
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||
public async addByEmail(shareId: Uuid, userEmail: string, masterKey: string): Promise<ShareUser> {
|
||||
const share = await this.models().share().load(shareId);
|
||||
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
|
||||
|
||||
@@ -110,6 +110,7 @@ export default class ShareUserModel extends BaseModel<ShareUser> {
|
||||
return this.save({
|
||||
share_id: shareId,
|
||||
user_id: user.id,
|
||||
master_key: masterKey,
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem, expectThrow } from '../utils/testing/testUtils';
|
||||
import { EmailSender, User, UserFlagType } from '../services/database/types';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
@@ -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 endOfBetaTemplate from '../views/emails/endOfBetaTemplate';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import { PublicPrivateKeyPair } from '@joplin/lib/services/e2ee/ppk';
|
||||
import paymentFailedUploadDisabledTemplate from '../views/emails/paymentFailedUploadDisabledTemplate';
|
||||
import oversizedAccount1 from '../views/emails/oversizedAccount1';
|
||||
import oversizedAccount2 from '../views/emails/oversizedAccount2';
|
||||
@@ -439,6 +440,18 @@ export default class UserModel extends BaseModel<User> {
|
||||
return output;
|
||||
}
|
||||
|
||||
private async syncInfo(userId: Uuid): Promise<any> {
|
||||
const item = await this.models().item().loadByName(userId, 'info.json');
|
||||
if (!item) throw new Error('Cannot find info.json file');
|
||||
const withContent = await this.models().item().loadWithContent(item.id);
|
||||
return JSON.parse(withContent.content.toString());
|
||||
}
|
||||
|
||||
public async publicPrivateKey(userId: string): Promise<PublicPrivateKeyPair> {
|
||||
const syncInfo = await this.syncInfo(userId);
|
||||
return syncInfo.ppk?.value || null;// syncInfo.ppk?.value.publicKey || '';
|
||||
}
|
||||
|
||||
// Note that when the "password" property is provided, it is going to be
|
||||
// hashed automatically. It means that it is not safe to do:
|
||||
//
|
||||
|
@@ -43,6 +43,7 @@ router.get('api/share_users', async (_path: SubPath, ctx: AppContext) => {
|
||||
items.push({
|
||||
id: su.id,
|
||||
status: su.status,
|
||||
master_key: su.master_key,
|
||||
share: {
|
||||
id: share.id,
|
||||
folder_id: share.folder_id,
|
||||
|
@@ -19,11 +19,18 @@ router.public = true;
|
||||
router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
ownerRequired(ctx);
|
||||
|
||||
interface Fields {
|
||||
folder_id?: string;
|
||||
note_id?: string;
|
||||
master_key_id?: string;
|
||||
}
|
||||
|
||||
const shareModel = ctx.joplin.models.share();
|
||||
const fields = await bodyFields<any>(ctx.req);
|
||||
const fields = await bodyFields<Fields>(ctx.req);
|
||||
const shareInput: ShareApiInput = shareModel.fromApiInput(fields) as ShareApiInput;
|
||||
if (fields.folder_id) shareInput.folder_id = fields.folder_id;
|
||||
if (fields.note_id) shareInput.note_id = fields.note_id;
|
||||
const masterKeyId = fields.master_key_id || '';
|
||||
|
||||
// - The API end point should only expose two ways of sharing:
|
||||
// - By folder_id (JoplinRootFolder)
|
||||
@@ -31,9 +38,9 @@ router.post('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
// - Additionally, the App method is available, but not exposed via the API.
|
||||
|
||||
if (shareInput.folder_id) {
|
||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id);
|
||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id, masterKeyId);
|
||||
} else if (shareInput.note_id) {
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id);
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId);
|
||||
} else {
|
||||
throw new ErrorBadRequest('Either folder_id or note_id must be provided');
|
||||
}
|
||||
@@ -44,20 +51,23 @@ router.post('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||
|
||||
interface UserInput {
|
||||
email: string;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
const fields = await bodyFields(ctx.req) as UserInput;
|
||||
const user = await ctx.joplin.models.user().loadByEmail(fields.email);
|
||||
if (!user) throw new ErrorNotFound('User not found');
|
||||
|
||||
const masterKey = fields.master_key || '';
|
||||
const shareId = path.id;
|
||||
|
||||
await ctx.joplin.models.shareUser().checkIfAllowed(ctx.joplin.owner, AclAction.Create, {
|
||||
share_id: shareId,
|
||||
user_id: user.id,
|
||||
master_key: masterKey,
|
||||
});
|
||||
|
||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email);
|
||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email, masterKey);
|
||||
});
|
||||
|
||||
router.get('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||
@@ -102,13 +112,17 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
throw new ErrorNotFound();
|
||||
});
|
||||
|
||||
// This end points returns both the shares owned by the user, and those they
|
||||
// participate in.
|
||||
router.get('api/shares', async (_path: SubPath, ctx: AppContext) => {
|
||||
ownerRequired(ctx);
|
||||
|
||||
const shares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||
const ownedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||
const participatedShares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().participatedSharesByUser(ctx.joplin.owner.id));
|
||||
|
||||
// Fake paginated results so that it can be added later on, if needed.
|
||||
return {
|
||||
items: shares.map(share => {
|
||||
items: ownedShares.concat(participatedShares).map(share => {
|
||||
return {
|
||||
...share,
|
||||
user: {
|
||||
|
@@ -26,6 +26,21 @@ router.get('api/users/:id', async (path: SubPath, ctx: AppContext) => {
|
||||
return user;
|
||||
});
|
||||
|
||||
router.publicSchemas.push('api/users/:id/public_key');
|
||||
|
||||
// "id" in this case is actually the email address
|
||||
router.get('api/users/:id/public_key', async (path: SubPath, ctx: AppContext) => {
|
||||
const user = await ctx.joplin.models.user().loadByEmail(path.id);
|
||||
if (!user) return ''; // Don't throw an error to prevent polling the end point
|
||||
|
||||
const ppk = await ctx.joplin.models.user().publicPrivateKey(user.id);
|
||||
|
||||
return {
|
||||
id: ppk.id,
|
||||
publicKey: ppk.publicKey,
|
||||
};
|
||||
});
|
||||
|
||||
router.post('api/users', async (_path: SubPath, ctx: AppContext) => {
|
||||
await ctx.joplin.models.user().checkIfAllowed(ctx.joplin.owner, AclAction.Create);
|
||||
const user = await postedUserFromContext(ctx);
|
||||
|
@@ -137,6 +137,7 @@ export interface ShareUser extends WithDates, WithUuid {
|
||||
share_id?: Uuid;
|
||||
user_id?: Uuid;
|
||||
status?: ShareUserStatus;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
export interface Item extends WithDates, WithUuid {
|
||||
@@ -177,6 +178,7 @@ export interface Share extends WithDates, WithUuid {
|
||||
type?: ShareType;
|
||||
folder_id?: Uuid;
|
||||
note_id?: Uuid;
|
||||
master_key_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
@@ -293,6 +295,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
status: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
master_key: { type: 'string' },
|
||||
},
|
||||
items: {
|
||||
id: { type: 'string' },
|
||||
@@ -336,6 +339,7 @@ export const databaseSchema: DatabaseTables = {
|
||||
created_time: { type: 'string' },
|
||||
folder_id: { type: 'string' },
|
||||
note_id: { type: 'string' },
|
||||
master_key_id: { type: 'string' },
|
||||
},
|
||||
changes: {
|
||||
counter: { type: 'number' },
|
||||
|
@@ -2,8 +2,8 @@ import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||
import { ErrorTooManyRequests } from '../errors';
|
||||
|
||||
const limiterSlowBruteByIP = new RateLimiterMemory({
|
||||
points: 3, // Up to 3 requests per IP
|
||||
duration: 30, // Per 30 seconds
|
||||
points: 10, // Up to 10 requests per IP
|
||||
duration: 60, // Per 60 seconds
|
||||
});
|
||||
|
||||
export default async function(ip: string) {
|
||||
|
@@ -135,12 +135,12 @@ export function parseSubPath(basePath: string, p: string, rawPath: string = null
|
||||
if (colonIndex2 < 0) {
|
||||
throw new ErrorBadRequest(`Invalid path format: ${p}`);
|
||||
} else {
|
||||
output.id = p.substr(0, colonIndex2 + 1);
|
||||
output.id = decodeURIComponent(p.substr(0, colonIndex2 + 1));
|
||||
output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
|
||||
}
|
||||
} else {
|
||||
const s = p.split('/');
|
||||
if (s.length >= 1) output.id = s[0];
|
||||
if (s.length >= 1) output.id = decodeURIComponent(s[0]);
|
||||
if (s.length >= 2) output.link = s[1];
|
||||
}
|
||||
|
||||
|
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