You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2026-01-14 00:29:38 +02:00
Compare commits
10 Commits
sharing_e2
...
refactor_e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdf49302f9 | ||
|
|
c5fa20dadb | ||
|
|
9dbab0413a | ||
|
|
a03e9811b6 | ||
|
|
a2c6461af8 | ||
|
|
d33b99cffb | ||
|
|
267c32143b | ||
|
|
9260b2a9ab | ||
|
|
0a54854f54 | ||
|
|
496039f15c |
@@ -208,9 +208,9 @@ packages/app-desktop/gui/DialogTitle.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.d.ts
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.js.map
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.d.ts
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js.map
|
||||
packages/app-desktop/gui/ErrorBoundary.d.ts
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js.map
|
||||
@@ -328,9 +328,6 @@ 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
|
||||
@@ -931,15 +928,12 @@ 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
|
||||
packages/lib/components/shared/encryption-config-shared.d.ts
|
||||
packages/lib/components/shared/encryption-config-shared.js
|
||||
packages/lib/components/shared/encryption-config-shared.js.map
|
||||
packages/lib/components/EncryptionConfigScreen/utils.d.ts
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js.map
|
||||
packages/lib/database.d.ts
|
||||
packages/lib/database.js
|
||||
packages/lib/database.js.map
|
||||
@@ -1237,12 +1231,6 @@ 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
|
||||
@@ -1579,9 +1567,6 @@ 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|SJCL.*)$',
|
||||
'regex': '^(GET|POST|PUT|DELETE|PATCH|HEAD|SQLite|PostgreSQL|ASC|DESC|E2EE|OR|AND|UNION|INTERSECT|EXCLUSION|INCLUSION|EUR|GBP|USD)$',
|
||||
'match': true,
|
||||
},
|
||||
},
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -193,9 +193,9 @@ packages/app-desktop/gui/DialogTitle.js.map
|
||||
packages/app-desktop/gui/DropboxLoginScreen.d.ts
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js
|
||||
packages/app-desktop/gui/DropboxLoginScreen.js.map
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.d.ts
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen.js.map
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.d.ts
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js
|
||||
packages/app-desktop/gui/EncryptionConfigScreen/EncryptionConfigScreen.js.map
|
||||
packages/app-desktop/gui/ErrorBoundary.d.ts
|
||||
packages/app-desktop/gui/ErrorBoundary.js
|
||||
packages/app-desktop/gui/ErrorBoundary.js.map
|
||||
@@ -313,9 +313,6 @@ 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
|
||||
@@ -916,15 +913,12 @@ 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
|
||||
packages/lib/components/shared/encryption-config-shared.d.ts
|
||||
packages/lib/components/shared/encryption-config-shared.js
|
||||
packages/lib/components/shared/encryption-config-shared.js.map
|
||||
packages/lib/components/EncryptionConfigScreen/utils.d.ts
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js
|
||||
packages/lib/components/EncryptionConfigScreen/utils.js.map
|
||||
packages/lib/database.d.ts
|
||||
packages/lib/database.js
|
||||
packages/lib/database.js.map
|
||||
@@ -1222,12 +1216,6 @@ 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
|
||||
@@ -1564,9 +1552,6 @@ 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`.'); // `generate-ppk`
|
||||
return _('Manages E2EE configuration. Commands are `enable`, `disable`, `decrypt`, `status`, `decrypt-file` and `target-status`.');
|
||||
}
|
||||
|
||||
options() {
|
||||
@@ -151,19 +151,6 @@ 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: 'masterPassword',
|
||||
});
|
||||
}, 2000);
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
// name: 'syncWizard',
|
||||
// });
|
||||
// }, 2000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { _ } from '@joplin/lib/locale';
|
||||
import bridge from '../../services/bridge';
|
||||
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import control_PluginsStates from './controls/plugins/PluginsStates';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
|
||||
@@ -27,12 +27,11 @@ const DialogRoot = styled.div`
|
||||
|
||||
interface Props {
|
||||
renderContent: Function;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Dialog(props: Props) {
|
||||
return (
|
||||
<DialogModalLayer className={props.className}>
|
||||
<DialogModalLayer>
|
||||
<DialogRoot>
|
||||
{props.renderContent()}
|
||||
</DialogRoot>
|
||||
|
||||
@@ -17,13 +17,10 @@ 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[];
|
||||
}
|
||||
|
||||
@@ -71,15 +68,15 @@ export default function DialogButtonRow(props: Props) {
|
||||
|
||||
if (props.okButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
|
||||
<button key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
{_('OK')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.cancelButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={cancelButton_click}>
|
||||
<button 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 : 'center'};
|
||||
justify-content: ${props => props.justifyContent ? props.justifyContent : 'flex-start'};
|
||||
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: 1em;
|
||||
margin-bottom: 1.2em;
|
||||
`;
|
||||
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
const React = require('react');
|
||||
const { connect } = require('react-redux');
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import dialogs from './dialogs';
|
||||
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, 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';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const MasterPasswordInput = styled(StyledInput)`
|
||||
min-width: 300px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
interface Props {}
|
||||
|
||||
class EncryptionConfigScreenComponent extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
shared.initialize(this, props);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted_ = false;
|
||||
shared.componentWillUnmount();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isMounted_ = true;
|
||||
shared.componentDidMount(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
shared.componentDidUpdate(this, prevProps);
|
||||
}
|
||||
|
||||
async checkPasswords() {
|
||||
return shared.checkPasswords(this);
|
||||
}
|
||||
|
||||
private renderMasterKey(mk: MasterKeyEntity, _isDefault: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const onToggleEnabledClick = () => {
|
||||
return shared.onToggleEnabledClick(this, mk);
|
||||
};
|
||||
|
||||
const passwordStyle = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
border: '1px solid',
|
||||
borderColor: theme.dividerColor,
|
||||
};
|
||||
|
||||
const onSaveClick = () => {
|
||||
return shared.onSavePasswordClick(this, mk);
|
||||
};
|
||||
|
||||
const onPasswordChange = (event: any) => {
|
||||
return shared.onPasswordChange(this, mk, event.target.value);
|
||||
};
|
||||
|
||||
const renderPasswordInput = (masterKeyId: string) => {
|
||||
if (this.state.masterPasswordKeys[masterKeyId] || !this.state.passwordChecks['master']) {
|
||||
return (
|
||||
<td style={{ ...theme.textStyle, color: theme.colorFaded, fontStyle: 'italic' }}>
|
||||
({_('Master password')})
|
||||
</td>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<td style={theme.textStyle}>
|
||||
<input type="password" style={passwordStyle} value={password} onChange={event => onPasswordChange(event)} />{' '}
|
||||
<button style={theme.buttonStyle} onClick={() => onSaveClick()}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
|
||||
const isActive = this.props.activeMasterKeyId === mk.id;
|
||||
const activeIcon = isActive ? '✔' : '';
|
||||
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
|
||||
return (
|
||||
<tr key={mk.id}>
|
||||
<td style={theme.textStyle}>{activeIcon}</td>
|
||||
<td style={theme.textStyle}>{mk.id}<br/>{_('Source: ')}{mk.source_application}</td>
|
||||
<td style={theme.textStyle}>{_('Created: ')}{time.formatMsToLocal(mk.created_time)}<br/>{_('Updated: ')}{time.formatMsToLocal(mk.updated_time)}</td>
|
||||
{renderPasswordInput(mk.id)}
|
||||
<td style={theme.textStyle}>{passwordOk}</td>
|
||||
<td style={theme.textStyle}>
|
||||
<button style={theme.buttonStyle} onClick={() => onToggleEnabledClick()}>{masterKeyEnabled(mk) ? _('Disable') : _('Enable')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
renderNeedUpgradeSection() {
|
||||
if (!shim.isElectron()) return null;
|
||||
|
||||
const needUpgradeMasterKeys = EncryptionService.instance().masterKeysThatNeedUpgrading(this.props.masterKeys);
|
||||
if (!needUpgradeMasterKeys.length) return null;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const rows = [];
|
||||
const comp = this;
|
||||
|
||||
for (const mk of needUpgradeMasterKeys) {
|
||||
rows.push(
|
||||
<tr key={mk.id}>
|
||||
<td style={theme.textStyle}>{mk.id}</td>
|
||||
<td><button onClick={() => shared.upgradeMasterKey(comp, mk)} style={theme.buttonStyle}>Upgrade</button></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Master keys that need upgrading')}</h1>
|
||||
<p style={theme.textStyle}>{_('The following master keys use an out-dated encryption algorithm and it is recommended to upgrade them. The upgraded master key will still be able to decrypt and encrypt your data as usual.')}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
<th style={theme.textStyle}>{_('Upgrade')}</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderReencryptData() {
|
||||
if (!shim.isElectron()) return null;
|
||||
if (!this.props.shouldReencrypt) return null;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const buttonLabel = _('Re-encrypt data');
|
||||
|
||||
const intro = this.props.shouldReencrypt ? _('The default encryption method has been changed to a more secure one and it is recommended that you apply it to your data.') : _('You may use the tool below to re-encrypt your data, for example if you know that some of your notes are encrypted with an obsolete encryption method.');
|
||||
|
||||
let t = `${intro}\n\n${_('In order to do so, your entire data set will have to be encrypted and synchronised, so it is best to run it overnight.\n\nTo start, please follow these instructions:\n\n1. Synchronise all your devices.\n2. Click "%s".\n3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n4. Once sync is done on this device, sync all your other devices and let it run to completion.\n\nImportant: you only need to run this ONCE on one device.', buttonLabel)}`;
|
||||
|
||||
t = t.replace(/\n\n/g, '</p><p>');
|
||||
t = t.replace(/\n/g, '<br>');
|
||||
t = `<p>${t}</p>`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Re-encryption')}</h1>
|
||||
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
|
||||
<span style={{ marginRight: 10 }}>
|
||||
<button onClick={() => shared.reencryptData()} style={theme.buttonStyle}>{buttonLabel}</button>
|
||||
</span>
|
||||
|
||||
{ !this.props.shouldReencrypt ? null : <button onClick={() => shared.dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderMasterKeySection(masterKeys: MasterKeyEntity[], isEnabledMasterKeys: boolean) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const mkComps = [];
|
||||
const showTable = isEnabledMasterKeys || this.state.showDisabledMasterKeys;
|
||||
const latestMasterKey = MasterKey.latest();
|
||||
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
mkComps.push(this.renderMasterKey(mk, isEnabledMasterKeys && latestMasterKey && mk.id === latestMasterKey.id));
|
||||
}
|
||||
|
||||
const headerComp = isEnabledMasterKeys ? <h1 style={theme.h1Style}>{_('Master Keys')}</h1> : <a onClick={() => shared.toggleShowDisabledMasterKeys(this) } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled master keys') : _('Show disabled master keys')}</a>;
|
||||
const infoComp = isEnabledMasterKeys ? <p style={theme.textStyle}>{'Note: Only one master key is going to be used for encryption (the one marked as "active"). Any of the keys might be used for decryption, depending on how the notes or notebooks were originally encrypted.'}</p> : null;
|
||||
const tableComp = !showTable ? null : (
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('Active')}</th>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
<th style={theme.textStyle}>{_('Date')}</th>
|
||||
<th style={theme.textStyle}>{_('Password')}</th>
|
||||
<th style={theme.textStyle}>{_('Valid')}</th>
|
||||
<th style={theme.textStyle}>{_('Actions')}</th>
|
||||
</tr>
|
||||
{mkComps}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
if (mkComps.length) {
|
||||
return (
|
||||
<div>
|
||||
{headerComp}
|
||||
{tableComp}
|
||||
{infoComp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private renderMasterPassword() {
|
||||
// if (!this.props.encryptionEnabled && !this.props.masterKeys.length) return null;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const onMasterPasswordSave = async () => {
|
||||
shared.onMasterPasswordSave(this);
|
||||
|
||||
if (!(await shared.masterPasswordIsValid(this, this.state.masterPasswordInput))) {
|
||||
alert('Password is invalid. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// 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() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const masterKeys: MasterKeyEntity[] = this.props.masterKeys;
|
||||
|
||||
const containerStyle = Object.assign({}, theme.containerStyle, {
|
||||
padding: theme.configScreenPadding,
|
||||
overflow: 'auto',
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
});
|
||||
|
||||
const nonExistingMasterKeyIds = this.props.notLoadedMasterKeys.slice();
|
||||
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
const idx = nonExistingMasterKeyIds.indexOf(mk.id);
|
||||
if (idx >= 0) nonExistingMasterKeyIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
const onToggleButtonClick = async () => {
|
||||
const isEnabled = getEncryptionEnabled();
|
||||
let masterKey = getDefaultMasterKey();
|
||||
|
||||
// If the user has explicitly disabled the master key, we generate a
|
||||
// new one. Needed for one the password has been forgotten.
|
||||
if (!masterKey.enabled) masterKey = null;
|
||||
|
||||
let answer = null;
|
||||
if (isEnabled) {
|
||||
answer = await dialogs.confirm(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
|
||||
} else {
|
||||
const msg = shared.enableEncryptionConfirmationMessages(masterKey);
|
||||
answer = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
|
||||
}
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
try {
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), !isEnabled, masterKey, answer);
|
||||
} catch (error) {
|
||||
await dialogs.alert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const decryptedItemsInfo = <p style={theme.textStyle}>{shared.decryptedStatText(this)}</p>;
|
||||
const toggleButton = (
|
||||
<button
|
||||
style={theme.buttonStyle}
|
||||
onClick={() => {
|
||||
void onToggleButtonClick();
|
||||
}}
|
||||
>
|
||||
{this.props.encryptionEnabled ? _('Disable encryption') : _('Enable encryption')}
|
||||
</button>
|
||||
);
|
||||
|
||||
const needUpgradeSection = this.renderNeedUpgradeSection();
|
||||
const reencryptDataSection = this.renderReencryptData();
|
||||
|
||||
const enabledMasterKeySection = this.renderMasterKeySection(masterKeys.filter(mk => masterKeyEnabled(mk)), true);
|
||||
const disabledMasterKeySection = this.renderMasterKeySection(masterKeys.filter(mk => !masterKeyEnabled(mk)), false);
|
||||
|
||||
let nonExistingMasterKeySection = null;
|
||||
|
||||
if (nonExistingMasterKeyIds.length) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < nonExistingMasterKeyIds.length; i++) {
|
||||
const id = nonExistingMasterKeyIds[i];
|
||||
rows.push(
|
||||
<tr key={id}>
|
||||
<td style={theme.textStyle}>{id}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
nonExistingMasterKeySection = (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Missing Master Keys')}</h1>
|
||||
<p style={theme.textStyle}>{_('The master keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={containerStyle}>
|
||||
{
|
||||
<div className="alert alert-warning" style={{ backgroundColor: theme.warningBackgroundColor, paddingLeft: 10, paddingRight: 10, paddingTop: 2, paddingBottom: 2 }}>
|
||||
<p style={theme.textStyle}>
|
||||
<span>{_('For more information about End-To-End Encryption (E2EE) and advice on how to enable it please check the documentation:')}</span>{' '}
|
||||
<a
|
||||
onClick={() => {
|
||||
bridge().openExternal('https://joplinapp.org/e2ee/');
|
||||
}}
|
||||
href="#"
|
||||
style={theme.urlStyle}
|
||||
>
|
||||
https://joplinapp.org/e2ee/
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<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>
|
||||
{decryptedItemsInfo}
|
||||
{toggleButton}
|
||||
{needUpgradeSection}
|
||||
{this.props.shouldReencrypt ? reencryptDataSection : null}
|
||||
{enabledMasterKeySection}
|
||||
{disabledMasterKeySection}
|
||||
{nonExistingMasterKeySection}
|
||||
{!this.props.shouldReencrypt ? reencryptDataSection : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: State) => {
|
||||
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
|
||||
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
masterKeys: syncInfo.masterKeys,
|
||||
passwords: state.settings['encryption.passwordCache'],
|
||||
encryptionEnabled: syncInfo.e2ee,
|
||||
activeMasterKeyId: syncInfo.activeMasterKeyId,
|
||||
shouldReencrypt: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
|
||||
notLoadedMasterKeys: state.notLoadedMasterKeys,
|
||||
masterPassword: state.settings['encryption.masterPassword'],
|
||||
};
|
||||
};
|
||||
|
||||
const EncryptionConfigScreen = connect(mapStateToProps)(EncryptionConfigScreenComponent);
|
||||
|
||||
export default EncryptionConfigScreen;
|
||||
@@ -0,0 +1,366 @@
|
||||
const React = require('react');
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import dialogs from '../dialogs';
|
||||
import bridge from '../../services/bridge';
|
||||
import { decryptedStatText, dontReencryptData, enableEncryptionConfirmationMessages, onSavePasswordClick, onToggleEnabledClick, reencryptData, upgradeMasterKey, useInputMasterPassword, useInputPasswords, usePasswordChecker, useStats, useToggleShowDisabledMasterKeys } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
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 StyledInput from '../style/StyledInput';
|
||||
import Button, { ButtonLevel } from '../Button/Button';
|
||||
import styled from 'styled-components';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
|
||||
const MasterPasswordInput = styled(StyledInput)`
|
||||
min-width: 300px;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
themeId: any;
|
||||
masterKeys: MasterKeyEntity[];
|
||||
passwords: Record<string, string>;
|
||||
notLoadedMasterKeys: string[];
|
||||
encryptionEnabled: boolean;
|
||||
shouldReencrypt: boolean;
|
||||
activeMasterKeyId: string;
|
||||
masterPassword: string;
|
||||
}
|
||||
|
||||
const EncryptionConfigScreen = (props: Props) => {
|
||||
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
|
||||
|
||||
const theme: any = useMemo(() => {
|
||||
return themeStyle(props.themeId);
|
||||
}, [props.themeId]);
|
||||
|
||||
const stats = useStats();
|
||||
const { passwordChecks, masterPasswordKeys } = usePasswordChecker(props.masterKeys, props.activeMasterKeyId, props.masterPassword, props.passwords);
|
||||
const { showDisabledMasterKeys, toggleShowDisabledMasterKeys } = useToggleShowDisabledMasterKeys();
|
||||
|
||||
const onUpgradeMasterKey = useCallback((mk: MasterKeyEntity) => {
|
||||
void upgradeMasterKey(mk, passwordChecks, props.passwords);
|
||||
}, [passwordChecks, props.passwords]);
|
||||
|
||||
const renderNeedUpgradeSection = () => {
|
||||
if (!shim.isElectron()) return null;
|
||||
|
||||
const needUpgradeMasterKeys = EncryptionService.instance().masterKeysThatNeedUpgrading(props.masterKeys);
|
||||
if (!needUpgradeMasterKeys.length) return null;
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const rows = [];
|
||||
|
||||
for (const mk of needUpgradeMasterKeys) {
|
||||
rows.push(
|
||||
<tr key={mk.id}>
|
||||
<td style={theme.textStyle}>{mk.id}</td>
|
||||
<td><button onClick={() => onUpgradeMasterKey(mk)} style={theme.buttonStyle}>Upgrade</button></td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Master keys that need upgrading')}</h1>
|
||||
<p style={theme.textStyle}>{_('The following master keys use an out-dated encryption algorithm and it is recommended to upgrade them. The upgraded master key will still be able to decrypt and encrypt your data as usual.')}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
<th style={theme.textStyle}>{_('Upgrade')}</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReencryptData = () => {
|
||||
if (!shim.isElectron()) return null;
|
||||
if (!props.shouldReencrypt) return null;
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
const buttonLabel = _('Re-encrypt data');
|
||||
|
||||
const intro = props.shouldReencrypt ? _('The default encryption method has been changed to a more secure one and it is recommended that you apply it to your data.') : _('You may use the tool below to re-encrypt your data, for example if you know that some of your notes are encrypted with an obsolete encryption method.');
|
||||
|
||||
let t = `${intro}\n\n${_('In order to do so, your entire data set will have to be encrypted and synchronised, so it is best to run it overnight.\n\nTo start, please follow these instructions:\n\n1. Synchronise all your devices.\n2. Click "%s".\n3. Let it run to completion. While it runs, avoid changing any note on your other devices, to avoid conflicts.\n4. Once sync is done on this device, sync all your other devices and let it run to completion.\n\nImportant: you only need to run this ONCE on one device.', buttonLabel)}`;
|
||||
|
||||
t = t.replace(/\n\n/g, '</p><p>');
|
||||
t = t.replace(/\n/g, '<br>');
|
||||
t = `<p>${t}</p>`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Re-encryption')}</h1>
|
||||
<p style={theme.textStyle} dangerouslySetInnerHTML={{ __html: t }}></p>
|
||||
<span style={{ marginRight: 10 }}>
|
||||
<button onClick={() => void reencryptData()} style={theme.buttonStyle}>{buttonLabel}</button>
|
||||
</span>
|
||||
|
||||
{ !props.shouldReencrypt ? null : <button onClick={() => dontReencryptData()} style={theme.buttonStyle}>{_('Ignore')}</button> }
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMasterKey = (mk: MasterKeyEntity) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const passwordStyle = {
|
||||
color: theme.color,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
border: '1px solid',
|
||||
borderColor: theme.dividerColor,
|
||||
};
|
||||
|
||||
const password = inputPasswords[mk.id] ? inputPasswords[mk.id] : '';
|
||||
const isActive = props.activeMasterKeyId === mk.id;
|
||||
const activeIcon = isActive ? '✔' : '';
|
||||
const passwordOk = passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
|
||||
const renderPasswordInput = (masterKeyId: string) => {
|
||||
if (masterPasswordKeys[masterKeyId] || !passwordChecks['master']) {
|
||||
return (
|
||||
<td style={{ ...theme.textStyle, color: theme.colorFaded, fontStyle: 'italic' }}>
|
||||
({_('Master password')})
|
||||
</td>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<td style={theme.textStyle}>
|
||||
<input type="password" style={passwordStyle} value={password} onChange={event => onInputPasswordChange(mk, event.target.value)} />{' '}
|
||||
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, props.passwords)}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr key={mk.id}>
|
||||
<td style={theme.textStyle}>{activeIcon}</td>
|
||||
<td style={theme.textStyle}>{mk.id}<br/>{_('Source: ')}{mk.source_application}</td>
|
||||
<td style={theme.textStyle}>{_('Created: ')}{time.formatMsToLocal(mk.created_time)}<br/>{_('Updated: ')}{time.formatMsToLocal(mk.updated_time)}</td>
|
||||
{renderPasswordInput(mk.id)}
|
||||
<td style={theme.textStyle}>{passwordOk}</td>
|
||||
<td style={theme.textStyle}>
|
||||
<button style={theme.buttonStyle} onClick={() => onToggleEnabledClick(mk)}>{masterKeyEnabled(mk) ? _('Disable') : _('Enable')}</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const renderMasterKeySection = (masterKeys: MasterKeyEntity[], isEnabledMasterKeys: boolean) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const mkComps = [];
|
||||
const showTable = isEnabledMasterKeys || showDisabledMasterKeys;
|
||||
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
mkComps.push(renderMasterKey(mk));
|
||||
}
|
||||
|
||||
const headerComp = isEnabledMasterKeys ? <h1 style={theme.h1Style}>{_('Master Keys')}</h1> : <a onClick={() => toggleShowDisabledMasterKeys() } style={{ ...theme.urlStyle, display: 'inline-block', marginBottom: 10 }} href="#">{showTable ? _('Hide disabled master keys') : _('Show disabled master keys')}</a>;
|
||||
const infoComp = isEnabledMasterKeys ? <p style={theme.textStyle}>{'Note: Only one master key is going to be used for encryption (the one marked as "active"). Any of the keys might be used for decryption, depending on how the notes or notebooks were originally encrypted.'}</p> : null;
|
||||
const tableComp = !showTable ? null : (
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('Active')}</th>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
<th style={theme.textStyle}>{_('Date')}</th>
|
||||
<th style={theme.textStyle}>{_('Password')}</th>
|
||||
<th style={theme.textStyle}>{_('Valid')}</th>
|
||||
<th style={theme.textStyle}>{_('Actions')}</th>
|
||||
</tr>
|
||||
{mkComps}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
if (mkComps.length) {
|
||||
return (
|
||||
<div>
|
||||
{headerComp}
|
||||
{tableComp}
|
||||
{infoComp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const { inputMasterPassword, onMasterPasswordSave, onMasterPasswordChange } = useInputMasterPassword(props.masterKeys, props.activeMasterKeyId);
|
||||
|
||||
const renderMasterPassword = () => {
|
||||
if (!props.encryptionEnabled && !props.masterKeys.length) return null;
|
||||
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
if (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={inputMasterPassword} onChange={(event: any) => onMasterPasswordChange(event.target.value)} />{' '}
|
||||
<Button ml="10px" level={ButtonLevel.Secondary} onClick={onMasterPasswordSave} title={_('Save')} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const containerStyle = Object.assign({}, theme.containerStyle, {
|
||||
padding: theme.configScreenPadding,
|
||||
overflow: 'auto',
|
||||
backgroundColor: theme.backgroundColor3,
|
||||
});
|
||||
|
||||
const nonExistingMasterKeyIds = props.notLoadedMasterKeys.slice();
|
||||
|
||||
for (let i = 0; i < props.masterKeys.length; i++) {
|
||||
const mk = props.masterKeys[i];
|
||||
const idx = nonExistingMasterKeyIds.indexOf(mk.id);
|
||||
if (idx >= 0) nonExistingMasterKeyIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
const onToggleButtonClick = async () => {
|
||||
const isEnabled = getEncryptionEnabled();
|
||||
const masterKey = getDefaultMasterKey();
|
||||
|
||||
let answer = null;
|
||||
if (isEnabled) {
|
||||
answer = await dialogs.confirm(_('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
|
||||
} else {
|
||||
const msg = enableEncryptionConfirmationMessages(masterKey);
|
||||
answer = await dialogs.prompt(msg.join('\n\n'), '', '', { type: 'password' });
|
||||
}
|
||||
|
||||
if (!answer) return;
|
||||
|
||||
try {
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), !isEnabled, masterKey, answer);
|
||||
} catch (error) {
|
||||
await dialogs.alert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const decryptedItemsInfo = <p style={theme.textStyle}>{decryptedStatText(stats)}</p>;
|
||||
const toggleButton = (
|
||||
<button
|
||||
style={theme.buttonStyle}
|
||||
onClick={() => {
|
||||
void onToggleButtonClick();
|
||||
}}
|
||||
>
|
||||
{props.encryptionEnabled ? _('Disable encryption') : _('Enable encryption')}
|
||||
</button>
|
||||
);
|
||||
|
||||
const needUpgradeSection = renderNeedUpgradeSection();
|
||||
const reencryptDataSection = renderReencryptData();
|
||||
|
||||
const enabledMasterKeySection = renderMasterKeySection(props.masterKeys.filter(mk => masterKeyEnabled(mk)), true);
|
||||
const disabledMasterKeySection = renderMasterKeySection(props.masterKeys.filter(mk => !masterKeyEnabled(mk)), false);
|
||||
|
||||
let nonExistingMasterKeySection = null;
|
||||
|
||||
if (nonExistingMasterKeyIds.length) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < nonExistingMasterKeyIds.length; i++) {
|
||||
const id = nonExistingMasterKeyIds[i];
|
||||
rows.push(
|
||||
<tr key={id}>
|
||||
<td style={theme.textStyle}>{id}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
nonExistingMasterKeySection = (
|
||||
<div>
|
||||
<h1 style={theme.h1Style}>{_('Missing Master Keys')}</h1>
|
||||
<p style={theme.textStyle}>{_('The master keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</p>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style={theme.textStyle}>{_('ID')}</th>
|
||||
</tr>
|
||||
{rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={containerStyle}>
|
||||
{
|
||||
<div className="alert alert-warning" style={{ backgroundColor: theme.warningBackgroundColor, paddingLeft: 10, paddingRight: 10, paddingTop: 2, paddingBottom: 2 }}>
|
||||
<p style={theme.textStyle}>
|
||||
<span>{_('For more information about End-To-End Encryption (E2EE) and advice on how to enable it please check the documentation:')}</span>{' '}
|
||||
<a
|
||||
onClick={() => {
|
||||
bridge().openExternal('https://joplinapp.org/e2ee/');
|
||||
}}
|
||||
href="#"
|
||||
style={theme.urlStyle}
|
||||
>
|
||||
https://joplinapp.org/e2ee/
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<h1 style={theme.h1Style}>{_('Status')}</h1>
|
||||
<p style={theme.textStyle}>
|
||||
{_('Encryption is:')} <strong>{props.encryptionEnabled ? _('Enabled') : _('Disabled')}</strong>
|
||||
</p>
|
||||
{renderMasterPassword()}
|
||||
{decryptedItemsInfo}
|
||||
{toggleButton}
|
||||
{needUpgradeSection}
|
||||
{props.shouldReencrypt ? reencryptDataSection : null}
|
||||
{enabledMasterKeySection}
|
||||
{disabledMasterKeySection}
|
||||
{nonExistingMasterKeySection}
|
||||
{!props.shouldReencrypt ? reencryptDataSection : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
|
||||
|
||||
return {
|
||||
themeId: state.settings.theme,
|
||||
masterKeys: syncInfo.masterKeys,
|
||||
passwords: state.settings['encryption.passwordCache'],
|
||||
encryptionEnabled: syncInfo.e2ee,
|
||||
activeMasterKeyId: syncInfo.activeMasterKeyId,
|
||||
shouldReencrypt: state.settings['encryption.shouldReencrypt'] >= Setting.SHOULD_REENCRYPT_YES,
|
||||
notLoadedMasterKeys: state.notLoadedMasterKeys,
|
||||
masterPassword: state.settings['encryption.masterPassword'],
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(EncryptionConfigScreen);
|
||||
@@ -37,7 +37,6 @@ 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');
|
||||
@@ -546,8 +545,8 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
bridge().restart();
|
||||
};
|
||||
|
||||
const onInvitationRespond = async (shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, masterKey, accept);
|
||||
const onInvitationRespond = async (shareUserId: string, accept: boolean) => {
|
||||
await ShareService.instance().respondInvitation(shareUserId, accept);
|
||||
await ShareService.instance().refreshShareInvitations();
|
||||
void reg.scheduleSync(1000);
|
||||
};
|
||||
@@ -594,9 +593,9 @@ class MainScreenComponent extends React.Component<Props, State> {
|
||||
msg = this.renderNotificationMessage(
|
||||
_('%s (%s) would like to share a notebook with you.', sharer.full_name, sharer.email),
|
||||
_('Accept'),
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, true),
|
||||
() => onInvitationRespond(invitation.id, true),
|
||||
_('Reject'),
|
||||
() => onInvitationRespond(invitation.id, invitation.master_key, false)
|
||||
() => onInvitationRespond(invitation.id, false)
|
||||
);
|
||||
} else if (this.props.hasDisabledSyncItems) {
|
||||
msg = this.renderNotificationMessage(
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
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,7 +752,6 @@ 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,7 +20,6 @@ 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');
|
||||
@@ -62,12 +61,6 @@ 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, share.master_key_id, recipientEmail);
|
||||
await ShareService.instance().addShareRecipient(share.id, recipientEmail);
|
||||
await Promise.all([
|
||||
ShareService.instance().refreshShares(),
|
||||
ShareService.instance().refreshShareUsers(share.id),
|
||||
|
||||
@@ -49,6 +49,5 @@ export default function() {
|
||||
'showShareFolderDialog',
|
||||
'gotoAnything',
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
# 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 )"
|
||||
@@ -55,21 +50,12 @@ 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,87 +143,4 @@ 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);
|
||||
}
|
||||
@@ -2,68 +2,56 @@ const React = require('react');
|
||||
const { TextInput, TouchableOpacity, Linking, View, StyleSheet, Text, Button, ScrollView } = require('react-native');
|
||||
const { connect } = require('react-redux');
|
||||
const { ScreenHeader } = require('../screen-header.js');
|
||||
const { BaseScreenComponent } = require('../base-screen.js');
|
||||
const { themeStyle } = require('../global-style.js');
|
||||
const DialogBox = require('react-native-dialogbox').default;
|
||||
const { dialogs } = require('../../utils/dialogs.js');
|
||||
import EncryptionService from '@joplin/lib/services/e2ee/EncryptionService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import time from '@joplin/lib/time';
|
||||
import shared from '@joplin/lib/components/shared/encryption-config-shared';
|
||||
import { decryptedStatText, enableEncryptionConfirmationMessages, onSavePasswordClick, useInputMasterPassword, useInputPasswords, usePasswordChecker, useStats } from '@joplin/lib/components/EncryptionConfigScreen/utils';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import { State } from '@joplin/lib/reducer';
|
||||
import { SyncInfo } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import { getDefaultMasterKey, setupAndDisableEncryption, toggleAndSetupEncryption } from '@joplin/lib/services/e2ee/utils';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface Props {}
|
||||
interface Props {
|
||||
themeId: any;
|
||||
masterKeys: MasterKeyEntity[];
|
||||
passwords: Record<string, string>;
|
||||
notLoadedMasterKeys: string[];
|
||||
encryptionEnabled: boolean;
|
||||
shouldReencrypt: boolean;
|
||||
activeMasterKeyId: string;
|
||||
masterPassword: string;
|
||||
}
|
||||
|
||||
class EncryptionConfigScreenComponent extends BaseScreenComponent<Props> {
|
||||
static navigationOptions(): any {
|
||||
return { header: null };
|
||||
}
|
||||
const EncryptionConfigScreen = (props: Props) => {
|
||||
const [passwordPromptShow, setPasswordPromptShow] = useState(false);
|
||||
const [passwordPromptAnswer, setPasswordPromptAnswer] = useState('');
|
||||
const [passwordPromptConfirmAnswer, setPasswordPromptConfirmAnswer] = useState('');
|
||||
const stats = useStats();
|
||||
const { passwordChecks, masterPasswordKeys } = usePasswordChecker(props.masterKeys, props.activeMasterKeyId, props.masterPassword, props.passwords);
|
||||
const { inputPasswords, onInputPasswordChange } = useInputPasswords(props.passwords);
|
||||
const { inputMasterPassword, onMasterPasswordSave, onMasterPasswordChange } = useInputMasterPassword(props.masterKeys, props.activeMasterKeyId);
|
||||
const dialogBoxRef = useRef(null);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const mkComps = [];
|
||||
|
||||
this.state = {
|
||||
passwordPromptShow: false,
|
||||
passwordPromptAnswer: '',
|
||||
passwordPromptConfirmAnswer: '',
|
||||
const nonExistingMasterKeyIds = props.notLoadedMasterKeys.slice();
|
||||
|
||||
const theme: any = useMemo(() => {
|
||||
return themeStyle(props.themeId);
|
||||
}, [props.themeId]);
|
||||
|
||||
const rootStyle = useMemo(() => {
|
||||
return {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundColor,
|
||||
};
|
||||
}, [theme]);
|
||||
|
||||
shared.initialize(this, props);
|
||||
|
||||
this.styles_ = {};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isMounted_ = false;
|
||||
}
|
||||
|
||||
async refreshStats() {
|
||||
return shared.refreshStats(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.isMounted_ = true;
|
||||
shared.componentDidMount(this);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
shared.componentDidUpdate(this, prevProps);
|
||||
}
|
||||
|
||||
async checkPasswords() {
|
||||
return shared.checkPasswords(this);
|
||||
}
|
||||
|
||||
styles() {
|
||||
const themeId = this.props.themeId;
|
||||
const theme = themeStyle(themeId);
|
||||
|
||||
if (this.styles_[themeId]) return this.styles_[themeId];
|
||||
this.styles_ = {};
|
||||
|
||||
const styles = useMemo(() => {
|
||||
const styles = {
|
||||
titleText: {
|
||||
flex: 1,
|
||||
@@ -93,39 +81,32 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent<Props> {
|
||||
},
|
||||
};
|
||||
|
||||
this.styles_[themeId] = StyleSheet.create(styles);
|
||||
return this.styles_[themeId];
|
||||
}
|
||||
return StyleSheet.create(styles);
|
||||
}, [theme]);
|
||||
|
||||
renderMasterKey(_num: number, mk: MasterKeyEntity) {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const decryptedItemsInfo = props.encryptionEnabled ? <Text style={styles.normalText}>{decryptedStatText(stats)}</Text> : null;
|
||||
|
||||
const onSaveClick = () => {
|
||||
return shared.onSavePasswordClick(this, mk);
|
||||
};
|
||||
const renderMasterKey = (_num: number, mk: MasterKeyEntity) => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const onPasswordChange = (text: string) => {
|
||||
return shared.onPasswordChange(this, mk, text);
|
||||
};
|
||||
|
||||
const password = this.state.passwords[mk.id] ? this.state.passwords[mk.id] : '';
|
||||
const passwordOk = this.state.passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
const password = inputPasswords[mk.id] ? inputPasswords[mk.id] : '';
|
||||
const passwordOk = passwordChecks[mk.id] === true ? '✔' : '❌';
|
||||
|
||||
const inputStyle: any = { flex: 1, marginRight: 10, color: theme.color };
|
||||
inputStyle.borderBottomWidth = 1;
|
||||
inputStyle.borderBottomColor = theme.dividerColor;
|
||||
|
||||
const renderPasswordInput = (masterKeyId: string) => {
|
||||
if (this.state.masterPasswordKeys[masterKeyId] || !this.state.passwordChecks['master']) {
|
||||
if (masterPasswordKeys[masterKeyId] || !passwordChecks['master']) {
|
||||
return (
|
||||
<Text style={{ ...this.styles().normalText, color: theme.colorFaded, fontStyle: 'italic' }}>({_('Master password')})</Text>
|
||||
<Text style={{ ...styles.normalText, color: theme.colorFaded, fontStyle: 'italic' }}>({_('Master password')})</Text>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={{ flex: 1, flexDirection: 'row', alignItems: 'center' }}>
|
||||
<TextInput selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} secureTextEntry={true} value={password} onChangeText={(text: string) => onPasswordChange(text)} style={inputStyle}></TextInput>
|
||||
<TextInput selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} secureTextEntry={true} value={password} onChangeText={(text: string) => onInputPasswordChange(mk, text)} style={inputStyle}></TextInput>
|
||||
<Text style={{ fontSize: theme.fontSize, marginRight: 10, color: theme.color }}>{passwordOk}</Text>
|
||||
<Button title={_('Save')} onPress={() => onSaveClick()}></Button>
|
||||
<Button title={_('Save')} onPress={() => onSavePasswordClick(mk, props.passwords)}></Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -133,69 +114,65 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent<Props> {
|
||||
|
||||
return (
|
||||
<View key={mk.id}>
|
||||
<Text style={this.styles().titleText}>{_('Master Key %s', mk.id.substr(0, 6))}</Text>
|
||||
<Text style={this.styles().normalText}>{_('Created: %s', time.formatMsToLocal(mk.created_time))}</Text>
|
||||
<Text style={styles.titleText}>{_('Master Key %s', mk.id.substr(0, 6))}</Text>
|
||||
<Text style={styles.normalText}>{_('Created: %s', time.formatMsToLocal(mk.created_time))}</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={{ flex: 0, fontSize: theme.fontSize, marginRight: 10, color: theme.color }}>{_('Password:')}</Text>
|
||||
{renderPasswordInput(mk.id)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
passwordPromptComponent() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
let masterKey = getDefaultMasterKey();
|
||||
|
||||
// If the user has explicitly disabled the master key, we generate a
|
||||
// new one. Needed for one the password has been forgotten.
|
||||
if (!masterKey.enabled) masterKey = null;
|
||||
const renderPasswordPrompt = () => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
const masterKey = getDefaultMasterKey();
|
||||
|
||||
const onEnableClick = async () => {
|
||||
try {
|
||||
const password = this.state.passwordPromptAnswer;
|
||||
const password = passwordPromptAnswer;
|
||||
if (!password) throw new Error(_('Password cannot be empty'));
|
||||
const password2 = this.state.passwordPromptConfirmAnswer;
|
||||
const password2 = passwordPromptConfirmAnswer;
|
||||
if (!password2) throw new Error(_('Confirm password cannot be empty'));
|
||||
if (password !== password2) throw new Error(_('Passwords do not match!'));
|
||||
await toggleAndSetupEncryption(EncryptionService.instance(), true, masterKey, password);
|
||||
// await generateMasterKeyAndEnableEncryption(EncryptionService.instance(), password);
|
||||
this.setState({ passwordPromptShow: false });
|
||||
setPasswordPromptShow(false);
|
||||
} catch (error) {
|
||||
await dialogs.error(this, error.message);
|
||||
alert(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const messages = shared.enableEncryptionConfirmationMessages(masterKey);
|
||||
const messages = enableEncryptionConfirmationMessages(masterKey);
|
||||
|
||||
const messageComps = messages.map(msg => {
|
||||
const messageComps = messages.map((msg: string) => {
|
||||
return <Text key={msg} style={{ fontSize: theme.fontSize, color: theme.color, marginBottom: 10 }}>{msg}</Text>;
|
||||
});
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, borderColor: theme.dividerColor, borderWidth: 1, padding: 10, marginTop: 10, marginBottom: 10 }}>
|
||||
<View>{messageComps}</View>
|
||||
<Text style={this.styles().normalText}>{_('Password:')}</Text>
|
||||
<Text style={styles.normalText}>{_('Password:')}</Text>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={this.styles().normalTextInput}
|
||||
style={styles.normalTextInput}
|
||||
secureTextEntry={true}
|
||||
value={this.state.passwordPromptAnswer}
|
||||
value={passwordPromptAnswer}
|
||||
onChangeText={(text: string) => {
|
||||
this.setState({ passwordPromptAnswer: text });
|
||||
setPasswordPromptAnswer(text);
|
||||
}}
|
||||
></TextInput>
|
||||
|
||||
<Text style={this.styles().normalText}>{_('Confirm password:')}</Text>
|
||||
<Text style={styles.normalText}>{_('Confirm password:')}</Text>
|
||||
<TextInput
|
||||
selectionColor={theme.textSelectionColor}
|
||||
keyboardAppearance={theme.keyboardAppearance}
|
||||
style={this.styles().normalTextInput}
|
||||
style={styles.normalTextInput}
|
||||
secureTextEntry={true}
|
||||
value={this.state.passwordPromptConfirmAnswer}
|
||||
value={passwordPromptConfirmAnswer}
|
||||
onChangeText={(text: string) => {
|
||||
this.setState({ passwordPromptConfirmAnswer: text });
|
||||
setPasswordPromptConfirmAnswer(text);
|
||||
}}
|
||||
></TextInput>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
@@ -211,156 +188,132 @@ class EncryptionConfigScreenComponent extends BaseScreenComponent<Props> {
|
||||
<Button
|
||||
title={_('Cancel')}
|
||||
onPress={() => {
|
||||
this.setState({ passwordPromptShow: false });
|
||||
setPasswordPromptShow(false);
|
||||
}}
|
||||
></Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private renderMasterPassword() {
|
||||
if (!this.props.encryptionEnabled && !this.props.masterKeys.length) return null;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
|
||||
const onMasterPasswordSave = async () => {
|
||||
shared.onMasterPasswordSave(this);
|
||||
|
||||
if (!(await shared.masterPasswordIsValid(this, this.state.masterPasswordInput))) {
|
||||
alert('Password is invalid. Please try again.');
|
||||
}
|
||||
};
|
||||
const renderMasterPassword = () => {
|
||||
if (!props.encryptionEnabled && !props.masterKeys.length) return null;
|
||||
|
||||
const inputStyle: any = { flex: 1, marginRight: 10, color: theme.color };
|
||||
inputStyle.borderBottomWidth = 1;
|
||||
inputStyle.borderBottomColor = theme.dividerColor;
|
||||
|
||||
if (this.state.passwordChecks['master']) {
|
||||
if (passwordChecks['master']) {
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={{ ...this.styles().normalText, flex: 0, marginRight: 5 }}>{_('Master password:')}</Text>
|
||||
<Text style={{ ...this.styles().normalText, fontWeight: 'bold' }}>{_('Loaded')}</Text>
|
||||
<Text style={{ ...styles.normalText, flex: 0, marginRight: 5 }}>{_('Master password:')}</Text>
|
||||
<Text style={{ ...styles.normalText, fontWeight: 'bold' }}>{_('Loaded')}</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={{ display: 'flex', flexDirection: 'column', marginTop: 10 }}>
|
||||
<Text style={this.styles().normalText}>{'The master password is not set or is invalid. Please type it below:'}</Text>
|
||||
<Text style={styles.normalText}>{'The master password is not set or is invalid. Please type it below:'}</Text>
|
||||
<View style={{ display: 'flex', flexDirection: 'row', marginTop: 10 }}>
|
||||
<TextInput selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} secureTextEntry={true} value={this.state.masterPasswordInput} onChangeText={(text: string) => shared.onMasterPasswordChange(this, text)} style={inputStyle}></TextInput>
|
||||
<TextInput selectionColor={theme.textSelectionColor} keyboardAppearance={theme.keyboardAppearance} secureTextEntry={true} value={inputMasterPassword} onChangeText={(text: string) => onMasterPasswordChange(text)} style={inputStyle}></TextInput>
|
||||
<Button onPress={onMasterPasswordSave} title={_('Save')} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
for (let i = 0; i < props.masterKeys.length; i++) {
|
||||
const mk = props.masterKeys[i];
|
||||
mkComps.push(renderMasterKey(i + 1, mk));
|
||||
|
||||
const idx = nonExistingMasterKeyIds.indexOf(mk.id);
|
||||
if (idx >= 0) nonExistingMasterKeyIds.splice(idx, 1);
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const masterKeys = this.props.masterKeys;
|
||||
const decryptedItemsInfo = this.props.encryptionEnabled ? <Text style={this.styles().normalText}>{shared.decryptedStatText(this)}</Text> : null;
|
||||
const onToggleButtonClick = async () => {
|
||||
if (props.encryptionEnabled) {
|
||||
const ok = await dialogs.confirmRef(dialogBoxRef.current, _('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
|
||||
if (!ok) return;
|
||||
|
||||
const mkComps = [];
|
||||
|
||||
const nonExistingMasterKeyIds = this.props.notLoadedMasterKeys.slice();
|
||||
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
mkComps.push(this.renderMasterKey(i + 1, mk));
|
||||
|
||||
const idx = nonExistingMasterKeyIds.indexOf(mk.id);
|
||||
if (idx >= 0) nonExistingMasterKeyIds.splice(idx, 1);
|
||||
try {
|
||||
await setupAndDisableEncryption(EncryptionService.instance());
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
}
|
||||
} else {
|
||||
setPasswordPromptShow(true);
|
||||
setPasswordPromptAnswer('');
|
||||
setPasswordPromptConfirmAnswer('');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleButtonClick = async () => {
|
||||
if (this.props.encryptionEnabled) {
|
||||
const ok = await dialogs.confirm(this, _('Disabling encryption means *all* your notes and attachments are going to be re-synchronised and sent unencrypted to the sync target. Do you wish to continue?'));
|
||||
if (!ok) return;
|
||||
let nonExistingMasterKeySection = null;
|
||||
|
||||
try {
|
||||
await setupAndDisableEncryption(EncryptionService.instance());
|
||||
} catch (error) {
|
||||
await dialogs.error(this, error.message);
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
passwordPromptShow: true,
|
||||
passwordPromptAnswer: '',
|
||||
passwordPromptConfirmAnswer: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let nonExistingMasterKeySection = null;
|
||||
|
||||
if (nonExistingMasterKeyIds.length) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < nonExistingMasterKeyIds.length; i++) {
|
||||
const id = nonExistingMasterKeyIds[i];
|
||||
rows.push(
|
||||
<Text style={this.styles().normalText} key={id}>
|
||||
{id}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
nonExistingMasterKeySection = (
|
||||
<View>
|
||||
<Text style={this.styles().titleText}>{_('Missing Master Keys')}</Text>
|
||||
<Text style={this.styles().normalText}>{_('The master keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</Text>
|
||||
<View style={{ marginTop: 10 }}>{rows}</View>
|
||||
</View>
|
||||
if (nonExistingMasterKeyIds.length) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < nonExistingMasterKeyIds.length; i++) {
|
||||
const id = nonExistingMasterKeyIds[i];
|
||||
rows.push(
|
||||
<Text style={styles.normalText} key={id}>
|
||||
{id}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const passwordPromptComp = this.state.passwordPromptShow ? this.passwordPromptComponent() : null;
|
||||
const toggleButton = !this.state.passwordPromptShow ? (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Button title={this.props.encryptionEnabled ? _('Disable encryption') : _('Enable encryption')} onPress={() => onToggleButtonClick()}></Button>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View style={this.rootStyle(this.props.themeId).root}>
|
||||
<ScreenHeader title={_('Encryption Config')} />
|
||||
<ScrollView style={this.styles().container}>
|
||||
{
|
||||
<View style={{ backgroundColor: theme.warningBackgroundColor, paddingTop: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10 }}>
|
||||
<Text>{_('For more information about End-To-End Encryption (E2EE) and advice on how to enable it please check the documentation:')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL('https://joplinapp.org/e2ee/');
|
||||
}}
|
||||
>
|
||||
<Text>https://joplinapp.org/e2ee/</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
|
||||
<Text style={this.styles().titleText}>{_('Status')}</Text>
|
||||
<Text style={this.styles().normalText}>{_('Encryption is: %s', this.props.encryptionEnabled ? _('Enabled') : _('Disabled'))}</Text>
|
||||
{decryptedItemsInfo}
|
||||
{this.renderMasterPassword()}
|
||||
{toggleButton}
|
||||
{passwordPromptComp}
|
||||
{mkComps}
|
||||
{nonExistingMasterKeySection}
|
||||
<View style={{ flex: 1, height: 20 }}></View>
|
||||
</ScrollView>
|
||||
<DialogBox
|
||||
ref={(dialogbox: any) => {
|
||||
this.dialogbox = dialogbox;
|
||||
}}
|
||||
/>
|
||||
nonExistingMasterKeySection = (
|
||||
<View>
|
||||
<Text style={styles.titleText}>{_('Missing Master Keys')}</Text>
|
||||
<Text style={styles.normalText}>{_('The master keys with these IDs are used to encrypt some of your items, however the application does not currently have access to them. It is likely they will eventually be downloaded via synchronisation.')}</Text>
|
||||
<View style={{ marginTop: 10 }}>{rows}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const EncryptionConfigScreen = connect((state: State) => {
|
||||
const passwordPromptComp = passwordPromptShow ? renderPasswordPrompt() : null;
|
||||
const toggleButton = !passwordPromptShow ? (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Button title={props.encryptionEnabled ? _('Disable encryption') : _('Enable encryption')} onPress={() => onToggleButtonClick()}></Button>
|
||||
</View>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<View style={rootStyle}>
|
||||
<ScreenHeader title={_('Encryption Config')} />
|
||||
<ScrollView style={styles.container}>
|
||||
{
|
||||
<View style={{ backgroundColor: theme.warningBackgroundColor, paddingTop: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10 }}>
|
||||
<Text>{_('For more information about End-To-End Encryption (E2EE) and advice on how to enable it please check the documentation:')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
Linking.openURL('https://joplinapp.org/e2ee/');
|
||||
}}
|
||||
>
|
||||
<Text>https://joplinapp.org/e2ee/</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
}
|
||||
|
||||
<Text style={styles.titleText}>{_('Status')}</Text>
|
||||
<Text style={styles.normalText}>{_('Encryption is: %s', props.encryptionEnabled ? _('Enabled') : _('Disabled'))}</Text>
|
||||
{decryptedItemsInfo}
|
||||
{renderMasterPassword()}
|
||||
{toggleButton}
|
||||
{passwordPromptComp}
|
||||
{mkComps}
|
||||
{nonExistingMasterKeySection}
|
||||
<View style={{ flex: 1, height: 20 }}></View>
|
||||
</ScrollView>
|
||||
<DialogBox ref={dialogBoxRef}/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default connect((state: State) => {
|
||||
const syncInfo = new SyncInfo(state.settings['syncInfoCache']);
|
||||
|
||||
return {
|
||||
@@ -372,6 +325,4 @@ const EncryptionConfigScreen = connect((state: State) => {
|
||||
notLoadedMasterKeys: state.notLoadedMasterKeys,
|
||||
masterPassword: state.settings['encryption.masterPassword'],
|
||||
};
|
||||
})(EncryptionConfigScreenComponent);
|
||||
|
||||
export default EncryptionConfigScreen;
|
||||
})(EncryptionConfigScreen);
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"postinstall": "jetify && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "^2.2.0",
|
||||
"@joplin/renderer": "^2.2.0",
|
||||
"@joplin/lib": "~2.4",
|
||||
"@joplin/renderer": "~2.4",
|
||||
"@react-native-community/clipboard": "^1.5.0",
|
||||
"@react-native-community/datetimepicker": "^3.0.3",
|
||||
"@react-native-community/geolocation": "^2.0.2",
|
||||
@@ -69,7 +69,7 @@
|
||||
"@codemirror/lang-markdown": "^0.18.4",
|
||||
"@codemirror/state": "^0.18.7",
|
||||
"@codemirror/view": "^0.18.19",
|
||||
"@joplin/tools": "^1.0.9",
|
||||
"@joplin/tools": "~2.4",
|
||||
"@rollup/plugin-node-resolve": "^13.0.0",
|
||||
"@rollup/plugin-typescript": "^8.2.1",
|
||||
"@types/node": "^14.14.6",
|
||||
|
||||
@@ -552,7 +552,7 @@ async function initialize(dispatch: Function) {
|
||||
// / E2EE SETUP
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
await ShareService.instance().initialize(store, EncryptionService.instance());
|
||||
await ShareService.instance().initialize(store);
|
||||
|
||||
reg.logger().info('Loading folders...');
|
||||
|
||||
|
||||
@@ -7,14 +7,13 @@ const { Keyboard } = require('react-native');
|
||||
|
||||
const dialogs = {};
|
||||
|
||||
dialogs.confirm = (parentComponent, message) => {
|
||||
if (!parentComponent) throw new Error('parentComponent is required');
|
||||
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
dialogs.confirmRef = (ref, message) => {
|
||||
if (!ref) throw new Error('ref is required');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Keyboard.dismiss();
|
||||
|
||||
parentComponent.dialogbox.confirm({
|
||||
ref.confirm({
|
||||
content: message,
|
||||
|
||||
ok: {
|
||||
@@ -32,6 +31,33 @@ dialogs.confirm = (parentComponent, message) => {
|
||||
});
|
||||
};
|
||||
|
||||
dialogs.confirm = (parentComponent, message) => {
|
||||
if (!parentComponent) throw new Error('parentComponent is required');
|
||||
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
return dialogs.confirmRef(parentComponent.dialogBox, message);
|
||||
|
||||
// return new Promise((resolve) => {
|
||||
// Keyboard.dismiss();
|
||||
|
||||
// parentComponent.dialogbox.confirm({
|
||||
// content: message,
|
||||
|
||||
// ok: {
|
||||
// callback: () => {
|
||||
// resolve(true);
|
||||
// },
|
||||
// },
|
||||
|
||||
// cancel: {
|
||||
// callback: () => {
|
||||
// resolve(false);
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
};
|
||||
|
||||
dialogs.pop = (parentComponent, message, buttons, options = null) => {
|
||||
if (!parentComponent) throw new Error('parentComponent is required');
|
||||
if (!('dialogbox' in parentComponent)) throw new Error('A "dialogbox" component must be defined on the parent component!');
|
||||
|
||||
@@ -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(), EncryptionService.instance());
|
||||
ShareService.instance().initialize(this.store());
|
||||
}
|
||||
|
||||
deinitRedux() {
|
||||
|
||||
@@ -900,11 +900,6 @@ 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.5.0';
|
||||
headers['X-API-MIN-VERSION'] = '2.1.4';
|
||||
|
||||
const fetchOptions: any = {};
|
||||
fetchOptions.headers = headers;
|
||||
|
||||
@@ -24,7 +24,6 @@ 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');
|
||||
|
||||
@@ -421,52 +420,49 @@ export default class Synchronizer {
|
||||
this.api().setTempDirName(Dirnames.Temp);
|
||||
|
||||
try {
|
||||
let remoteInfo = await fetchSyncInfo(this.api());
|
||||
const 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'));
|
||||
remoteInfo = await fetchSyncInfo(this.api());
|
||||
}
|
||||
|
||||
logger.info('Sync target is already setup - checking it...');
|
||||
|
||||
await this.migrationHandler().checkCanSync(remoteInfo);
|
||||
|
||||
const localInfo = await localSyncInfo();
|
||||
|
||||
logger.info('Sync target local info:', localInfo);
|
||||
|
||||
await setPpkIfNotExist(this.encryptionService(), localInfo, remoteInfo);
|
||||
|
||||
// console.info('LOCAL', localInfo);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
|
||||
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 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);
|
||||
logger.info('Sync target is already setup - checking it...');
|
||||
|
||||
await this.migrationHandler().checkCanSync(remoteInfo);
|
||||
|
||||
const localInfo = await localSyncInfo();
|
||||
|
||||
logger.info('Sync target local info:', localInfo);
|
||||
|
||||
// console.info('LOCAL', localInfo);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'outdatedSyncTarget') {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// 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,
|
||||
];
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
183
packages/lib/components/EncryptionConfigScreen/utils.ts
Normal file
183
packages/lib/components/EncryptionConfigScreen/utils.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import shim from '../../shim';
|
||||
import { _ } from '../../locale';
|
||||
import BaseItem, { EncryptedItemsStats } from '../../models/BaseItem';
|
||||
import useAsyncEffect, { AsyncEffectEvent } from '../../hooks/useAsyncEffect';
|
||||
import { MasterKeyEntity } from '../../services/e2ee/types';
|
||||
import time from '../../time';
|
||||
import { findMasterKeyPassword } from '../../services/e2ee/utils';
|
||||
import EncryptionService from '../../services/e2ee/EncryptionService';
|
||||
import { masterKeyEnabled, setMasterKeyEnabled } from '../../services/synchronizer/syncInfoUtils';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { reg } from '../../registry';
|
||||
import Setting from '../../models/Setting';
|
||||
const { useCallback, useEffect, useState } = shim.react();
|
||||
|
||||
type PasswordChecks = Record<string, boolean>;
|
||||
|
||||
export const useStats = () => {
|
||||
const [stats, setStats] = useState<EncryptedItemsStats>({ encrypted: null, total: null });
|
||||
const [statsUpdateTime, setStatsUpdateTime] = useState<number>(0);
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const r = await BaseItem.encryptedItemsStats();
|
||||
if (event.cancelled) return;
|
||||
setStats(r);
|
||||
}, [statsUpdateTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const iid = shim.setInterval(() => {
|
||||
setStatsUpdateTime(Date.now());
|
||||
}, 3000);
|
||||
|
||||
return () => {
|
||||
clearInterval(iid);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return stats;
|
||||
};
|
||||
|
||||
export const decryptedStatText = (stats: EncryptedItemsStats) => {
|
||||
const doneCount = stats.encrypted !== null ? stats.total - stats.encrypted : '-';
|
||||
const totalCount = stats.total !== null ? stats.total : '-';
|
||||
const result = _('Decrypted items: %s / %s', doneCount, totalCount);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const enableEncryptionConfirmationMessages = (masterKey: MasterKeyEntity) => {
|
||||
const msg = [_('Enabling encryption means *all* your notes and attachments are going to be re-synchronised and sent encrypted to the sync target. Do not lose the password as, for security purposes, this will be the *only* way to decrypt the data! To enable encryption, please enter your password below.')];
|
||||
if (masterKey) msg.push(_('Encryption will be enabled using the master key created on %s', time.unixMsToLocalDateTime(masterKey.created_time)));
|
||||
return msg;
|
||||
};
|
||||
|
||||
const masterPasswordIsValid = async (masterKeys: MasterKeyEntity[], activeMasterKeyId: string, masterPassword: string = null) => {
|
||||
const activeMasterKey = masterKeys.find((mk: MasterKeyEntity) => mk.id === activeMasterKeyId);
|
||||
masterPassword = masterPassword === null ? masterPassword : masterPassword;
|
||||
if (activeMasterKey && masterPassword) {
|
||||
return EncryptionService.instance().checkMasterKeyPassword(activeMasterKey, masterPassword);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const reencryptData = async () => {
|
||||
const ok = confirm(_('Please confirm that you would like to re-encrypt your complete database.'));
|
||||
if (!ok) return;
|
||||
|
||||
await BaseItem.forceSyncAll();
|
||||
void reg.waitForSyncFinishedThenSync();
|
||||
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
|
||||
alert(_('Your data is going to be re-encrypted and synced again.'));
|
||||
};
|
||||
|
||||
export const dontReencryptData = () => {
|
||||
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
|
||||
};
|
||||
|
||||
export const useToggleShowDisabledMasterKeys = () => {
|
||||
const [showDisabledMasterKeys, setShowDisabledMasterKeys] = useState<boolean>(false);
|
||||
|
||||
const toggleShowDisabledMasterKeys = () => {
|
||||
setShowDisabledMasterKeys((current) => !current);
|
||||
};
|
||||
|
||||
return { showDisabledMasterKeys, toggleShowDisabledMasterKeys };
|
||||
};
|
||||
|
||||
export const onToggleEnabledClick = (mk: MasterKeyEntity) => {
|
||||
setMasterKeyEnabled(mk.id, !masterKeyEnabled(mk));
|
||||
};
|
||||
|
||||
export const onSavePasswordClick = (mk: MasterKeyEntity, passwords: Record<string, string>) => {
|
||||
const password = passwords[mk.id];
|
||||
if (!password) {
|
||||
Setting.deleteObjectValue('encryption.passwordCache', mk.id);
|
||||
} else {
|
||||
Setting.setObjectValue('encryption.passwordCache', mk.id, password);
|
||||
}
|
||||
};
|
||||
|
||||
export const onMasterPasswordSave = (masterPasswordInput: string) => {
|
||||
Setting.setValue('encryption.masterPassword', masterPasswordInput);
|
||||
};
|
||||
|
||||
export const useInputMasterPassword = (masterKeys: MasterKeyEntity[], activeMasterKeyId: string) => {
|
||||
const [inputMasterPassword, setInputMasterPassword] = useState<string>('');
|
||||
|
||||
const onMasterPasswordSave = useCallback(async () => {
|
||||
Setting.setValue('encryption.masterPassword', inputMasterPassword);
|
||||
|
||||
if (!(await masterPasswordIsValid(masterKeys, activeMasterKeyId, inputMasterPassword))) {
|
||||
alert('Password is invalid. Please try again.');
|
||||
}
|
||||
}, [inputMasterPassword]);
|
||||
|
||||
const onMasterPasswordChange = useCallback((password: string) => {
|
||||
setInputMasterPassword(password);
|
||||
}, []);
|
||||
|
||||
return { inputMasterPassword, onMasterPasswordSave, onMasterPasswordChange };
|
||||
};
|
||||
|
||||
export const useInputPasswords = (propsPasswords: Record<string, string>) => {
|
||||
const [inputPasswords, setInputPasswords] = useState<Record<string, string>>(propsPasswords);
|
||||
|
||||
useEffect(() => {
|
||||
setInputPasswords(propsPasswords);
|
||||
}, [propsPasswords]);
|
||||
|
||||
const onInputPasswordChange = useCallback((mk: MasterKeyEntity, password: string) => {
|
||||
setInputPasswords(current => {
|
||||
return {
|
||||
...current,
|
||||
[mk.id]: password,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { inputPasswords, onInputPasswordChange };
|
||||
};
|
||||
|
||||
export const usePasswordChecker = (masterKeys: MasterKeyEntity[], activeMasterKeyId: string, masterPassword: string, passwords: Record<string, string>) => {
|
||||
const [passwordChecks, setPasswordChecks] = useState<PasswordChecks>({});
|
||||
const [masterPasswordKeys, setMasterPasswordKeys] = useState<PasswordChecks>({});
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
const newPasswordChecks: PasswordChecks = {};
|
||||
const newMasterPasswordKeys: PasswordChecks = {};
|
||||
|
||||
for (let i = 0; i < masterKeys.length; i++) {
|
||||
const mk = masterKeys[i];
|
||||
const password = await findMasterKeyPassword(EncryptionService.instance(), mk, passwords);
|
||||
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
|
||||
newPasswordChecks[mk.id] = ok;
|
||||
newMasterPasswordKeys[mk.id] = password === masterPassword;
|
||||
}
|
||||
|
||||
newPasswordChecks['master'] = await masterPasswordIsValid(masterKeys, activeMasterKeyId, masterPassword);
|
||||
|
||||
if (event.cancelled) return;
|
||||
|
||||
setPasswordChecks(newPasswordChecks);
|
||||
setMasterPasswordKeys(newMasterPasswordKeys);
|
||||
}, [masterKeys, masterPassword]);
|
||||
|
||||
return { passwordChecks, masterPasswordKeys };
|
||||
};
|
||||
|
||||
export const upgradeMasterKey = async (masterKey: MasterKeyEntity, passwordChecks: PasswordChecks, passwords: Record<string, string>): Promise<string> => {
|
||||
const passwordCheck = passwordChecks[masterKey.id];
|
||||
if (!passwordCheck) {
|
||||
return _('Please enter your password in the master key list below before upgrading the key.');
|
||||
}
|
||||
|
||||
try {
|
||||
const password = passwords[masterKey.id];
|
||||
const newMasterKey = await EncryptionService.instance().upgradeMasterKey(masterKey, password);
|
||||
await MasterKey.save(newMasterKey);
|
||||
void reg.waitForSyncFinishedThenSync();
|
||||
return _('The master key has been upgraded successfully!');
|
||||
} catch (error) {
|
||||
return _('Could not upgrade master key: %s', error.message);
|
||||
}
|
||||
};
|
||||
@@ -1,196 +0,0 @@
|
||||
import EncryptionService from '../../services/e2ee/EncryptionService';
|
||||
import { _ } from '../../locale';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import Setting from '../../models/Setting';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { reg } from '../../registry.js';
|
||||
import shim from '../../shim';
|
||||
import { MasterKeyEntity } from '../../services/e2ee/types';
|
||||
import time from '../../time';
|
||||
import { masterKeyEnabled, setMasterKeyEnabled } from '../../services/synchronizer/syncInfoUtils';
|
||||
import { findMasterKeyPassword } from '../../services/e2ee/utils';
|
||||
|
||||
class Shared {
|
||||
|
||||
private refreshStatsIID_: any;
|
||||
|
||||
public initialize(comp: any, props: any) {
|
||||
comp.state = {
|
||||
passwordChecks: {},
|
||||
// Master keys that can be decrypted with the master password
|
||||
// (normally all of them, but for legacy support we need this).
|
||||
masterPasswordKeys: {},
|
||||
stats: {
|
||||
encrypted: null,
|
||||
total: null,
|
||||
},
|
||||
passwords: Object.assign({}, props.passwords),
|
||||
showDisabledMasterKeys: false,
|
||||
masterPasswordInput: '',
|
||||
};
|
||||
comp.isMounted_ = false;
|
||||
|
||||
this.refreshStatsIID_ = null;
|
||||
}
|
||||
|
||||
public async refreshStats(comp: any) {
|
||||
const stats = await BaseItem.encryptedItemsStats();
|
||||
comp.setState({
|
||||
stats: stats,
|
||||
});
|
||||
}
|
||||
|
||||
public async toggleShowDisabledMasterKeys(comp: any) {
|
||||
comp.setState({ showDisabledMasterKeys: !comp.state.showDisabledMasterKeys });
|
||||
}
|
||||
|
||||
public async reencryptData() {
|
||||
const ok = confirm(_('Please confirm that you would like to re-encrypt your complete database.'));
|
||||
if (!ok) return;
|
||||
|
||||
await BaseItem.forceSyncAll();
|
||||
void reg.waitForSyncFinishedThenSync();
|
||||
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
|
||||
alert(_('Your data is going to be re-encrypted and synced again.'));
|
||||
}
|
||||
|
||||
public dontReencryptData() {
|
||||
Setting.setValue('encryption.shouldReencrypt', Setting.SHOULD_REENCRYPT_NO);
|
||||
}
|
||||
|
||||
public async upgradeMasterKey(comp: any, masterKey: MasterKeyEntity) {
|
||||
const passwordCheck = comp.state.passwordChecks[masterKey.id];
|
||||
if (!passwordCheck) {
|
||||
alert(_('Please enter your password in the master key list below before upgrading the key.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const password = comp.state.passwords[masterKey.id];
|
||||
const newMasterKey = await EncryptionService.instance().reencryptMasterKey(masterKey, password, password);
|
||||
await MasterKey.save(newMasterKey);
|
||||
void reg.waitForSyncFinishedThenSync();
|
||||
alert(_('The master key has been upgraded successfully!'));
|
||||
} catch (error) {
|
||||
alert(_('Could not upgrade master key: %s', error.message));
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(comp: any) {
|
||||
this.componentDidUpdate(comp);
|
||||
|
||||
void this.refreshStats(comp);
|
||||
|
||||
if (this.refreshStatsIID_) {
|
||||
shim.clearInterval(this.refreshStatsIID_);
|
||||
this.refreshStatsIID_ = null;
|
||||
}
|
||||
|
||||
this.refreshStatsIID_ = shim.setInterval(() => {
|
||||
if (!comp.isMounted_) {
|
||||
shim.clearInterval(this.refreshStatsIID_);
|
||||
this.refreshStatsIID_ = null;
|
||||
return;
|
||||
}
|
||||
void this.refreshStats(comp);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
public componentDidUpdate(comp: any, prevProps: any = null) {
|
||||
if (prevProps && comp.props.passwords !== prevProps.passwords) {
|
||||
comp.setState({ passwords: Object.assign({}, comp.props.passwords) });
|
||||
}
|
||||
|
||||
if (!prevProps || comp.props.masterKeys !== prevProps.masterKeys || comp.props.passwords !== prevProps.passwords) {
|
||||
comp.checkPasswords();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.refreshStatsIID_) {
|
||||
shim.clearInterval(this.refreshStatsIID_);
|
||||
this.refreshStatsIID_ = null;
|
||||
}
|
||||
}
|
||||
|
||||
public async masterPasswordIsValid(comp: any, masterPassword: string = null) {
|
||||
const activeMasterKey = comp.props.masterKeys.find((mk: MasterKeyEntity) => mk.id === comp.props.activeMasterKeyId);
|
||||
masterPassword = masterPassword === null ? comp.props.masterPassword : masterPassword;
|
||||
if (activeMasterKey && masterPassword) {
|
||||
return EncryptionService.instance().checkMasterKeyPassword(activeMasterKey, masterPassword);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async checkPasswords(comp: any) {
|
||||
const passwordChecks = Object.assign({}, comp.state.passwordChecks);
|
||||
const masterPasswordKeys = Object.assign({}, comp.state.masterPasswordKeys);
|
||||
for (let i = 0; i < comp.props.masterKeys.length; i++) {
|
||||
const mk = comp.props.masterKeys[i];
|
||||
const password = await findMasterKeyPassword(EncryptionService.instance(), mk);
|
||||
const ok = password ? await EncryptionService.instance().checkMasterKeyPassword(mk, password) : false;
|
||||
passwordChecks[mk.id] = ok;
|
||||
masterPasswordKeys[mk.id] = password === comp.props.masterPassword;
|
||||
}
|
||||
|
||||
passwordChecks['master'] = await this.masterPasswordIsValid(comp);
|
||||
|
||||
comp.setState({ passwordChecks, masterPasswordKeys });
|
||||
}
|
||||
|
||||
public masterPasswordStatus(comp: any) {
|
||||
// Don't translate for now because that's temporary - later it should
|
||||
// always be set and the label should be replaced by a "Change master
|
||||
// password" button.
|
||||
return comp.props.masterPassword ? 'Master password is set' : 'Master password is not set';
|
||||
}
|
||||
|
||||
public decryptedStatText(comp: any) {
|
||||
const stats = comp.state.stats;
|
||||
const doneCount = stats.encrypted !== null ? stats.total - stats.encrypted : '-';
|
||||
const totalCount = stats.total !== null ? stats.total : '-';
|
||||
const result = _('Decrypted items: %s / %s', doneCount, totalCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
public onSavePasswordClick(comp: any, mk: MasterKeyEntity) {
|
||||
const password = comp.state.passwords[mk.id];
|
||||
if (!password) {
|
||||
Setting.deleteObjectValue('encryption.passwordCache', mk.id);
|
||||
} else {
|
||||
Setting.setObjectValue('encryption.passwordCache', mk.id, password);
|
||||
}
|
||||
|
||||
comp.checkPasswords();
|
||||
}
|
||||
|
||||
public onMasterPasswordChange(comp: any, value: string) {
|
||||
comp.setState({ masterPasswordInput: value });
|
||||
}
|
||||
|
||||
public onMasterPasswordSave(comp: any) {
|
||||
Setting.setValue('encryption.masterPassword', comp.state.masterPasswordInput);
|
||||
}
|
||||
|
||||
public onPasswordChange(comp: any, mk: MasterKeyEntity, password: string) {
|
||||
const passwords = Object.assign({}, comp.state.passwords);
|
||||
passwords[mk.id] = password;
|
||||
comp.setState({ passwords: passwords });
|
||||
}
|
||||
|
||||
public onToggleEnabledClick(_comp: any, mk: MasterKeyEntity) {
|
||||
setMasterKeyEnabled(mk.id, !masterKeyEnabled(mk));
|
||||
}
|
||||
|
||||
public enableEncryptionConfirmationMessages(masterKey: MasterKeyEntity) {
|
||||
const msg = [_('Enabling encryption means *all* your notes and attachments are going to be re-synchronised and sent encrypted to the sync target. Do not lose the password as, for security purposes, this will be the *only* way to decrypt the data! To enable encryption, please enter your password below.')];
|
||||
if (masterKey) msg.push(_('Encryption will be enabled using the master key created on %s', time.unixMsToLocalDateTime(masterKey.created_time)));
|
||||
return msg;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const shared = new Shared();
|
||||
|
||||
export default shared;
|
||||
@@ -40,10 +40,6 @@ export default class FileApiDriverJoplinServer {
|
||||
return true;
|
||||
}
|
||||
|
||||
public get requiresPublicPrivateKeyPair() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public requestRepeatCount() {
|
||||
return 3;
|
||||
}
|
||||
|
||||
@@ -106,10 +106,6 @@ 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();
|
||||
|
||||
@@ -25,6 +25,7 @@ function useEventListener(
|
||||
const eventListener = (event: Event) => {
|
||||
// eslint-disable-next-line no-extra-boolean-cast
|
||||
if (!!savedHandler?.current) {
|
||||
// @ts-ignore
|
||||
savedHandler.current(event);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
import JoplinError from '../JoplinError';
|
||||
const JoplinError = require('../JoplinError.js');
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const moment = require('moment');
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface ItemThatNeedSync {
|
||||
type_: ModelType;
|
||||
updated_time: number;
|
||||
encryption_applied: number;
|
||||
share_id: string;
|
||||
}
|
||||
|
||||
export interface ItemsThatNeedSyncResult {
|
||||
@@ -34,6 +33,11 @@ export interface ItemsThatNeedSyncResult {
|
||||
neverSyncedItemIds: string[];
|
||||
}
|
||||
|
||||
export interface EncryptedItemsStats {
|
||||
encrypted: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export default class BaseItem extends BaseModel {
|
||||
|
||||
public static encryptionService_: any = null;
|
||||
@@ -410,7 +414,6 @@ 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)) {
|
||||
@@ -428,9 +431,7 @@ export default class BaseItem extends BaseModel {
|
||||
let cipherText = null;
|
||||
|
||||
try {
|
||||
cipherText = await this.encryptionService().encryptString(serialized, {
|
||||
masterKeyId: share && share.master_key_id ? share.master_key_id : '',
|
||||
});
|
||||
cipherText = await this.encryptionService().encryptString(serialized);
|
||||
} catch (error) {
|
||||
const msg = [`Could not encrypt item ${item.id}`];
|
||||
if (error && error.message) msg.push(error.message);
|
||||
@@ -517,7 +518,7 @@ export default class BaseItem extends BaseModel {
|
||||
return output;
|
||||
}
|
||||
|
||||
static async encryptedItemsStats() {
|
||||
public static async encryptedItemsStats(): Promise<EncryptedItemsStats> {
|
||||
const classNames = this.encryptableItemClassNames();
|
||||
let encryptedCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
@@ -28,8 +28,8 @@ export default class MasterKey extends BaseItem {
|
||||
return output;
|
||||
}
|
||||
|
||||
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], methods: number[]) {
|
||||
return masterKeys.filter(m => !methods.includes(m.encryption_method));
|
||||
static allWithoutEncryptionMethod(masterKeys: MasterKeyEntity[], method: number) {
|
||||
return masterKeys.filter(m => m.encryption_method !== method);
|
||||
}
|
||||
|
||||
public static async all(): Promise<MasterKeyEntity[]> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BaseItemEntity } from '../../services/database/types';
|
||||
|
||||
export default function(_resource: BaseItemEntity): boolean {
|
||||
return true;
|
||||
// return !resource.is_shared && !resource.share_id;
|
||||
export default function(resource: BaseItemEntity): boolean {
|
||||
return !resource.is_shared && !resource.share_id;
|
||||
}
|
||||
|
||||
95
packages/lib/package-lock.json
generated
95
packages/lib/package-lock.json
generated
@@ -41,7 +41,6 @@
|
||||
"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",
|
||||
@@ -68,7 +67,7 @@
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/node-rsa": "^1.1.1",
|
||||
"@types/react": "^17.0.20",
|
||||
"clean-html": "^1.5.0",
|
||||
"jest": "^26.6.3",
|
||||
"sharp": "^0.26.2",
|
||||
@@ -1063,15 +1062,6 @@
|
||||
"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",
|
||||
@@ -1084,6 +1074,29 @@
|
||||
"integrity": "sha512-UEyp8LwZ4Dg30kVU2Q3amHHyTn1jEdhCIE59ANed76GaT1Vp76DD3ZWSAxgCrw6wJ0TqeoBpqmfUHiUDPs//HQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
|
||||
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "17.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.20.tgz",
|
||||
"integrity": "sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/scheduler": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/stack-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz",
|
||||
@@ -2264,6 +2277,12 @@
|
||||
"integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
@@ -5761,14 +5780,6 @@
|
||||
"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",
|
||||
@@ -9765,15 +9776,6 @@
|
||||
"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",
|
||||
@@ -9786,6 +9788,29 @@
|
||||
"integrity": "sha512-UEyp8LwZ4Dg30kVU2Q3amHHyTn1jEdhCIE59ANed76GaT1Vp76DD3ZWSAxgCrw6wJ0TqeoBpqmfUHiUDPs//HQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/prop-types": {
|
||||
"version": "15.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.4.tgz",
|
||||
"integrity": "sha512-rZ5drC/jWjrArrS8BR6SIr4cWpW09RNTYt9AMZo3Jwwif+iacXAqgVjm0B0Bv/S1jhDXKHqRVNCbACkJ89RAnQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/react": {
|
||||
"version": "17.0.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.20.tgz",
|
||||
"integrity": "sha512-wWZrPlihslrPpcKyCSlmIlruakxr57/buQN1RjlIeaaTWDLtJkTtRW429MoQJergvVKc4IWBpRhWw7YNh/7GVA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/prop-types": "*",
|
||||
"@types/scheduler": "*",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"@types/scheduler": {
|
||||
"version": "0.16.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/stack-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.0.tgz",
|
||||
@@ -10753,6 +10778,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"csstype": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
||||
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw==",
|
||||
"dev": true
|
||||
},
|
||||
"dashdash": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
|
||||
@@ -13539,14 +13570,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,7 +19,7 @@
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
"@types/node-rsa": "^1.1.1",
|
||||
"@types/react": "^17.0.20",
|
||||
"clean-html": "^1.5.0",
|
||||
"jest": "^26.6.3",
|
||||
"sharp": "^0.26.2",
|
||||
@@ -63,7 +63,6 @@
|
||||
"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",
|
||||
|
||||
@@ -12,6 +12,7 @@ interface MenuItem {
|
||||
click: Function;
|
||||
role?: any;
|
||||
accelerator?: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface MenuItems {
|
||||
@@ -78,6 +79,7 @@ export default class MenuUtils {
|
||||
id: command.declaration.name,
|
||||
label: this.service.label(commandName),
|
||||
click: () => onClick(command.declaration.name),
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (command.declaration.role) item.role = command.declaration.role;
|
||||
@@ -132,10 +134,13 @@ export default class MenuUtils {
|
||||
public pluginContextMenuItems(plugins: PluginStates, location: MenuItemLocation): MenuItem[] {
|
||||
const output: MenuItem[] = [];
|
||||
const pluginViewInfos = pluginUtils.viewInfosByType(plugins, 'menuItem');
|
||||
const whenClauseContext = this.service.currentWhenClauseContext();
|
||||
|
||||
for (const info of pluginViewInfos) {
|
||||
if (info.view.location !== location) continue;
|
||||
output.push(this.commandToStatefulMenuItem(info.view.commandName));
|
||||
const menuItem = this.commandToStatefulMenuItem(info.view.commandName);
|
||||
menuItem.enabled = this.service.isEnabled(info.view.commandName, whenClauseContext);
|
||||
output.push(menuItem);
|
||||
}
|
||||
|
||||
if (output.length) output.splice(0, 0, { type: 'separator' } as any);
|
||||
|
||||
@@ -18,8 +18,6 @@ export interface BaseItemEntity {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// AUTO-GENERATED BY packages/tools/generate-database-types.js
|
||||
|
||||
/*
|
||||
@@ -52,7 +50,6 @@ export interface FolderEntity {
|
||||
"parent_id"?: string
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"master_key_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface ItemChangeEntity {
|
||||
@@ -129,7 +126,6 @@ export interface NoteEntity {
|
||||
"is_shared"?: number
|
||||
"share_id"?: string
|
||||
"conflict_original_id"?: string
|
||||
"master_key_id"?: string
|
||||
"type_"?: number
|
||||
}
|
||||
export interface NotesNormalizedEntity {
|
||||
|
||||
@@ -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, { EncryptionMethod } from './EncryptionService';
|
||||
import EncryptionService 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: EncryptionMethod.SJCL,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
||||
masterKeyId: '01234568abcdefgh01234568abcdefgh',
|
||||
};
|
||||
|
||||
@@ -39,33 +39,33 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
let hasThrown = false;
|
||||
try {
|
||||
await service.decryptMasterKeyContent(masterKey, 'wrongpassword');
|
||||
await service.decryptMasterKey_(masterKey, 'wrongpassword');
|
||||
} catch (error) {
|
||||
hasThrown = true;
|
||||
}
|
||||
|
||||
expect(hasThrown).toBe(true);
|
||||
|
||||
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||
const decryptedMasterKey = await service.decryptMasterKey_(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: EncryptionMethod.SJCL2,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
});
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
|
||||
let upgradedMasterKey = await service.reencryptMasterKey(masterKey, '123456', '123456');
|
||||
let upgradedMasterKey = await service.upgradeMasterKey(masterKey, '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.decryptMasterKeyContent(masterKey, '123456');
|
||||
const plainTextNew = await service.decryptMasterKeyContent(upgradedMasterKey, '123456');
|
||||
const plainTextOld = await service.decryptMasterKey_(masterKey, '123456');
|
||||
const plainTextNew = await service.decryptMasterKey_(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: EncryptionMethod.SJCL2,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
});
|
||||
|
||||
await checkThrowAsync(async () => await service.reencryptMasterKey(masterKey, '777', '777'));
|
||||
await checkThrowAsync(async () => await service.upgradeMasterKey(masterKey, '777'));
|
||||
}));
|
||||
|
||||
it('should require a checksum only for old master keys', (async () => {
|
||||
const masterKey = await service.generateMasterKey('123456', {
|
||||
encryptionMethod: EncryptionMethod.SJCL2,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
});
|
||||
|
||||
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: EncryptionMethod.SJCL4,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_4,
|
||||
});
|
||||
|
||||
expect(!masterKey.checksum).toBe(true);
|
||||
expect(!!masterKey.content).toBe(true);
|
||||
|
||||
const decryptedMasterKey = await service.decryptMasterKeyContent(masterKey, '123456');
|
||||
const decryptedMasterKey = await service.decryptMasterKey_(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: EncryptionMethod.SJCL4,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_4,
|
||||
});
|
||||
|
||||
const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKeyContent(masterKey, 'wrong'));
|
||||
const hasThrown = await checkThrowAsync(async () => await service.decryptMasterKey_(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: EncryptionMethod.SJCL2,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
}));
|
||||
|
||||
const masterKey2 = await MasterKey.save(await service.generateMasterKey('123456', {
|
||||
encryptionMethod: EncryptionMethod.SJCL,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL,
|
||||
}));
|
||||
|
||||
await MasterKey.save(await service.generateMasterKey('123456'));
|
||||
@@ -164,22 +164,22 @@ describe('services_EncryptionService', function() {
|
||||
|
||||
{
|
||||
const cipherText = await service.encryptString('some secret', {
|
||||
encryptionMethod: EncryptionMethod.SJCL2,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_2,
|
||||
});
|
||||
const plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('some secret');
|
||||
const header = await service.decodeHeaderString(cipherText);
|
||||
expect(header.encryptionMethod).toBe(EncryptionMethod.SJCL2);
|
||||
expect(header.encryptionMethod).toBe(EncryptionService.METHOD_SJCL_2);
|
||||
}
|
||||
|
||||
{
|
||||
const cipherText = await service.encryptString('some secret', {
|
||||
encryptionMethod: EncryptionMethod.SJCL3,
|
||||
encryptionMethod: EncryptionService.METHOD_SJCL_3,
|
||||
});
|
||||
const plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('some secret');
|
||||
const header = await service.decodeHeaderString(cipherText);
|
||||
expect(header.encryptionMethod).toBe(EncryptionMethod.SJCL3);
|
||||
expect(header.encryptionMethod).toBe(EncryptionService.METHOD_SJCL_3);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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_ = EncryptionMethod.SJCL;
|
||||
service.defaultEncryptionMethod_ = EncryptionService.METHOD_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_ = EncryptionMethod.SJCL1a;
|
||||
service.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
|
||||
const cipherText = await service.encryptString('🐶🐶🐶'.substr(0,5));
|
||||
const plainText = await service.decryptString(cipherText);
|
||||
expect(plainText).toBe('🐶🐶🐶'.substr(0,5));
|
||||
@@ -293,5 +293,4 @@ describe('services_EncryptionService', function() {
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
expect(service.isMasterKeyLoaded(masterKey)).toBe(false);
|
||||
}));
|
||||
|
||||
});
|
||||
|
||||
@@ -25,32 +25,16 @@ 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.
|
||||
@@ -68,8 +52,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_ = EncryptionMethod.SJCL1a; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||
public defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A; // public because used in tests
|
||||
private defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
||||
|
||||
private headerTemplates_ = {
|
||||
// Template version 1
|
||||
@@ -95,8 +79,8 @@ export default class EncryptionService {
|
||||
// changed easily since the chunk size is incorporated into the encrypted data.
|
||||
this.chunkSize_ = 5000;
|
||||
this.decryptedMasterKeys_ = {};
|
||||
this.defaultEncryptionMethod_ = EncryptionMethod.SJCL1a;
|
||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionMethod.SJCL4;
|
||||
this.defaultEncryptionMethod_ = EncryptionService.METHOD_SJCL_1A;
|
||||
this.defaultMasterKeyEncryptionMethod_ = EncryptionService.METHOD_SJCL_4;
|
||||
|
||||
this.headerTemplates_ = {
|
||||
// Template version 1
|
||||
@@ -113,10 +97,6 @@ export default class EncryptionService {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public get defaultMasterKeyEncryptionMethod() {
|
||||
return this.defaultMasterKeyEncryptionMethod_;
|
||||
}
|
||||
|
||||
loadedMasterKeysCount() {
|
||||
return Object.keys(this.decryptedMasterKeys_).length;
|
||||
}
|
||||
@@ -155,7 +135,7 @@ export default class EncryptionService {
|
||||
logger.info(`Loading master key: ${model.id}. Make active:`, makeActive);
|
||||
|
||||
this.decryptedMasterKeys_[model.id] = {
|
||||
plainText: await this.decryptMasterKeyContent(model, password),
|
||||
plainText: await this.decryptMasterKey_(model, password),
|
||||
updatedTime: model.updated_time,
|
||||
};
|
||||
|
||||
@@ -195,7 +175,7 @@ export default class EncryptionService {
|
||||
return await this.randomHexString(64);
|
||||
}
|
||||
|
||||
private async randomHexString(byteCount: number) {
|
||||
async randomHexString(byteCount: number) {
|
||||
const bytes: any[] = await shim.randomBytes(byteCount);
|
||||
return bytes
|
||||
.map(a => {
|
||||
@@ -204,39 +184,32 @@ export default class EncryptionService {
|
||||
.join('');
|
||||
}
|
||||
|
||||
public masterKeysThatNeedUpgrading(masterKeys: MasterKeyEntity[]) {
|
||||
return MasterKey.allWithoutEncryptionMethod(masterKeys, [this.defaultMasterKeyEncryptionMethod_, EncryptionMethod.Custom]);
|
||||
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 async reencryptMasterKey(model: MasterKeyEntity, decryptionPassword: string, encryptionPassword: string, decryptOptions: EncryptOptions = null, encryptOptions: EncryptOptions = null): Promise<MasterKeyEntity> {
|
||||
async upgradeMasterKey(model: MasterKeyEntity, decryptionPassword: string) {
|
||||
const newEncryptionMethod = this.defaultMasterKeyEncryptionMethod_;
|
||||
const plainText = await this.decryptMasterKeyContent(model, decryptionPassword, decryptOptions);
|
||||
const newContent = await this.encryptMasterKeyContent(newEncryptionMethod, plainText, encryptionPassword, encryptOptions);
|
||||
const plainText = await this.decryptMasterKey_(model, decryptionPassword);
|
||||
const newContent = await this.encryptMasterKeyContent_(newEncryptionMethod, plainText, decryptionPassword);
|
||||
return { ...model, ...newContent };
|
||||
}
|
||||
|
||||
public async encryptMasterKeyContent(encryptionMethod: EncryptionMethod, hexaBytes: string, password: string, options: EncryptOptions = null): Promise<MasterKeyEntity> {
|
||||
options = { ...options };
|
||||
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);
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
return {
|
||||
checksum: checksum,
|
||||
encryption_method: encryptionMethod,
|
||||
content: cipherText,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateMasterKeyContent_(password: string, options: EncryptOptions = null) {
|
||||
async generateMasterKeyContent_(password: string, options: any = null) {
|
||||
options = Object.assign({}, {
|
||||
encryptionMethod: this.defaultMasterKeyEncryptionMethod_,
|
||||
}, options);
|
||||
@@ -244,10 +217,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, options);
|
||||
return this.encryptMasterKeyContent_(options.encryptionMethod, hexaBytes, password);
|
||||
}
|
||||
|
||||
public async generateMasterKey(password: string, options: EncryptOptions = null) {
|
||||
async generateMasterKey(password: string, options: any = null) {
|
||||
const model = await this.generateMasterKeyContent_(password, options);
|
||||
|
||||
const now = Date.now();
|
||||
@@ -258,16 +231,9 @@ export default class EncryptionService {
|
||||
return model;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
public async decryptMasterKey_(model: MasterKeyEntity, password: string): Promise<string> {
|
||||
const plainText = await this.decrypt(model.encryption_method, password, model.content);
|
||||
if (model.encryption_method === EncryptionMethod.SJCL2) {
|
||||
if (model.encryption_method === EncryptionService.METHOD_SJCL_2) {
|
||||
const checksum = this.sha256(plainText);
|
||||
if (checksum !== model.checksum) throw new Error('Could not decrypt master key (checksum failed)');
|
||||
}
|
||||
@@ -277,7 +243,7 @@ export default class EncryptionService {
|
||||
|
||||
public async checkMasterKeyPassword(model: MasterKeyEntity, password: string) {
|
||||
try {
|
||||
await this.decryptMasterKeyContent(model, password);
|
||||
await this.decryptMasterKey_(model, password);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
@@ -291,14 +257,14 @@ export default class EncryptionService {
|
||||
return error;
|
||||
}
|
||||
|
||||
public async encrypt(method: EncryptionMethod, key: string, plainText: string): Promise<string> {
|
||||
async encrypt(method: number, key: string, plainText: 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 === EncryptionMethod.SJCL) {
|
||||
if (method === EncryptionService.METHOD_SJCL) {
|
||||
try {
|
||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
@@ -317,7 +283,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 === EncryptionMethod.SJCL1a) {
|
||||
if (method === EncryptionService.METHOD_SJCL_1A) {
|
||||
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
|
||||
@@ -338,7 +304,7 @@ export default class EncryptionService {
|
||||
|
||||
// 2020-01-23: Deprectated - see above.
|
||||
// Was used to encrypt master keys
|
||||
if (method === EncryptionMethod.SJCL2) {
|
||||
if (method === EncryptionService.METHOD_SJCL_2) {
|
||||
try {
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
v: 1,
|
||||
@@ -353,7 +319,7 @@ export default class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
if (method === EncryptionMethod.SJCL3) {
|
||||
if (method === EncryptionService.METHOD_SJCL_3) {
|
||||
try {
|
||||
// Good demo to understand each parameter: https://bitwiseshiftleft.github.io/sjcl/demo/
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
@@ -371,7 +337,7 @@ export default class EncryptionService {
|
||||
}
|
||||
|
||||
// Same as above but more secure (but slower) to encrypt master keys
|
||||
if (method === EncryptionMethod.SJCL4) {
|
||||
if (method === EncryptionService.METHOD_SJCL_4) {
|
||||
try {
|
||||
return sjcl.json.encrypt(key, plainText, {
|
||||
v: 1,
|
||||
@@ -389,7 +355,7 @@ export default class EncryptionService {
|
||||
throw new Error(`Unknown encryption method: ${method}`);
|
||||
}
|
||||
|
||||
async decrypt(method: EncryptionMethod, key: string, cipherText: string) {
|
||||
async decrypt(method: number, key: string, cipherText: string) {
|
||||
if (!method) throw new Error('Encryption method is required');
|
||||
if (!key) throw new Error('Encryption key is required');
|
||||
|
||||
@@ -399,7 +365,7 @@ export default class EncryptionService {
|
||||
try {
|
||||
const output = sjcl.json.decrypt(key, cipherText);
|
||||
|
||||
if (method === EncryptionMethod.SJCL1a) {
|
||||
if (method === EncryptionService.METHOD_SJCL_1A) {
|
||||
return unescape(output);
|
||||
} else {
|
||||
return output;
|
||||
@@ -410,13 +376,13 @@ export default class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async encryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
||||
async encryptAbstract_(source: any, destination: any, options: any = null) {
|
||||
options = Object.assign({}, {
|
||||
encryptionMethod: this.defaultEncryptionMethod(),
|
||||
}, options);
|
||||
|
||||
const method = options.encryptionMethod;
|
||||
const masterKeyId = options.masterKeyId ? options.masterKeyId : this.activeMasterKeyId();
|
||||
const masterKeyId = this.activeMasterKeyId();
|
||||
const masterKeyPlainText = this.loadedMasterKey(masterKeyId).plainText;
|
||||
|
||||
const header = {
|
||||
@@ -446,7 +412,7 @@ export default class EncryptionService {
|
||||
}
|
||||
}
|
||||
|
||||
async decryptAbstract_(source: any, destination: any, options: EncryptOptions = null) {
|
||||
async decryptAbstract_(source: any, destination: any, options: any = null) {
|
||||
if (!options) options = {};
|
||||
|
||||
const header: any = await this.decodeHeaderSource_(source);
|
||||
@@ -523,21 +489,21 @@ export default class EncryptionService {
|
||||
};
|
||||
}
|
||||
|
||||
public async encryptString(plainText: any, options: EncryptOptions = null): Promise<string> {
|
||||
async encryptString(plainText: any, options: any = null) {
|
||||
const source = this.stringReader_(plainText);
|
||||
const destination = this.stringWriter_();
|
||||
await this.encryptAbstract_(source, destination, options);
|
||||
return destination.result();
|
||||
}
|
||||
|
||||
public async decryptString(cipherText: any, options: EncryptOptions = null): Promise<string> {
|
||||
async decryptString(cipherText: any, options: any = null) {
|
||||
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: EncryptOptions = null) {
|
||||
async encryptFile(srcPath: string, destPath: string, options: any = null) {
|
||||
let source = await this.fileReader_(srcPath, 'base64');
|
||||
let destination = await this.fileWriter_(destPath, 'ascii');
|
||||
|
||||
@@ -562,7 +528,7 @@ export default class EncryptionService {
|
||||
await cleanUp();
|
||||
}
|
||||
|
||||
async decryptFile(srcPath: string, destPath: string, options: EncryptOptions = null) {
|
||||
async decryptFile(srcPath: string, destPath: string, options: any = null) {
|
||||
let source = await this.fileReader_(srcPath, 'ascii');
|
||||
let destination = await this.fileWriter_(destPath, 'base64');
|
||||
|
||||
@@ -651,8 +617,8 @@ export default class EncryptionService {
|
||||
return output;
|
||||
}
|
||||
|
||||
isValidEncryptionMethod(method: EncryptionMethod) {
|
||||
return [EncryptionMethod.SJCL, EncryptionMethod.SJCL1a, EncryptionMethod.SJCL2, EncryptionMethod.SJCL3, EncryptionMethod.SJCL4].indexOf(method) >= 0;
|
||||
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;
|
||||
}
|
||||
|
||||
async itemIsEncrypted(item: any) {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
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);
|
||||
}));
|
||||
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
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,9 +1,8 @@
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, expectNotThrow, expectThrow } from '../../testing/test-utils';
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, expectNotThrow } from '../../testing/test-utils';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { migrateMasterPassword, showMissingMasterKeyMessage, updateMasterPassword } from './utils';
|
||||
import { migrateMasterPassword, showMissingMasterKeyMessage } from './utils';
|
||||
import { localSyncInfo, setActiveMasterKeyId, setMasterKeyEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import Setting from '../../models/Setting';
|
||||
import { generateKeyPairAndSave, ppkPasswordIsValid } from './ppk';
|
||||
|
||||
describe('e2ee/utils', function() {
|
||||
|
||||
@@ -72,32 +71,4 @@ 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,9 +4,7 @@ import MasterKey from '../../models/MasterKey';
|
||||
import Setting from '../../models/Setting';
|
||||
import { MasterKeyEntity } from './types';
|
||||
import EncryptionService from './EncryptionService';
|
||||
import { getActiveMasterKey, getActiveMasterKeyId, localSyncInfo, masterKeyEnabled, saveLocalSyncInfo, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
import JoplinError from '../../JoplinError';
|
||||
import { generateKeyPairAndSave, pkReencryptPrivateKey, ppkPasswordIsValid } from './ppk';
|
||||
import { getActiveMasterKey, getActiveMasterKeyId, masterKeyEnabled, setEncryptionEnabled, SyncInfo } from '../synchronizer/syncInfoUtils';
|
||||
|
||||
const logger = Logger.create('e2ee/utils');
|
||||
|
||||
@@ -105,7 +103,7 @@ export async function migrateMasterPassword() {
|
||||
// previously any master key could be encrypted with any password, so to support
|
||||
// this legacy case, we first check if the MK decrypts with the master password.
|
||||
// If not, try with the master key specific password, if any is defined.
|
||||
export async function findMasterKeyPassword(service: EncryptionService, masterKey: MasterKeyEntity): Promise<string> {
|
||||
export async function findMasterKeyPassword(service: EncryptionService, masterKey: MasterKeyEntity, passwordCache: Record<string, string> = null): Promise<string> {
|
||||
const masterPassword = Setting.value('encryption.masterPassword');
|
||||
if (masterPassword && await service.checkMasterKeyPassword(masterKey, masterPassword)) {
|
||||
logger.info('findMasterKeyPassword: Using master password');
|
||||
@@ -114,7 +112,7 @@ export async function findMasterKeyPassword(service: EncryptionService, masterKe
|
||||
|
||||
logger.info('findMasterKeyPassword: No master password is defined - trying to get master key specific password');
|
||||
|
||||
const passwords = Setting.value('encryption.passwordCache');
|
||||
const passwords = passwordCache ? passwordCache : Setting.value('encryption.passwordCache');
|
||||
return passwords[masterKey.id];
|
||||
}
|
||||
|
||||
@@ -163,112 +161,9 @@ export function showMissingMasterKeyMessage(syncInfo: SyncInfo, notLoadedMasterK
|
||||
}
|
||||
|
||||
export function getDefaultMasterKey(): MasterKeyEntity {
|
||||
const mk = getActiveMasterKey();
|
||||
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);
|
||||
let mk = getActiveMasterKey();
|
||||
if (!mk || masterKeyEnabled(mk)) {
|
||||
mk = MasterKey.latest();
|
||||
}
|
||||
|
||||
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');
|
||||
return mk && masterKeyEnabled(mk) ? mk : null;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import Note from '../../models/Note';
|
||||
import { encryptionService, msleep, setupDatabaseAndSynchronizer, switchClient } from '../../testing/test-utils';
|
||||
import { 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 mockService(api: any) {
|
||||
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() {
|
||||
const service = new ShareService();
|
||||
const store = createStore(reducer as any);
|
||||
service.initialize(store, encryptionService(), api);
|
||||
service.initialize(store, mockApi() as any);
|
||||
return service;
|
||||
}
|
||||
|
||||
@@ -25,17 +32,9 @@ 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({
|
||||
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) {
|
||||
|
||||
},
|
||||
});
|
||||
const service = mockService();
|
||||
await msleep(1);
|
||||
await service.shareNote(note.id);
|
||||
|
||||
@@ -62,86 +61,6 @@ 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,41 +1,18 @@
|
||||
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 { 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';
|
||||
import { 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_;
|
||||
@@ -43,9 +20,8 @@ export default class ShareService {
|
||||
return this.instance_;
|
||||
}
|
||||
|
||||
public initialize(store: Store<any>, encryptionService: EncryptionService, api: JoplinServerApi = null) {
|
||||
public initialize(store: Store<any>, api: JoplinServerApi = null) {
|
||||
this.store_ = store;
|
||||
this.encryptionService_ = encryptionService;
|
||||
this.api_ = api;
|
||||
}
|
||||
|
||||
@@ -80,40 +56,15 @@ export default class ShareService {
|
||||
return this.api_;
|
||||
}
|
||||
|
||||
public async shareFolder(folderId: string): Promise<ApiShare> {
|
||||
public async shareFolder(folderId: string) {
|
||||
const folder = await Folder.load(folderId);
|
||||
if (!folder) throw new Error(`No such folder: ${folderId}`);
|
||||
|
||||
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);
|
||||
if (folder.parent_id) {
|
||||
await Folder.save({ id: folder.id, parent_id: '' });
|
||||
}
|
||||
|
||||
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 : '',
|
||||
});
|
||||
const share = await this.api().exec('POST', 'api/shares', {}, { folder_id: folderId });
|
||||
|
||||
// Note: race condition if the share is created but the app crashes
|
||||
// before setting share_id on the folder. See unshareFolder() for info.
|
||||
@@ -223,34 +174,9 @@ export default class ShareService {
|
||||
return this.state.shareInvitations;
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
public async addShareRecipient(shareId: string, recipientEmail: string) {
|
||||
return this.api().exec('POST', `api/shares/${shareId}/users`, {}, {
|
||||
email: recipientEmail,
|
||||
master_key: JSON.stringify(recipientMasterKey),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,24 +200,8 @@ export default class ShareService {
|
||||
return this.api().exec('GET', 'api/share_users');
|
||||
}
|
||||
|
||||
public async respondInvitation(shareUserId: string, masterKey: MasterKeyEntity, accept: boolean) {
|
||||
logger.info('respondInvitation: ', shareUserId, accept);
|
||||
|
||||
public async respondInvitation(shareUserId: string, accept: boolean) {
|
||||
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 });
|
||||
@@ -301,57 +211,15 @@ 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: invitations,
|
||||
shareInvitations: result.items,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -363,8 +231,6 @@ 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,7 +1,6 @@
|
||||
import { State as RootState } from '../../reducer';
|
||||
import { Draft } from 'immer';
|
||||
import { FolderEntity } from '../database/types';
|
||||
import { MasterKeyEntity } from '../e2ee/types';
|
||||
|
||||
interface StateShareUserUser {
|
||||
id: string;
|
||||
@@ -26,13 +25,11 @@ export interface StateShare {
|
||||
type: number;
|
||||
folder_id: string;
|
||||
note_id: string;
|
||||
master_key_id: string;
|
||||
user?: StateShareUserUser;
|
||||
}
|
||||
|
||||
export interface ShareInvitation {
|
||||
id: string;
|
||||
master_key: MasterKeyEntity;
|
||||
share: StateShare;
|
||||
status: ShareUserStatus;
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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,7 +2,6 @@ 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 {
|
||||
@@ -15,11 +14,6 @@ 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
|
||||
|
||||
@@ -94,7 +88,6 @@ 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();
|
||||
@@ -122,12 +115,10 @@ 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);
|
||||
}
|
||||
@@ -138,7 +129,6 @@ export class SyncInfo {
|
||||
e2ee: this.e2ee_,
|
||||
activeMasterKeyId: this.activeMasterKeyId_,
|
||||
masterKeys: this.masterKeys,
|
||||
ppk: this.ppk_,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +142,6 @@ 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) {
|
||||
@@ -172,16 +161,6 @@ 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;
|
||||
}
|
||||
@@ -278,11 +257,3 @@ 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);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { NoteEntity, ResourceEntity } from './services/database/types';
|
||||
|
||||
let isTestingEnv_ = false;
|
||||
|
||||
// We need to ensure that there's only one instance of React being used by
|
||||
// all the packages. In particular, the lib might need React to define
|
||||
// generic hooks, but it shouldn't have React in its dependencies as that
|
||||
// would cause the following error:
|
||||
// We need to ensure that there's only one instance of React being used by all
|
||||
// the packages. In particular, the lib might need React to define generic
|
||||
// hooks, but it shouldn't have React in its dependencies as that would cause
|
||||
// the following error:
|
||||
//
|
||||
// https://reactjs.org/warnings/invalid-hook-call-warning.html#duplicate-react
|
||||
//
|
||||
// So instead, the **applications** include React as a dependency, then
|
||||
// pass it to any other packages using the shim. Essentially, only one
|
||||
// package should require React, and in our case that should be one of the
|
||||
// applications (app-desktop, app-mobile, etc.) since we are sure they
|
||||
// won't be dependency to other packages (unlike the lib which can be
|
||||
// included anywhere).
|
||||
|
||||
let react_: any = null;
|
||||
// So instead, the **applications** include React as a dependency, then pass it
|
||||
// to any other packages using the shim. Essentially, only one package should
|
||||
// require React, and in our case that should be one of the applications
|
||||
// (app-desktop, app-mobile, etc.) since we are sure they won't be dependency to
|
||||
// other packages (unlike the lib which can be included anywhere).
|
||||
//
|
||||
// Regarding the type - althought we import React, we only use it as a type
|
||||
// using `typeof React`. This is just to get types in hooks.
|
||||
//
|
||||
// https://stackoverflow.com/a/42816077/561309
|
||||
let react_: typeof React = null;
|
||||
|
||||
const shim = {
|
||||
Geolocation: null as any,
|
||||
|
||||
@@ -505,12 +505,11 @@ 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(password);
|
||||
masterKey = await service.generateMasterKey('123456');
|
||||
masterKey = await MasterKey.save(masterKey);
|
||||
} else { // Use the one already available
|
||||
const masterKeys = await MasterKey.all();
|
||||
@@ -518,12 +517,7 @@ async function loadEncryptionMasterKey(id: number = null, useExisting = false) {
|
||||
masterKey = masterKeys[0];
|
||||
}
|
||||
|
||||
const passwordCache = Setting.value('encryption.passwordCache');
|
||||
passwordCache[masterKey.id] = password;
|
||||
Setting.setValue('encryption.passwordCache', passwordCache);
|
||||
await Setting.saveAll();
|
||||
|
||||
await service.loadMasterKey(masterKey, password, true);
|
||||
await service.loadMasterKey(masterKey, '123456', 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,7 +14,6 @@ const theme: Theme = {
|
||||
oddBackgroundColor: '#141517',
|
||||
color: '#dddddd',
|
||||
colorError: 'red',
|
||||
colorCorrect: '#72b972',
|
||||
colorWarn: '#9A5B00',
|
||||
colorWarnUrl: '#ffff82',
|
||||
colorFaded: '#999999', // For less important text
|
||||
|
||||
@@ -11,7 +11,6 @@ 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,7 +13,6 @@ 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.5.0",
|
||||
"version": "2.4.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
|
||||
Binary file not shown.
@@ -1,22 +0,0 @@
|
||||
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,20 +67,6 @@ 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() {
|
||||
@@ -92,8 +78,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);
|
||||
});
|
||||
@@ -107,7 +93,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,7 +60,6 @@ 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;
|
||||
}
|
||||
@@ -149,20 +148,6 @@ 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[]> {
|
||||
@@ -333,38 +318,36 @@ export default class ShareModel extends BaseModel<Share> {
|
||||
});
|
||||
}
|
||||
|
||||
public async shareFolder(owner: User, folderId: string, masterKeyId: string): Promise<Share> {
|
||||
public async shareFolder(owner: User, folderId: 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: Share = {
|
||||
const shareToSave = {
|
||||
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, masterKeyId: string): Promise<Share> {
|
||||
public async shareNote(owner: User, noteId: 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: Share = {
|
||||
const shareToSave = {
|
||||
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, masterKey: string = '') {
|
||||
await this.models().shareUser().addById(share.id, shareeId, masterKey);
|
||||
public async shareWithUserAndAccept(share: Share, shareeId: Uuid) {
|
||||
await this.models().shareUser().addById(share.id, shareeId);
|
||||
await this.models().shareUser().setStatus(share.id, shareeId, ShareUserStatus.Accepted);
|
||||
}
|
||||
|
||||
public async addById(shareId: Uuid, userId: Uuid, masterKey: string): Promise<ShareUser> {
|
||||
public async addById(shareId: Uuid, userId: Uuid): Promise<ShareUser> {
|
||||
const user = await this.models().user().load(userId);
|
||||
return this.addByEmail(shareId, user.email, masterKey);
|
||||
return this.addByEmail(shareId, user.email);
|
||||
}
|
||||
|
||||
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, masterKey: string): Promise<ShareUser> {
|
||||
public async addByEmail(shareId: Uuid, userEmail: string): Promise<ShareUser> {
|
||||
const share = await this.models().share().load(shareId);
|
||||
if (!share) throw new ErrorNotFound(`No such share: ${shareId}`);
|
||||
|
||||
@@ -110,7 +110,6 @@ 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, expectThrow } from '../utils/testing/testUtils';
|
||||
import { createUserAndSession, beforeAllDb, afterAllTests, beforeEachDb, models, checkThrowAsync, createItem } from '../utils/testing/testUtils';
|
||||
import { EmailSender, User, UserFlagType } from '../services/database/types';
|
||||
import { ErrorUnprocessableEntity } from '../utils/errors';
|
||||
import { betaUserDateRange, stripeConfig } from '../utils/stripe';
|
||||
@@ -267,56 +267,4 @@ 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,7 +15,6 @@ 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';
|
||||
@@ -440,18 +439,6 @@ 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,7 +43,6 @@ 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,18 +19,11 @@ 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<Fields>(ctx.req);
|
||||
const fields = await bodyFields<any>(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)
|
||||
@@ -38,9 +31,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, masterKeyId);
|
||||
return ctx.joplin.models.share().shareFolder(ctx.joplin.owner, shareInput.folder_id);
|
||||
} else if (shareInput.note_id) {
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id, masterKeyId);
|
||||
return ctx.joplin.models.share().shareNote(ctx.joplin.owner, shareInput.note_id);
|
||||
} else {
|
||||
throw new ErrorBadRequest('Either folder_id or note_id must be provided');
|
||||
}
|
||||
@@ -51,23 +44,20 @@ 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, masterKey);
|
||||
return ctx.joplin.models.shareUser().addByEmail(shareId, user.email);
|
||||
});
|
||||
|
||||
router.get('api/shares/:id/users', async (path: SubPath, ctx: AppContext) => {
|
||||
@@ -112,17 +102,13 @@ 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 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));
|
||||
|
||||
const shares = ctx.joplin.models.share().toApiOutput(await ctx.joplin.models.share().sharesByUser(ctx.joplin.owner.id)) as Share[];
|
||||
// Fake paginated results so that it can be added later on, if needed.
|
||||
return {
|
||||
items: ownedShares.concat(participatedShares).map(share => {
|
||||
items: shares.map(share => {
|
||||
return {
|
||||
...share,
|
||||
user: {
|
||||
|
||||
@@ -26,21 +26,6 @@ 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,7 +137,6 @@ export interface ShareUser extends WithDates, WithUuid {
|
||||
share_id?: Uuid;
|
||||
user_id?: Uuid;
|
||||
status?: ShareUserStatus;
|
||||
master_key?: string;
|
||||
}
|
||||
|
||||
export interface Item extends WithDates, WithUuid {
|
||||
@@ -178,7 +177,6 @@ export interface Share extends WithDates, WithUuid {
|
||||
type?: ShareType;
|
||||
folder_id?: Uuid;
|
||||
note_id?: Uuid;
|
||||
master_key_id?: Uuid;
|
||||
}
|
||||
|
||||
export interface Change extends WithDates, WithUuid {
|
||||
@@ -295,7 +293,6 @@ export const databaseSchema: DatabaseTables = {
|
||||
status: { type: 'number' },
|
||||
updated_time: { type: 'string' },
|
||||
created_time: { type: 'string' },
|
||||
master_key: { type: 'string' },
|
||||
},
|
||||
items: {
|
||||
id: { type: 'string' },
|
||||
@@ -339,7 +336,6 @@ 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: 10, // Up to 10 requests per IP
|
||||
duration: 60, // Per 60 seconds
|
||||
points: 3, // Up to 3 requests per IP
|
||||
duration: 30, // Per 30 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 = decodeURIComponent(p.substr(0, colonIndex2 + 1));
|
||||
output.id = p.substr(0, colonIndex2 + 1);
|
||||
output.link = ltrimSlashes(p.substr(colonIndex2 + 1));
|
||||
}
|
||||
} else {
|
||||
const s = p.split('/');
|
||||
if (s.length >= 1) output.id = decodeURIComponent(s[0]);
|
||||
if (s.length >= 1) output.id = s[0];
|
||||
if (s.length >= 2) output.link = s[1];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Joplin-CLI 1.0.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"Last-Translator: jmontane, 2019\n"
|
||||
"Language-Team: jmontane@softcatala.org\n"
|
||||
"Last-Translator: Xavi Ivars <xavi.ivars@gmail.com>\n"
|
||||
"Language-Team: xavivars@softcatala.org\n"
|
||||
"Language: ca\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
"X-Generator: Poedit 3.0\n"
|
||||
"POT-Creation-Date: \n"
|
||||
"PO-Revision-Date: \n"
|
||||
|
||||
#: packages/app-cli/app/app-gui.js:452
|
||||
msgid "To delete a tag, untag the associated notes."
|
||||
@@ -179,8 +181,8 @@ msgid ""
|
||||
"Duplicates the notes matching <note> to [notebook]. If no notebook is "
|
||||
"specified the note is duplicated in the current notebook."
|
||||
msgstr ""
|
||||
"Duplica les notes que coincideixen amb <note> a [blocdenotes]. Si no "
|
||||
"indiqueu cap bloc de notes es dupliquen en el bloc de notes actual."
|
||||
"Duplica les notes que coincideixen amb <note> a [notebook]. Si no indiqueu "
|
||||
"cap bloc de notes, es dupliquen en el bloc de notes actual."
|
||||
|
||||
#: packages/app-cli/app/command-done.js:14
|
||||
msgid "Marks a to-do as done."
|
||||
@@ -227,7 +229,7 @@ msgstr "Elements desxifrats: %d"
|
||||
#, javascript-format
|
||||
msgid "Skipped items: %d (use --retry-failed-items to retry decrypting them)"
|
||||
msgstr ""
|
||||
"Elements omesos: %d (utilitza --retry-failed-items per tornar a intentar "
|
||||
"Elements omesos: %d (utilitza --retry-failed-items per a tornar a intentar "
|
||||
"desxifrar-los)"
|
||||
|
||||
#: packages/app-cli/app/command-e2ee.js:78
|
||||
@@ -274,8 +276,8 @@ msgstr "Edita la nota."
|
||||
msgid ""
|
||||
"No text editor is defined. Please set it using `config editor <editor-path>`"
|
||||
msgstr ""
|
||||
"No hi ha definit cap editor de text. Establiu-ne un usant `config editor "
|
||||
"<editor-path>`"
|
||||
"No hi ha definit cap editor de text. Establiu-ne un usant \"config editor "
|
||||
"<editor-path>\""
|
||||
|
||||
#: packages/app-cli/app/command-edit.js:40
|
||||
msgid "No active notebook."
|
||||
@@ -289,14 +291,14 @@ msgstr "La nota «%s» no existeix. Voleu crear-la?"
|
||||
#: packages/app-cli/app/command-edit.js:75
|
||||
msgid "Starting to edit note. Close the editor to get back to the prompt."
|
||||
msgstr ""
|
||||
"S'està iniciant l'edició del a nota. Tanqueu l'editor per a tornar a "
|
||||
"S'està iniciant l'edició de la nota. Tanqueu l'editor per a tornar a "
|
||||
"l'indicador."
|
||||
|
||||
#: packages/app-cli/app/command-edit.js:82
|
||||
#: packages/app-desktop/commands/startExternalEditing.js:32
|
||||
#, javascript-format
|
||||
msgid "Error opening note in editor: %s"
|
||||
msgstr "S'ha produït un error a l'obrir la nota amb l'editor: %s"
|
||||
msgstr "S'ha produït un error en l'obrir la nota amb l'editor: %s"
|
||||
|
||||
#: packages/app-cli/app/command-edit.js:97
|
||||
msgid "Note has been saved."
|
||||
@@ -329,7 +331,7 @@ msgstr "Exporta només el bloc de notes indicat."
|
||||
|
||||
#: packages/app-cli/app/command-geoloc.js:13
|
||||
msgid "Displays a geolocation URL for the note."
|
||||
msgstr "Motra una URL de geolocalitzacio de la nota."
|
||||
msgstr "Mostra un URL de geolocalització de la nota."
|
||||
|
||||
#: packages/app-cli/app/command-help.js:13
|
||||
msgid "Displays usage information."
|
||||
@@ -364,10 +366,10 @@ msgid ""
|
||||
"using the shortcuts `$n` or `$b` for, respectively, the currently selected "
|
||||
"note or notebook. `$c` can be used to refer to the currently selected item."
|
||||
msgstr ""
|
||||
"En qualsevol ordre, podeu referenciar una nota o bloc de notes per el títol "
|
||||
"o l'ID, o podeu usar dreceres «$n» o «$b» per a, respectivament, la nota o "
|
||||
"el bloc de nota seleccionat. Podeu usar «$c» per a fer referència a "
|
||||
"l'element seleccionat."
|
||||
"En qualsevol ordre, podeu referenciar una nota o bloc de notes pel títol o "
|
||||
"l'ID, o podeu usar dreceres «$n» o «$b» per a, respectivament, la nota o el "
|
||||
"bloc de nota seleccionat. Podeu usar «$c» per a fer referència a l'element "
|
||||
"seleccionat."
|
||||
|
||||
#: packages/app-cli/app/command-help.js:79
|
||||
msgid "To move from one pane to another, press Tab or Shift+Tab."
|
||||
@@ -551,7 +553,7 @@ msgid ""
|
||||
"be deleted."
|
||||
msgstr ""
|
||||
"Voleu suprimir el bloc de notes? També se suprimiran totes les notes i els "
|
||||
"sub-blocs d'aquest bloc de notes."
|
||||
"subblocs d'aquest bloc de notes."
|
||||
|
||||
#: packages/app-cli/app/command-rmnote.js:13
|
||||
msgid "Deletes the notes matching <note-pattern>."
|
||||
@@ -581,8 +583,8 @@ msgid ""
|
||||
"Start, stop or check the API server. To specify on which port it should run, "
|
||||
"set the api.port config variable. Commands are (%s)."
|
||||
msgstr ""
|
||||
"Arrenca, atura o verifica el servidor API. Per especificar a quin port ha de "
|
||||
"córrer, estableix la variable api.port. Les ordres són (%s)."
|
||||
"Arrenca, atura o verifica el servidor API. Per a especificar a quin port ha "
|
||||
"de córrer, estableix la variable api.port. Les ordres són (%s)."
|
||||
|
||||
#: packages/app-cli/app/command-server.js:38
|
||||
#, javascript-format
|
||||
@@ -620,8 +622,8 @@ msgstr "Mostra un resum sobre les notes i blocs de notes."
|
||||
msgid ""
|
||||
"To retry decryption of these items. Run `e2ee decrypt --retry-failed-items`"
|
||||
msgstr ""
|
||||
"Per a reintentar el desxifratge d'aquests elements. Executa `e2ee decrypt --"
|
||||
"retry-failed-items`"
|
||||
"Per a reintentar el desxifratge d'aquests elements. Executa \"e2ee decrypt --"
|
||||
"retry-failed-items\""
|
||||
|
||||
#: packages/app-cli/app/command-sync.js:29
|
||||
msgid "Synchronises with remote storage."
|
||||
@@ -658,7 +660,7 @@ msgstr ""
|
||||
#: packages/app-desktop/gui/DropboxLoginScreen.js:30
|
||||
#: packages/app-mobile/components/screens/dropbox-login.js:59
|
||||
msgid "Step 1: Open this URL in your browser to authorise the application:"
|
||||
msgstr "Pas 1: Obriu aquest URL al navegador per autoritzar l'aplicació:"
|
||||
msgstr "Pas 1: Obriu aquest URL al navegador per a autoritzar l'aplicació:"
|
||||
|
||||
#: packages/app-cli/app/command-sync.js:94
|
||||
#: packages/app-desktop/gui/DropboxLoginScreen.js:32
|
||||
@@ -738,9 +740,9 @@ msgid ""
|
||||
"target is a regular note it will be converted to a to-do). Use \"clear\" to "
|
||||
"convert the to-do back to a regular note."
|
||||
msgstr ""
|
||||
"<todo-command> pot ser «toggle» o «clear». Useu «toggle» per a canviar el "
|
||||
"<todo-command> pot ser «toggle» o «clear». Useu «toggle» per a canviar els "
|
||||
"llistats de tasques entre l'estat de finalitzat i no finalitzat (si "
|
||||
"l'objectiu és una nota normal es convertirà a un llistat de tasques "
|
||||
"l'objectiu és una nota normal, es convertirà a un llistat de tasques "
|
||||
"pendents). Useu «clear» per a convertir un llistat de tasques pendents a una "
|
||||
"nota normal."
|
||||
|
||||
@@ -795,7 +797,7 @@ msgstr ""
|
||||
|
||||
#: packages/app-cli/app/gui/NoteWidget.js:50
|
||||
msgid "You may also type `status` for more information."
|
||||
msgstr "També podeu escriure `status` per obtenir més informació."
|
||||
msgstr "També podeu escriure \"status\" per a obtenir més informació."
|
||||
|
||||
#: packages/app-cli/app/help-utils.js:56
|
||||
msgid "Enum"
|
||||
@@ -894,7 +896,7 @@ msgstr "Cancel·la"
|
||||
msgid ""
|
||||
"The app is now going to close. Please relaunch it to complete the process."
|
||||
msgstr ""
|
||||
"L'aplicació es tancarà ara. Si us plau, torneu-la a executar per completar "
|
||||
"L'aplicació es tancarà ara. Si us plau, torneu-la a executar per a completar "
|
||||
"el procés."
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:171
|
||||
@@ -904,7 +906,7 @@ msgstr "La versió actual està actualitzada."
|
||||
#: packages/app-desktop/checkForUpdates.js:184
|
||||
#, javascript-format
|
||||
msgid "%s (pre-release)"
|
||||
msgstr "%s (pre-llençament)"
|
||||
msgstr "%s (prellançament)"
|
||||
|
||||
#: packages/app-desktop/checkForUpdates.js:187
|
||||
msgid "An update is available, do you want to download it now?"
|
||||
@@ -974,7 +976,7 @@ msgstr "Esteu segur que voleu renovar el testimoni d'autorització?"
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:84
|
||||
msgid "The web clipper service is enabled and set to auto-start."
|
||||
msgstr ""
|
||||
"El servei de desa-retalls de webs és actiu i configurat per a iniciar-se "
|
||||
"El servei de porta-retalls de webs és actiu i configurat per a iniciar-se "
|
||||
"automàticament."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:69
|
||||
@@ -992,17 +994,17 @@ msgstr "Estat: %s"
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:74
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:103
|
||||
msgid "Disable Web Clipper Service"
|
||||
msgstr "Desactiva el servei del desa-retalls de webs"
|
||||
msgstr "Desactiva el servei del porta-retalls de webs"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:77
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:109
|
||||
msgid "The web clipper service is not enabled."
|
||||
msgstr "El servei del desa-retalls de webs no està activat."
|
||||
msgstr "El servei del porta-retalls de webs no està activat."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:78
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:114
|
||||
msgid "Enable Web Clipper Service"
|
||||
msgstr "Activa el servei del desa-retalls de webs"
|
||||
msgstr "Activa el servei del porta-retalls de webs"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:89
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:137
|
||||
@@ -1010,18 +1012,18 @@ msgid ""
|
||||
"Joplin Web Clipper allows saving web pages and screenshots from your browser "
|
||||
"to Joplin."
|
||||
msgstr ""
|
||||
"El desa-retalls de webs del Joplin us permet desar pàgines web i captures de "
|
||||
"pantalla del navegador web al Joplin."
|
||||
"El porta-retalls de webs del Joplin us permet desar pàgines web i captures "
|
||||
"de pantalla del navegador web al Joplin."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:90
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:142
|
||||
msgid "In order to use the web clipper, you need to do the following:"
|
||||
msgstr "Per a poder usar el desa-retalls de webs, cal que feu el següent:"
|
||||
msgstr "Per a poder usar el porta-retalls de webs, cal que feu el següent:"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:92
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:150
|
||||
msgid "Step 1: Enable the clipper service"
|
||||
msgstr "Pas 1: Activeu el servei del desa-retalls de webs"
|
||||
msgstr "Pas 1: Activeu el servei del porta-retalls de webs"
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:93
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.min.js:155
|
||||
@@ -1031,7 +1033,7 @@ msgid ""
|
||||
"to a particular port."
|
||||
msgstr ""
|
||||
"Aquest servei permet que l'extensió del navegador pugui comunicar-se amb el "
|
||||
"Joplin. En activar-la, el tallafocs us podria demanar de donar permís al "
|
||||
"Joplin. En activar-la, el tallafoc us podria demanar de donar permís al "
|
||||
"Joplin per a escoltar un port determinat."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:96
|
||||
@@ -1065,7 +1067,7 @@ msgid ""
|
||||
"This authorisation token is only needed to allow third-party applications to "
|
||||
"access Joplin."
|
||||
msgstr ""
|
||||
"Aquest testimoni d'autorització només és necessari per permetre l'accés "
|
||||
"Aquest testimoni d'autorització només és necessari per a permetre l'accés "
|
||||
"d'aplicacions de tercers al Joplin."
|
||||
|
||||
#: packages/app-desktop/gui/ClipperConfigScreen.js:111
|
||||
@@ -1171,7 +1173,7 @@ msgstr "Actualització"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js:154
|
||||
msgid "Please upgrade Joplin to use this plugin"
|
||||
msgstr "Si us plau, actualitzeu Joplin per utilitzar aquest connector"
|
||||
msgstr "Si us plau, actualitzeu Joplin per a utilitzar aquest connector"
|
||||
|
||||
#: packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js:168
|
||||
#, javascript-format
|
||||
@@ -1235,19 +1237,16 @@ msgid "Submit"
|
||||
msgstr "Tramet"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:73
|
||||
#, fuzzy
|
||||
msgid "Source: "
|
||||
msgstr "Font"
|
||||
msgstr "Font: "
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:76
|
||||
#, fuzzy
|
||||
msgid "Created: "
|
||||
msgstr "Creació: %s"
|
||||
msgstr "Data de creació: "
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:79
|
||||
#, fuzzy
|
||||
msgid "Updated: "
|
||||
msgstr "Actualitzat: %s"
|
||||
msgstr "Última actualització: "
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:84
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:96
|
||||
@@ -1257,9 +1256,8 @@ msgid "Save"
|
||||
msgstr "Desa"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:87
|
||||
#, fuzzy
|
||||
msgid "Disable"
|
||||
msgstr "Desactivat"
|
||||
msgstr "Desactiva"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:87
|
||||
#: packages/app-mobile/components/screens/encryption-config.js:157
|
||||
@@ -1318,9 +1316,9 @@ msgid ""
|
||||
"You may use the tool below to re-encrypt your data, for example if you know "
|
||||
"that some of your notes are encrypted with an obsolete encryption method."
|
||||
msgstr ""
|
||||
"Podeu utilitzar l'eina següent per re-xifrar les vostres dades, per exemple "
|
||||
"si sabeu que algunes de les vostres notes estan xifrades amb un mètode de "
|
||||
"xifratge obsolet."
|
||||
"Podeu utilitzar l'eina següent per tornar a xifrar les vostres dades, per "
|
||||
"exemple si sabeu que algunes de les vostres notes estan xifrades amb un "
|
||||
"mètode de xifratge obsolet."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:122
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:186
|
||||
@@ -1348,7 +1346,7 @@ msgstr ""
|
||||
"1. Sincronitzeu tots els vostres dispositius.\n"
|
||||
"2. Feu click en \"%s\".\n"
|
||||
"3. Deixeu-lo executant-se fins que acabi. Mentre s'executa, eviteu fer "
|
||||
"canvis en cap nota des dels altres dispositius per evitar els conflictes.\n"
|
||||
"canvis en cap nota des dels altres dispositius per a evitar els conflictes.\n"
|
||||
"4. Un cop la sincronització està acabada en aquest dispositiu, sincronitzeu "
|
||||
"tots els altres dispositius, i deixeu-los executant-se fins que acabin.\n"
|
||||
"\n"
|
||||
@@ -1371,11 +1369,11 @@ msgstr "Claus mestres"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:142
|
||||
msgid "Hide disabled master keys"
|
||||
msgstr ""
|
||||
msgstr "Amaga les claus mestres deshabilitades"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:142
|
||||
msgid "Show disabled master keys"
|
||||
msgstr ""
|
||||
msgstr "Mostra les claus mestres deshabilitades"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:143
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:343
|
||||
@@ -1384,7 +1382,7 @@ msgid ""
|
||||
"as \"active\"). Any of the keys might be used for decryption, depending on "
|
||||
"how the notes or notebooks were originally encrypted."
|
||||
msgstr ""
|
||||
"Nota: només s'usarà una clau mestre per al xifratge (la marcada com a "
|
||||
"Nota: només s'usarà una clau mestra per al xifratge (la marcada com a "
|
||||
"«activa»). Qualsevol de les claus es podrien usar per a desxifrar, depenent "
|
||||
"de com es van xifrar originalment les notes o blocs de notes."
|
||||
|
||||
@@ -1395,7 +1393,7 @@ msgstr "Activa"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:149
|
||||
msgid "Date"
|
||||
msgstr ""
|
||||
msgstr "Data"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:150
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:329
|
||||
@@ -1403,14 +1401,12 @@ msgid "Password"
|
||||
msgstr "Contrasenya"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:151
|
||||
#, fuzzy
|
||||
msgid "Valid"
|
||||
msgstr "Invàlid"
|
||||
msgstr "Vàlid"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:152
|
||||
#, fuzzy
|
||||
msgid "Actions"
|
||||
msgstr "Acció"
|
||||
msgstr "Accions"
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:182
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:244
|
||||
@@ -1450,9 +1446,9 @@ msgid ""
|
||||
"however the application does not currently have access to them. It is likely "
|
||||
"they will eventually be downloaded via synchronisation."
|
||||
msgstr ""
|
||||
"Les claus mestres amb aquests IDs s'usen per a xifrar alguns dels elements. "
|
||||
"Tot i això l'aplicació actualment no hi té accés. Probablement es baixin via "
|
||||
"sincrontizació."
|
||||
"Les claus mestres amb aquests ID s'usen per a xifrar alguns dels elements. "
|
||||
"Tot i això l'aplicació actualment no hi té accés. Probablement es baixaran "
|
||||
"via sincronització."
|
||||
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.js:226
|
||||
#: packages/app-desktop/gui/EncryptionConfigScreen.min.js:414
|
||||
@@ -1562,7 +1558,7 @@ msgid ""
|
||||
"Press the shortcut and then press ENTER. Or, press BACKSPACE to clear the "
|
||||
"shortcut."
|
||||
msgstr ""
|
||||
"Premeu la drecera i premeu RETORN. O, premeu RETROCÉS per esborrar la "
|
||||
"Premeu la drecera i premeu RETORN. O, premeu RETROCÉS per a esborrar la "
|
||||
"drecera."
|
||||
|
||||
#: packages/app-desktop/gui/KeymapConfig/ShortcutRecorder.js:51
|
||||
@@ -1684,13 +1680,12 @@ msgstr "Establiu la contrasenya"
|
||||
#: packages/app-desktop/gui/MainScreen/MainScreen.js:619
|
||||
msgid "Use the arrows to move the layout items. Press \"Escape\" to exit."
|
||||
msgstr ""
|
||||
"Utilitzeu les fletxes per moure els elements de la disposició. Premeu "
|
||||
"«Escapa» per sortir."
|
||||
"Utilitzeu les fletxes per a moure els elements de la disposició. Premeu "
|
||||
"«Escapa» per a sortir."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/commandPalette.js:18
|
||||
#, fuzzy
|
||||
msgid "Command palette..."
|
||||
msgstr "Paleta d'ordres"
|
||||
msgstr "Paleta d'ordres..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/editAlarm.js:20
|
||||
#: packages/app-mobile/components/SelectDateTimeDialog.js:84
|
||||
@@ -1792,9 +1787,8 @@ msgid "Share notebook..."
|
||||
msgstr "Comparteix el quadern de notes..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showShareNoteDialog.js:16
|
||||
#, fuzzy
|
||||
msgid "Publish note..."
|
||||
msgstr "Comparteix nota..."
|
||||
msgstr "Publica la nota..."
|
||||
|
||||
#: packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.js:19
|
||||
#: packages/lib/services/spellChecker/SpellCheckerService.js:180
|
||||
@@ -1882,7 +1876,7 @@ msgstr "&Visualitza"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:506
|
||||
msgid "Layout button sequence"
|
||||
msgstr "Seqûència del botó de disposició"
|
||||
msgstr "Seqüència del botó de disposició"
|
||||
|
||||
#: packages/app-desktop/gui/MenuBar.js:551
|
||||
#: packages/app-desktop/gui/MenuBar.js:557
|
||||
@@ -2056,7 +2050,7 @@ msgid ""
|
||||
"switch to %s to edit the note."
|
||||
msgstr ""
|
||||
"Espereu que tots els adjunts hagin estat descarregats i desxifrats. També "
|
||||
"podeu canviar a %s per editar la nota."
|
||||
"podeu canviar a %s per a editar la nota."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteBody/TinyMCE/plugins/lists.js:2151
|
||||
msgid "Checkbox list"
|
||||
@@ -2089,7 +2083,7 @@ msgstr "Subíndex"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.js:285
|
||||
msgid "Click to add tags..."
|
||||
msgstr "Feu clic per afegir etiquetes..."
|
||||
msgstr "Feu clic per a afegir etiquetes..."
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/NoteEditor.js:339
|
||||
msgid ""
|
||||
@@ -2231,7 +2225,7 @@ msgstr "Cerca en la nota actual"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.js:59
|
||||
msgid "There was an error downloading this attachment:"
|
||||
msgstr "Hi ha hagut un error al descarregar aquest adjunt:"
|
||||
msgstr "Hi ha hagut un error en baixar aquest adjunt:"
|
||||
|
||||
#: packages/app-desktop/gui/NoteEditor/utils/contextMenu.js:62
|
||||
#: packages/lib/services/ResourceEditWatcher/index.js:196
|
||||
@@ -2266,7 +2260,7 @@ msgstr "Missatge o enllaç no suportat: %s"
|
||||
|
||||
#: packages/app-desktop/gui/NoteList/NoteList.js:163
|
||||
msgid "Custom order"
|
||||
msgstr "Ordrenació personalitzada"
|
||||
msgstr "Ordenació personalitzada"
|
||||
|
||||
#: packages/app-desktop/gui/NoteList/NoteList.js:163
|
||||
msgid "View"
|
||||
@@ -2283,8 +2277,8 @@ msgid ""
|
||||
"To manually sort the notes, the sort order must be changed to \"%s\" in the "
|
||||
"menu \"%s\" > \"%s\""
|
||||
msgstr ""
|
||||
"Per ordenar les notes manualment, l'ordre de classificació s'ha de canviar a "
|
||||
"\"%s\" en el menú \"%s\" > \"%s\""
|
||||
"Per a ordenar les notes manualment, l'ordre de classificació s'ha de canviar "
|
||||
"a \"%s\" en el menú \"%s\" > \"%s\""
|
||||
|
||||
#: packages/app-desktop/gui/NoteList/NoteList.js:415
|
||||
msgid "No notes in here. Create one by clicking on \"New note\"."
|
||||
@@ -2340,7 +2334,7 @@ msgid ""
|
||||
"Click \"%s\" to restore the note. It will be copied in the notebook named "
|
||||
"\"%s\". The current version of the note will not be replaced or modified."
|
||||
msgstr ""
|
||||
"Cliqueu «%s» per restaurar la nota. Aquesta serà copiada al el notebook "
|
||||
"Feu clic «%s» per a restaurar la nota. Aquesta serà copiada al bloc de notes "
|
||||
"anomenat «%s». La versió actual de la nota no serà substituïda o modificada."
|
||||
|
||||
#: packages/app-desktop/gui/PromptDialog.min.js:249
|
||||
@@ -2376,7 +2370,7 @@ msgid ""
|
||||
"notes. Please be careful when deleting one of them as they cannot be "
|
||||
"restored afterwards."
|
||||
msgstr ""
|
||||
"Aquesta és una eina avançada per mostrar els adjunts que estan enllaçats a "
|
||||
"Aquesta és una eina avançada per a mostrar els adjunts que estan enllaçats a "
|
||||
"les vostres notes. Tingueu precaució en suprimir-ne un, ja que després no es "
|
||||
"poden restaurar."
|
||||
|
||||
@@ -2391,18 +2385,18 @@ msgstr ""
|
||||
"Avís: no es mostren tots els recursos per motius de rendiment (límit:% s)."
|
||||
|
||||
#: packages/app-desktop/gui/Root.js:106
|
||||
#, fuzzy
|
||||
msgid "Confirmation"
|
||||
msgstr "Configuració"
|
||||
msgstr "Confirmació"
|
||||
|
||||
#: packages/app-desktop/gui/Root.js:119
|
||||
msgid "The Web Clipper needs your authorisation to access your data."
|
||||
msgstr ""
|
||||
"El porta-retalls web necessita autorització per a accedir a les vostres "
|
||||
"dades."
|
||||
|
||||
#: packages/app-desktop/gui/Root.js:120
|
||||
#, fuzzy
|
||||
msgid "Grant authorisation"
|
||||
msgstr "Testimoni d'autorització:"
|
||||
msgstr "Autoritza"
|
||||
|
||||
#: packages/app-desktop/gui/Root.js:160
|
||||
msgid "OneDrive Login"
|
||||
@@ -2475,9 +2469,8 @@ msgid "Share Notebook"
|
||||
msgstr "Comparteix el quadern de notes"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:144
|
||||
#, fuzzy
|
||||
msgid "Unpublish note"
|
||||
msgstr "Deixa de compartir la nota"
|
||||
msgstr "Deixa de publicar la nota"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:171
|
||||
msgid "Synchronising..."
|
||||
@@ -2502,7 +2495,7 @@ msgstr "Nota: quan es comparteix una nota, deixa d'estar xifrada al servidor."
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:187
|
||||
msgid "Publish Notes"
|
||||
msgstr ""
|
||||
msgstr "Publica notes"
|
||||
|
||||
#: packages/app-desktop/gui/ShareNoteDialog.js:189
|
||||
msgid "Copy Shareable Link"
|
||||
@@ -2524,7 +2517,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Voleu suprimir el bloc de notes \"%s\"? \n"
|
||||
"\n"
|
||||
"També se suprimiran totes les notes i els sub-blocs d'aquest bloc de notes."
|
||||
"També se suprimiran totes les notes i els subblocs d'aquest bloc de notes."
|
||||
|
||||
#: packages/app-desktop/gui/Sidebar/Sidebar.js:197
|
||||
#, javascript-format
|
||||
@@ -2591,22 +2584,21 @@ msgid "Export debug report"
|
||||
msgstr "Exporta l'informe de depuració"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:157
|
||||
#, fuzzy
|
||||
msgid "Sync your notes"
|
||||
msgstr "Ordena les notes per"
|
||||
msgstr "Sincronitzeu les notes"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:158
|
||||
msgid "Publish notes to the internet"
|
||||
msgstr ""
|
||||
msgstr "Publica les notes a internet"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:159
|
||||
#, fuzzy
|
||||
msgid "Collaborate on notebooks with others"
|
||||
msgstr "Primer heu de crear un bloc de notes"
|
||||
msgstr "Col·laboreu en blocs de notes amb altres"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:182
|
||||
msgid "Thank you! Your Joplin Cloud account is now setup and ready to use."
|
||||
msgstr ""
|
||||
"Gràcies! El vostre compte de Joplin Cloud està preparat per a utilitzar-se."
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:190
|
||||
#, javascript-format
|
||||
@@ -2616,30 +2608,35 @@ msgid ""
|
||||
"\n"
|
||||
"%s"
|
||||
msgstr ""
|
||||
"S'ha produït un error configurant el vostre compte de Joplin Cloud. "
|
||||
"Verifiqueu el vostre correu electrònic i la contrasenya, i proveu de nou. "
|
||||
"L'error ha sigut:\n"
|
||||
"\n"
|
||||
"%s"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:203
|
||||
msgid "Login below."
|
||||
msgstr ""
|
||||
msgstr "Identifiqueu-vos baix."
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:205
|
||||
#, fuzzy
|
||||
msgid "Or create an account."
|
||||
msgstr "Crea una nota nova."
|
||||
msgstr "O creeu un compte."
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:210
|
||||
msgid "Login"
|
||||
msgstr ""
|
||||
msgstr "Identifiqueu-vos"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:231
|
||||
#, fuzzy
|
||||
msgid "Select"
|
||||
msgstr "Seleccioneu tot"
|
||||
msgstr "Selecciona"
|
||||
|
||||
#: packages/app-desktop/gui/SyncWizard/Dialog.js:278
|
||||
msgid ""
|
||||
"Joplin can synchronise your notes using various providers. Select one from "
|
||||
"the list below."
|
||||
msgstr ""
|
||||
"Joplin pot sincronitzar les notes utilitzant diversos proveïdors. "
|
||||
"Seleccioneu-ne un de la llista inferior."
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.js:43
|
||||
msgid "Duplicate"
|
||||
@@ -2656,7 +2653,7 @@ msgstr "Alterna entre el tipus nota i tasques pendents"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.js:87
|
||||
msgid "Switch to note type"
|
||||
msgstr "Canvia el tipos a nota"
|
||||
msgstr "Canvia el tipus a nota"
|
||||
|
||||
#: packages/app-desktop/gui/utils/NoteListUtils.js:93
|
||||
msgid "Switch to to-do type"
|
||||
@@ -2683,9 +2680,9 @@ msgid ""
|
||||
"by a tag name, or @ followed by a notebook name. Or type : to search for "
|
||||
"commands."
|
||||
msgstr ""
|
||||
"Escriviu un títol de nota o part del seu contingut per saltar a la nota. O "
|
||||
"Escriviu un títol de nota o part del seu contingut per a saltar a la nota. O "
|
||||
"escriviu # seguit d'un nom d'etiqueta, o @ seguit d'un nom de bloc de notes. "
|
||||
"O escriviu : per a cercar entre les ordres."
|
||||
"O escriviu \":\" per a cercar entre les ordres."
|
||||
|
||||
#: packages/app-desktop/plugins/GotoAnything.js:505
|
||||
msgid "Command palette"
|
||||
@@ -2705,11 +2702,11 @@ msgstr "No"
|
||||
|
||||
#: packages/app-mobile/components/CameraView.js:158
|
||||
msgid "Permission to use camera"
|
||||
msgstr "Permís per utilitzar la càmara"
|
||||
msgstr "Permís per a utilitzar la càmera"
|
||||
|
||||
#: packages/app-mobile/components/CameraView.js:159
|
||||
msgid "Your permission to use your camera is required."
|
||||
msgstr "Es necessita el vostre permís per utilitzar la càmara."
|
||||
msgstr "Es necessita el vostre permís per a utilitzar la càmera."
|
||||
|
||||
#: packages/app-mobile/components/NoteBodyViewer/hooks/useOnResourceLongPress.js:27
|
||||
msgid "Open"
|
||||
@@ -2749,8 +2746,8 @@ msgid ""
|
||||
"In order to use file system synchronisation your permission to write to "
|
||||
"external storage is required."
|
||||
msgstr ""
|
||||
"Per utilitzar la sincronització del sistema de fitxers, cal el vostre permís "
|
||||
"per escriure a l'emmagatzematge extern."
|
||||
"Per a utilitzar la sincronització del sistema de fitxers, cal el vostre "
|
||||
"permís per a escriure a l'emmagatzematge extern."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.js:152
|
||||
msgid "Information"
|
||||
@@ -2794,8 +2791,8 @@ msgid ""
|
||||
"Use this to rebuild the search index if there is a problem with search. It "
|
||||
"may take a long time depending on the number of notes."
|
||||
msgstr ""
|
||||
"Utilitzeu això per reconstruir l'índex de cerca si hi ha un problema amb la "
|
||||
"cerca. Pot trigar molt en funció del nombre de notes."
|
||||
"Utilitzeu això per a reconstruir l'índex de cerca si hi ha un problema amb "
|
||||
"la cerca. Pot trigar molt en funció del nombre de notes."
|
||||
|
||||
#: packages/app-mobile/components/screens/ConfigScreen.js:425
|
||||
msgid "Exporting profile..."
|
||||
@@ -2907,7 +2904,7 @@ msgid ""
|
||||
"You may turn off this option at any time in the Configuration screen."
|
||||
msgstr ""
|
||||
"Per tal d'associar una geolocalització a la nota, l'aplicació necessita "
|
||||
"permís per accedir a la vostra ubicació.\n"
|
||||
"permís per a accedir a la vostra ubicació.\n"
|
||||
"\n"
|
||||
"Podeu desactivar aquesta opció en qualsevol moment a la pantalla de "
|
||||
"configuració."
|
||||
@@ -2964,7 +2961,7 @@ msgstr "Fes una foto"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.js:807
|
||||
msgid "Choose an option"
|
||||
msgstr "Esculliu una opció"
|
||||
msgstr "Escolliu una opció"
|
||||
|
||||
#: packages/app-mobile/components/screens/Note.js:840
|
||||
msgid "Convert to note"
|
||||
@@ -3110,6 +3107,9 @@ msgid ""
|
||||
"Joplin's own sync service. Also gives access to Joplin-specific features "
|
||||
"such as publishing notes or collaborating on notebooks with others."
|
||||
msgstr ""
|
||||
"El servei de sincronització propi de Joplin. També us dona accés a "
|
||||
"funcionalitats específiques de Joplin, com la publicació de notes o la "
|
||||
"col·laboració en blocs de notes amb altres."
|
||||
|
||||
#: packages/lib/SyncTargetJoplinServer.js:60
|
||||
msgid "Joplin Server"
|
||||
@@ -3121,7 +3121,7 @@ msgstr "Nextcloud"
|
||||
|
||||
#: packages/lib/SyncTargetNone.js:22
|
||||
msgid "(None)"
|
||||
msgstr ""
|
||||
msgstr "(cap)"
|
||||
|
||||
#: packages/lib/SyncTargetOneDrive.js:32
|
||||
msgid "OneDrive"
|
||||
@@ -3186,7 +3186,7 @@ msgstr "Ociós"
|
||||
|
||||
#: packages/lib/Synchronizer.js:277
|
||||
msgid "In progress"
|
||||
msgstr "En progés"
|
||||
msgstr "En progrés"
|
||||
|
||||
#: packages/lib/Synchronizer.js:984
|
||||
msgid ""
|
||||
@@ -3229,7 +3229,7 @@ msgid ""
|
||||
"\n"
|
||||
"Please try again."
|
||||
msgstr ""
|
||||
"No s'ha pogut autoritzar l'aplciació:\n"
|
||||
"No s'ha pogut autoritzar l'aplicació:\n"
|
||||
"\n"
|
||||
"%s\n"
|
||||
"\n"
|
||||
@@ -3269,7 +3269,7 @@ msgstr "Elements desxifrats: %s / %s"
|
||||
#: packages/lib/components/shared/encryption-config-shared.js:151
|
||||
#, javascript-format
|
||||
msgid "Encryption will be enabled using the master key created on %s"
|
||||
msgstr ""
|
||||
msgstr "El xifrat s'habilitarà utilitzant la clau mestra creada a %s"
|
||||
|
||||
#: packages/lib/models/BaseItem.js:721
|
||||
msgid "Encrypted"
|
||||
@@ -3339,9 +3339,8 @@ msgid "Error"
|
||||
msgstr "Error"
|
||||
|
||||
#: packages/lib/models/Resource.js:408
|
||||
#, fuzzy
|
||||
msgid "Conflicts (attachments)"
|
||||
msgstr "Adjunts de la nota"
|
||||
msgstr "Conflictes (adjunts)"
|
||||
|
||||
#: packages/lib/models/Resource.js:422
|
||||
#, javascript-format
|
||||
@@ -3381,7 +3380,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Atenció: si canvieu aquesta ubicació, assegureu-vos de copiar-hi tot el "
|
||||
"contingut abans de sincronitzar; en cas contrari, se suprimiran tots els "
|
||||
"fitxers. Consulteu les FAQ per obtenir més detalls: %s"
|
||||
"fitxers. Consulteu les FAQ per a obtenir més detalls: %s"
|
||||
|
||||
#: packages/lib/models/Setting.js:124
|
||||
msgid "Light"
|
||||
@@ -3417,7 +3416,7 @@ msgstr "OLED fosc"
|
||||
|
||||
#: packages/lib/models/Setting.js:152
|
||||
msgid "Open Sync Wizard..."
|
||||
msgstr ""
|
||||
msgstr "Obre l'assistent de sincronització..."
|
||||
|
||||
#: packages/lib/models/Setting.js:162
|
||||
msgid "Synchronisation target"
|
||||
@@ -3543,7 +3542,7 @@ msgstr "Tema"
|
||||
|
||||
#: packages/lib/models/Setting.js:510
|
||||
msgid "Automatically switch theme to match system theme"
|
||||
msgstr "Canvia de tema automàticament per coincidir amb el tema del sistema"
|
||||
msgstr "Canvia de tema automàticament per a coincidir amb el tema del sistema"
|
||||
|
||||
#: packages/lib/models/Setting.js:522
|
||||
msgid "Preferred light theme"
|
||||
@@ -3555,7 +3554,7 @@ msgstr "Tema fosc preferit"
|
||||
|
||||
#: packages/lib/models/Setting.js:546
|
||||
msgid "Show note counts"
|
||||
msgstr "Mostra el número de notes"
|
||||
msgstr "Mostra el nombre de notes"
|
||||
|
||||
#: packages/lib/models/Setting.js:554 packages/lib/models/Setting.js:556
|
||||
#: packages/lib/models/Setting.js:557
|
||||
@@ -3619,7 +3618,7 @@ msgstr "Activa els salts de línia"
|
||||
|
||||
#: packages/lib/models/Setting.js:683
|
||||
msgid "Enable typographer support"
|
||||
msgstr "Activa el soport tipogràfic"
|
||||
msgstr "Activa el suport tipogràfic"
|
||||
|
||||
#: packages/lib/models/Setting.js:684
|
||||
msgid "Enable Linkify"
|
||||
@@ -3704,7 +3703,7 @@ msgid ""
|
||||
"reducing the number of conflicts."
|
||||
msgstr ""
|
||||
"Això permetrà que Joplin s’executi en segon pla. Es recomana habilitar "
|
||||
"aquesta configuració perquè les notes es sincronitzin constantment, reduint "
|
||||
"aquesta configuració perquè les notes se sincronitzin constantment, reduint "
|
||||
"així el nombre de conflictes."
|
||||
|
||||
#: packages/lib/models/Setting.js:717
|
||||
@@ -3726,7 +3725,7 @@ msgstr "Per defecte"
|
||||
|
||||
#: packages/lib/models/Setting.js:769
|
||||
msgid "Editor font family"
|
||||
msgstr "Famíllia de lletra de l'editor"
|
||||
msgstr "Família de lletra de l'editor"
|
||||
|
||||
#: packages/lib/models/Setting.js:770
|
||||
msgid ""
|
||||
@@ -3752,11 +3751,11 @@ msgstr ""
|
||||
|
||||
#: packages/lib/models/Setting.js:783
|
||||
msgid "Editor maximum width"
|
||||
msgstr ""
|
||||
msgstr "Amplada màxima de l'editor"
|
||||
|
||||
#: packages/lib/models/Setting.js:783
|
||||
msgid "Set it to 0 to make it take the complete available space."
|
||||
msgstr ""
|
||||
msgstr "Definiu-ho com a 0 per a fer que agafi tot l'espai disponible."
|
||||
|
||||
#: packages/lib/models/Setting.js:802
|
||||
msgid "Custom stylesheet for rendered Markdown"
|
||||
@@ -3768,7 +3767,7 @@ msgstr "Full d'estil personalitzat per a estils d'aplicacions de tot Joplin"
|
||||
|
||||
#: packages/lib/models/Setting.js:828
|
||||
msgid "Re-upload local data to sync target"
|
||||
msgstr "Torna a pujar les dades locals per sincronitzar la destinació"
|
||||
msgstr "Torna a pujar les dades locals per a sincronitzar la destinació"
|
||||
|
||||
#: packages/lib/models/Setting.js:838
|
||||
msgid "Delete local data and re-download from sync target"
|
||||
@@ -3782,12 +3781,12 @@ msgstr "Actualitza automàticament l'aplicació"
|
||||
|
||||
#: packages/lib/models/Setting.js:844
|
||||
msgid "Get pre-releases when checking for updates"
|
||||
msgstr "Obtén pre-llançaments quan cerqui actualitzacions"
|
||||
msgstr "Obtén prellançaments quan cerqui actualitzacions"
|
||||
|
||||
#: packages/lib/models/Setting.js:844
|
||||
#, javascript-format
|
||||
msgid "See the pre-release page for more details: %s"
|
||||
msgstr "Consulta la pàgina de pre-llançament per a més detalls: %s"
|
||||
msgstr "Consulta la pàgina de prellançament per a més detalls: %s"
|
||||
|
||||
#: packages/lib/models/Setting.js:852
|
||||
msgid "Synchronisation interval"
|
||||
@@ -3880,7 +3879,7 @@ msgstr "Vim"
|
||||
|
||||
#: packages/lib/models/Setting.js:920
|
||||
msgid "Do not resize images"
|
||||
msgstr ""
|
||||
msgstr "No canvies la mida de les imatges"
|
||||
|
||||
#: packages/lib/models/Setting.js:935
|
||||
msgid "Custom TLS certificates"
|
||||
@@ -3895,9 +3894,9 @@ msgid ""
|
||||
msgstr ""
|
||||
"Una llista separada per comes de camins a directoris d'on carregar els "
|
||||
"certificats, o el camí a fitxers de certificats concrets. Per exemple, "
|
||||
"el_meu/dir_cert, /altres/personalitzat.pem. Tingueu en compte que si feu "
|
||||
"canvis en la configuració TLS, cal que els deseu abans de fer clic a "
|
||||
"«Comprova la configuració de la sincronització»."
|
||||
"directori/subdirectori_certificats, /altres/personalitzat.pem. Tingueu en "
|
||||
"compte que si feu canvis en la configuració TLS, cal que els deseu abans de "
|
||||
"fer clic a «Comprova la configuració de la sincronització»."
|
||||
|
||||
#: packages/lib/models/Setting.js:958
|
||||
msgid "Ignore TLS certificate errors"
|
||||
@@ -3953,10 +3952,11 @@ msgid ""
|
||||
"item with a factor of 2 will take twice as much space as an item with a "
|
||||
"factor of 1.Restart app to see changes."
|
||||
msgstr ""
|
||||
"La propietat factor defineix com l’element creixerà o es reduirà per ajustar-"
|
||||
"se a l’espai disponible al seu contenidor respecte als altres elements. Per "
|
||||
"tant, un element amb un factor de 2 ocuparà el doble d’espai que un element "
|
||||
"amb un factor de 1. Reinicieu l’aplicació per veure els canvis."
|
||||
"La propietat factor defineix com l’element creixerà o es reduirà per a "
|
||||
"ajustar-se a l’espai disponible al seu contenidor respecte als altres "
|
||||
"elements. Per tant, un element amb un factor de 2 ocuparà el doble d’espai "
|
||||
"que un element amb un factor d'1. Reinicieu l’aplicació per a veure els "
|
||||
"canvis."
|
||||
|
||||
#: packages/lib/models/Setting.js:1029
|
||||
msgid "Note list growth factor"
|
||||
@@ -4019,7 +4019,7 @@ msgid ""
|
||||
"formatting. It is indicated below which plugins are compatible or not with "
|
||||
"the WYSIWYG editor."
|
||||
msgstr ""
|
||||
"Aquests connectors milloren el renderitzador de Markdown amb funcions "
|
||||
"Aquestes extensions milloren el renderitzador de Markdown amb funcions "
|
||||
"addicionals. Tingueu en compte que, tot i que aquestes funcions poden ser "
|
||||
"útils, no són Markdown estàndard i, per tant, la majoria només funcionaran "
|
||||
"dins de Joplin. A més, alguns d’ells són *incompatibles* amb l’editor "
|
||||
@@ -4065,7 +4065,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Obriu l'URL següent al navegador per a autenticar l'aplicació. L'aplicació "
|
||||
"crearà un directori a «Aplicacions/Joplin» i només llegirà i escriurà "
|
||||
"fitxers en aquest directory. No tindrà accés a cap fitxer fora d'aquesta "
|
||||
"fitxers en aquest directory. No tindrà accés a cap fitxer fora d'aquest "
|
||||
"directori ni a cap dada personal. No es compartirà cap dada amb terceres "
|
||||
"parts."
|
||||
|
||||
@@ -4121,7 +4121,7 @@ msgid ""
|
||||
"target. In order to find these items, either search for the title or the ID "
|
||||
"(which is displayed in brackets above)."
|
||||
msgstr ""
|
||||
"Aquests elements es mantindran al dispositiu però no es pujaran a la "
|
||||
"Aquests elements es mantindran al dispositiu, però no es pujaran a la "
|
||||
"destinació de sincronització. Per a poder trobar aquests elements, podeu "
|
||||
"cercar pel títol o la ID (que es mostra entre claudàtors a sobre)."
|
||||
|
||||
@@ -4204,7 +4204,7 @@ msgstr "Conflictius: %d"
|
||||
#: packages/lib/services/ReportService.js:242
|
||||
#, javascript-format
|
||||
msgid "To delete: %d"
|
||||
msgstr "Per suprimir: %d"
|
||||
msgstr "Per a suprimir: %d"
|
||||
|
||||
#: packages/lib/services/ReportService.js:244
|
||||
msgid "Folders"
|
||||
@@ -4276,7 +4276,7 @@ msgstr "Indiqueu el format d'importació per a %s"
|
||||
|
||||
#: packages/lib/services/interop/InteropService_Exporter_Jex.js:43
|
||||
msgid "There is no data to export."
|
||||
msgstr "No hi ha dades per exportar."
|
||||
msgstr "No hi ha dades per a exportar."
|
||||
|
||||
#: packages/lib/services/interop/InteropService_Importer_Md.js:46
|
||||
msgid "Please specify the notebook where the notes should be imported to."
|
||||
@@ -4356,16 +4356,18 @@ msgid "attachment"
|
||||
msgstr "fitxer adjunt"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:199
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid "Cannot save %s \"%s\" because it is larger than the allowed limit (%s)"
|
||||
msgstr "No es pot desar %s «%s» perquè és més gran que el límit permès (%s)"
|
||||
|
||||
#: packages/server/dist/models/UserModel.js:204
|
||||
#, fuzzy, javascript-format
|
||||
#, javascript-format
|
||||
msgid ""
|
||||
"Cannot save %s \"%s\" because it would go over the total allowed size (%s) "
|
||||
"for this account"
|
||||
msgstr "No es pot desar %s «%s» perquè és més gran que el límit permès (%s)"
|
||||
msgstr ""
|
||||
"No es pot desar %s «%s» perquè se'n passaria de l'espai total (%s) d'aquest "
|
||||
"compte"
|
||||
|
||||
#, javascript-format
|
||||
#~ msgid "%s %s (%s)"
|
||||
|
||||
@@ -8,7 +8,7 @@ In some cases however, the extra markup format that appears in notes can be seen
|
||||
|
||||
However **there is a catch**: in Joplin, notes, even when edited with this Rich Text editor, are **still Markdown** under the hood. This is generally a good thing, because it means you can switch at any time between Markdown and Rich Text editor, and the note is still readable. It is also good if you sync with the mobile application, which doesn't have a rich text editor. The catch is that since Markdown is used under the hood, it means the rich text editor has a number of limitations it inherits from that format:
|
||||
|
||||
- For a start, **most Markdown plugins will not be compatible**. If you open a Markdown note that makes use of such plugin in the Rich Text editor, it is likely you will lose the plugin special formatting. The only supported plugins are the "fenced" plugins - those that wrap a section of text in triple backticks (for example, KaTeX, Mermaid, etc. are working). You can see on the Markdown config screen which plugins that are compatible or not.
|
||||
- For a start, **most Markdown plugins will not be compatible**. If you open a Markdown note that makes use of such plugin in the Rich Text editor, it is likely you will lose the plugin special formatting. The only supported plugins are the "fenced" plugins - those that wrap a section of text in triple backticks (for example, KaTeX, Mermaid, etc. are working). You can see a plugin's compatibility on the Markdown config screen.
|
||||
|
||||
- It is not possible to have multiple new lines in a row. Because in Markdown, multiple new lines would be collapsed into one when rendered.
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# 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.
|
||||
@@ -20,15 +20,15 @@ Any kind of file can be attached to a note. In Markdown, links to these files ar
|
||||
|
||||
Images can be attached either by clicking on "Attach file" or by pasting (with `Ctrl+V` or `Cmd+V`) an image directly in the editor, or by drag and dropping an image.
|
||||
|
||||
More info about attachments: https://joplinapp.org#attachments--resources
|
||||
More info about attachments: https://joplinapp.org/help/#attachments
|
||||
|
||||
## Search
|
||||
|
||||
Joplin supports advanced search queries, which are fully documented on the official website: https://joplinapp.org#searching
|
||||
Joplin supports advanced search queries, which are fully documented on the official website: https://joplinapp.org/help/#searching
|
||||
|
||||
## Alarms
|
||||
|
||||
An alarm can be associated with any to-do. It will be triggered at the given time by displaying a notification. To use this feature, see the documentation: https://joplinapp.org#notifications
|
||||
An alarm can be associated with any to-do. It will be triggered at the given time by displaying a notification. To use this feature, see the documentation: https://joplinapp.org/help/#notifications
|
||||
|
||||
## Markdown advanced tips
|
||||
|
||||
@@ -56,7 +56,7 @@ f(x) = \int_{-\infty}^\infty
|
||||
\,d\xi
|
||||
$$
|
||||
|
||||
Various other tricks are possible, such as using HTML, or customising the CSS. See the Markdown documentation for more info - https://joplinapp.org#markdown
|
||||
Various other tricks are possible, such as using HTML, or customising the CSS. See the Markdown documentation for more info - https://joplinapp.org/markdown/
|
||||
|
||||
## Community and further help
|
||||
|
||||
|
||||
Reference in New Issue
Block a user