1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-23 22:36:32 +02:00

Mobile,Desktop: Resolves #11872: Explain why items could not be decrypted (#12048)

This commit is contained in:
Henry Heino
2025-04-07 12:03:55 -07:00
committed by GitHub
parent 59447f4c45
commit 338dabf5da
7 changed files with 399 additions and 114 deletions

View File

@@ -1,16 +1,17 @@
import * as React from 'react';
import { View, Text, Button, FlatList, TextStyle, StyleSheet } from 'react-native';
import { View, Text, Button, FlatList, TextStyle, StyleSheet, Role } from 'react-native';
import Setting from '@joplin/lib/models/Setting';
import { connect } from 'react-redux';
import { ScreenHeader } from '../ScreenHeader';
import ReportService, { ReportSection } from '@joplin/lib/services/ReportService';
import ReportService, { ReportItemType, ReportSection } from '@joplin/lib/services/ReportService';
import { _ } from '@joplin/lib/locale';
import { BaseScreenComponent } from '../base-screen';
import { themeStyle } from '../global-style';
import { AppState } from '../../utils/types';
import checkDisabledSyncItemsNotification from '@joplin/lib/services/synchronizer/utils/checkDisabledSyncItemsNotification';
import { Dispatch } from 'redux';
import Icon from '../Icon';
interface Props {
themeId: number;
@@ -21,6 +22,86 @@ interface State {
report: ReportSection[];
}
interface ProcessedLine {
key: string;
text?: string;
isSection?: boolean;
isDivider?: boolean;
retryAllHandler?: ()=> void;
retryHandler?: ()=> void;
ignoreHandler?: ()=> void;
listItems?: ProcessedLine[];
}
type OnRefreshScreen = ()=> Promise<void>;
const processReport = (report: ReportSection[], refreshScreen: OnRefreshScreen, dispatch: Dispatch, baseStyle: TextStyle) => {
const lines: ProcessedLine[] = [];
let currentList: ProcessedLine[]|null = null;
for (let i = 0; i < report.length; i++) {
const section = report[i];
let style: TextStyle = { ...baseStyle };
style.fontWeight = 'bold';
if (i > 0) style.paddingTop = 20;
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
if (section.canRetryAll) {
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
}
for (const n in section.body) {
if (!section.body.hasOwnProperty(n)) continue;
style = { ...baseStyle };
const item = section.body[n];
let text = '';
let retryHandler = null;
let ignoreHandler = null;
if (typeof item === 'object') {
if (item.canRetry) {
retryHandler = async () => {
await item.retryHandler();
await refreshScreen();
};
}
if (item.canIgnore) {
ignoreHandler = async () => {
await item.ignoreHandler();
await refreshScreen();
await checkDisabledSyncItemsNotification((action) => dispatch(action));
};
}
if (item.type === ReportItemType.OpenList) {
currentList = [];
} else if (item.type === ReportItemType.CloseList) {
lines.push({ key: `list_${i}_${n}`, listItems: currentList });
currentList = null;
}
text = item.text;
} else {
text = item;
}
const line = { key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler };
if (currentList) {
// The OpenList item, for example, might be empty and should be skipped:
const hasContent = line.text || retryHandler || ignoreHandler;
if (hasContent) {
currentList.push(line);
}
} else {
lines.push(line);
}
}
lines.push({ key: `divider2_${i}`, isDivider: true });
}
return lines;
};
class StatusScreenComponent extends BaseScreenComponent<Props, State> {
public constructor(props: Props) {
super(props);
@@ -52,15 +133,11 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
marginLeft: 2,
marginRight: 2,
},
});
}
public override render() {
const theme = themeStyle(this.props.themeId);
const styles = this.styles();
const renderBody = (report: ReportSection[]) => {
const baseStyle = {
retryAllButton: {
flexGrow: 0,
alignSelf: 'flex-start',
},
baseStyle: {
paddingLeft: 6,
paddingRight: 6,
paddingTop: 2,
@@ -68,70 +145,49 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
flex: 0,
color: theme.color,
fontSize: theme.fontSize,
};
const lines = [];
for (let i = 0; i < report.length; i++) {
const section = report[i];
let style: TextStyle = { ...baseStyle };
style.fontWeight = 'bold';
if (i > 0) style.paddingTop = 20;
lines.push({ key: `section_${i}`, isSection: true, text: section.title });
if (section.canRetryAll) {
lines.push({ key: `retry_all_${i}`, text: '', retryAllHandler: section.retryAllHandler });
alignSelf: 'center',
},
listWrapper: {
paddingBottom: 5,
},
listBullet: {
fontSize: theme.fontSize / 3,
color: theme.color,
alignSelf: 'center',
justifyContent: 'center',
flexGrow: 0,
marginStart: 12,
marginEnd: 2,
},
divider: {
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
marginTop: 20,
marginBottom: 20,
},
});
}
for (const n in section.body) {
if (!section.body.hasOwnProperty(n)) continue;
style = { ...baseStyle };
const item = section.body[n];
public override render() {
const styles = this.styles();
let text = '';
let retryHandler = null;
let ignoreHandler = null;
if (typeof item === 'object') {
if (item.canRetry) {
retryHandler = async () => {
await item.retryHandler();
await this.refreshScreen();
};
}
if (item.canIgnore) {
ignoreHandler = async () => {
await item.ignoreHandler();
await this.refreshScreen();
await checkDisabledSyncItemsNotification((action) => this.props.dispatch(action));
};
}
text = item.text;
} else {
text = item;
}
lines.push({ key: `item_${i}_${n}`, text: text, retryHandler, ignoreHandler });
}
lines.push({ key: `divider2_${i}`, isDivider: true });
}
return (
<FlatList
data={lines}
renderItem={({ item }) => {
const style: TextStyle = { ...baseStyle };
const renderItem = (item: ProcessedLine, inList: boolean) => {
const style: TextStyle = { ...styles.baseStyle };
let textRole: Role|null = undefined;
const text = item.text;
if (item.isSection === true) {
style.fontWeight = 'bold';
style.marginBottom = 5;
textRole = 'heading';
} else if (inList) {
textRole = 'listitem';
}
style.flex = 1;
const retryAllButton = item.retryAllHandler ? (
<View style={{ flex: 0 }}>
<View style={styles.retryAllButton}>
<Button title={_('Retry All')} onPress={item.retryAllHandler} />
</View>
) : null;
@@ -148,18 +204,36 @@ class StatusScreenComponent extends BaseScreenComponent<Props, State> {
</View>
) : null;
const textComponent = text ? <Text style={style} role={textRole}>{text}</Text> : null;
if (item.isDivider) {
return <View style={{ borderBottomWidth: 1, borderBottomColor: theme.dividerColor, marginTop: 20, marginBottom: 20 }} />;
return <View style={styles.divider} role='separator' key={item.key} />;
} else if (item.listItems) {
return <View role='list' style={styles.listWrapper} key={item.key}>
{textComponent}
{item.listItems.map(item => renderItem(item, true))}
</View>;
} else {
return (
<View style={{ flex: 1, flexDirection: 'row' }}>
<Text style={style}>{item.text}</Text>
<View style={{ flex: 1, flexDirection: 'row' }} key={item.key}>
{inList ? <Icon style={styles.listBullet} name='fas fa-circle' accessibilityLabel={null} /> : null}
{textComponent}
{ignoreButton}
{retryAllButton}
{retryButton}
</View>
);
}
};
const renderBody = (report: ReportSection[]) => {
const baseStyle = styles.baseStyle;
const lines = processReport(report, () => this.refreshScreen(), this.props.dispatch, baseStyle);
return (
<FlatList
data={lines}
renderItem={({ item }) => {
return renderItem(item, false);
}}
/>
);

View File

@@ -18,6 +18,16 @@ interface DecryptionResult {
error: any;
}
// Key for use with the KvStore.
const decryptionErrorKeyPrefix = 'decryptErrorLabel:';
const decryptionErrorKey = (type: number, id: string) => {
return `${decryptionErrorKeyPrefix}${type}:${id}`;
};
const decryptionCounterKeyPrefix = 'decrypt:';
const decryptionCounterKey = (type: number, id: string) => {
return `${decryptionCounterKeyPrefix}${type}:${id}`;
};
export default class DecryptionWorker {
public static instance_: DecryptionWorker = null;
@@ -96,24 +106,29 @@ export default class DecryptionWorker {
}
public async decryptionDisabledItems() {
let items = await this.kvStore().searchByPrefix('decrypt:');
let items = await this.kvStore().searchByPrefix(decryptionCounterKeyPrefix);
items = items.filter(item => item.value > this.maxDecryptionAttempts_);
items = items.map(item => {
return await Promise.all(items.map(async item => {
const s = item.key.split(':');
const type_ = Number(s[1]);
const id = s[2];
const errorDescription = await this.kvStore().value<string>(decryptionErrorKey(type_, id));
return {
type_: Number(s[1]),
id: s[2],
type_,
id,
reason: errorDescription,
};
});
return items;
}));
}
public async clearDisabledItem(typeId: string, itemId: string) {
await this.kvStore().deleteValue(`decrypt:${typeId}:${itemId}`);
public async clearDisabledItem(typeId: number, itemId: string) {
await this.kvStore().deleteValue(decryptionCounterKey(typeId, itemId));
await this.kvStore().deleteValue(decryptionErrorKey(typeId, itemId));
}
public async clearDisabledItems() {
await this.kvStore().deleteByPrefix('decrypt:');
await this.kvStore().deleteByPrefix(decryptionCounterKeyPrefix);
await this.kvStore().deleteByPrefix(decryptionErrorKeyPrefix);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -193,10 +208,14 @@ export default class DecryptionWorker {
itemCount: items.length,
});
const counterKey = `decrypt:${item.type_}:${item.id}`;
const counterKey = decryptionCounterKey(item.type_, item.id);
const errorKey = decryptionErrorKey(item.type_, item.id);
const clearDecryptionCounter = async () => {
await this.kvStore().deleteValue(counterKey);
// The decryption error key stores the reason for the decryption counter's value.
// As such, the error should be reset when the decryption counter is reset:
await this.kvStore().deleteValue(errorKey);
};
// Don't log in production as it results in many messages when importing many items
@@ -253,6 +272,8 @@ export default class DecryptionWorker {
throw error;
}
await this.kvStore().setValue(errorKey, String(error));
if (options.errorHandler === 'log') {
this.logger().warn(`DecryptionWorker: error for: ${item.id} (${ItemClass.tableName()})`, error);
this.logger().debug('Item with error:', item);

View File

@@ -6,6 +6,13 @@ enum ValueType {
Text = 2,
}
interface KvStoreKeyValue {
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partial refactoring of old code from before rule was applied
value: any;
type: ValueType;
}
export default class KvStore extends BaseService {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -110,7 +117,7 @@ export default class KvStore extends BaseService {
}
}
public async searchByPrefix(prefix: string) {
public async searchByPrefix(prefix: string): Promise<KvStoreKeyValue[]> {
const results = await this.db().selectAll('SELECT `key`, `value`, `type` FROM key_values WHERE `key` LIKE ?', [`${prefix}%`]);
return this.formatValues_(results);
}

View File

@@ -1,11 +1,15 @@
import { _ } from '../locale';
import ReportService, { ReportSection } from './ReportService';
import { createNTestNotes, decryptionWorker, setupDatabaseAndSynchronizer, supportDir, switchClient, syncTargetId, synchronizer, synchronizerStart } from '../testing/test-utils';
import { createNTestNotes, decryptionWorker, encryptionService, loadEncryptionMasterKey, setupDatabaseAndSynchronizer, supportDir, switchClient, syncTargetId, synchronizer, synchronizerStart } from '../testing/test-utils';
import Folder from '../models/Folder';
import BaseItem from '../models/BaseItem';
import DecryptionWorker from './DecryptionWorker';
import Note from '../models/Note';
import shim from '../shim';
import SyncTargetRegistry from '../SyncTargetRegistry';
import { loadMasterKeysFromSettings, setupAndEnableEncryption } from './e2ee/utils';
import Setting from '../models/Setting';
import DecryptionWorker from './DecryptionWorker';
import { ModelType } from '../BaseModel';
const firstSectionWithTitle = (report: ReportSection[], title: string) => {
@@ -22,6 +26,10 @@ const getIgnoredSection = (report: ReportSection[]) => {
return firstSectionWithTitle(report, _('Ignored items that cannot be synchronised'));
};
const getDecryptionErrorSection = (report: ReportSection[]): ReportSection|null => {
return firstSectionWithTitle(report, _('Items that cannot be decrypted'));
};
const sectionBodyToText = (section: ReportSection) => {
return section.body.map(item => {
if (typeof item === 'string') {
@@ -32,13 +40,71 @@ const sectionBodyToText = (section: ReportSection) => {
}).join('\n');
};
const getListItemsInBodyStartingWith = (section: ReportSection, keyPrefix: string) => {
return section.body.filter(item =>
typeof item !== 'string' && item.type === 'openList' && item.key.startsWith(keyPrefix),
);
};
const addCannotDecryptNotes = async (corruptedNoteCount: number) => {
await switchClient(2);
const notes = [];
for (let i = 0; i < corruptedNoteCount; i++) {
notes.push(await Note.save({ title: `Note ${i}` }));
}
await synchronizerStart();
await switchClient(1);
await synchronizerStart();
// First, simulate a broken note and check that the decryption worker
// gives up decrypting after a number of tries. This is mainly relevant
// for data that crashes the mobile application - we don't want to keep
// decrypting these.
for (const note of notes) {
await Note.save({ id: note.id, encryption_cipher_text: 'bad' });
}
return notes.map(note => note.id);
};
const addRemoteNotes = async (noteCount: number) => {
await switchClient(2);
const notes = [];
for (let i = 0; i < noteCount; i++) {
notes.push(await Note.save({ title: `Test Note ${i}` }));
}
await synchronizerStart();
await switchClient(1);
return notes.map(note => note.id);
};
const setUpLocalAndRemoteEncryption = async () => {
await switchClient(2);
// Encryption setup
const masterKey = await loadEncryptionMasterKey();
await setupAndEnableEncryption(encryptionService(), masterKey, '123456');
await synchronizerStart();
// Give both clients the same master key
await switchClient(1);
await synchronizerStart();
Setting.setObjectValue('encryption.passwordCache', masterKey.id, '123456');
await loadMasterKeysFromSettings(encryptionService());
};
describe('ReportService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
// For compatibility with code that calls DecryptionWorker.instance()
DecryptionWorker.instance_ = decryptionWorker();
});
it('should move sync errors to the "ignored" section after clicking "ignore"', async () => {
@@ -129,6 +195,7 @@ describe('ReportService', () => {
let report = await service.status(syncTargetId());
const unsyncableSection = getCannotSyncSection(report);
expect(unsyncableSection).not.toBeNull();
expect(sectionBodyToText(unsyncableSection)).toContain('could not be downloaded');
// Item for the download error should be ignorable
@@ -159,4 +226,85 @@ describe('ReportService', () => {
expect(getIgnoredSection(report)).toBeNull();
expect(getCannotSyncSection(report)).toBeNull();
});
it('should associate decryption failures with error message headers when errors are known', async () => {
await setUpLocalAndRemoteEncryption();
const service = new ReportService();
const syncTargetId = SyncTargetRegistry.nameToId('joplinServer');
let report = await service.status(syncTargetId);
// Initially, should not have a "cannot be decrypted section"
expect(getDecryptionErrorSection(report)).toBeNull();
const corruptedNoteIds = await addCannotDecryptNotes(4);
await addRemoteNotes(10);
await synchronizerStart();
for (let i = 0; i < 3; i++) {
report = await service.status(syncTargetId);
expect(getDecryptionErrorSection(report)).toBeNull();
// .start needs to be run multiple times for items to be disabled and thus
// added to the report
await decryptionWorker().start();
}
// After adding corrupted notes, it should have such a section.
report = await service.status(syncTargetId);
const decryptionErrorsSection = getDecryptionErrorSection(report);
expect(decryptionErrorsSection).not.toBeNull();
// There should be a list of errors (all errors are known)
const errorLists = getListItemsInBodyStartingWith(decryptionErrorsSection, 'itemsWithError');
expect(errorLists).toHaveLength(1);
// There should, however, be testIds.length ReportItems with the IDs of the notes.
const decryptionErrorsText = sectionBodyToText(decryptionErrorsSection);
for (const noteId of corruptedNoteIds) {
expect(decryptionErrorsText).toContain(noteId);
}
});
it('should not associate decryption failures with error message headers when errors are unknown', async () => {
const decryption = decryptionWorker();
// Create decryption errors:
const testIds = ['0123456789012345601234567890123456', '0123456789012345601234567890123457', '0123456789012345601234567890123458'];
// Adds items to the decryption error list **without also adding the reason**. This matches
// the format of older decryption errors.
const addIdsToDecryptionErrorList = async (worker: DecryptionWorker, ids: string[]) => {
for (const id of ids) {
// A value that is more than the maximum number of attempts:
const numDecryptionAttempts = 3;
// Add the failure manually so that the error message is unknown
await worker.kvStore().setValue(
`decrypt:${ModelType.Note}:${id}`, numDecryptionAttempts,
);
}
};
await addIdsToDecryptionErrorList(decryption, testIds);
const service = new ReportService();
const syncTargetId = SyncTargetRegistry.nameToId('joplinServer');
const report = await service.status(syncTargetId);
// Report should have an "Items that cannot be decrypted" section
const decryptionErrorSection = getDecryptionErrorSection(report);
expect(decryptionErrorSection).not.toBeNull();
// There should not be any lists of errors (no errors associated with the item).
const errorLists = getListItemsInBodyStartingWith(decryptionErrorSection, 'itemsWithError');
expect(errorLists).toHaveLength(0);
// There should be items with the correct messages:
const expectedMessages = testIds.map(id => `Note: ${id}`);
const bodyText = sectionBodyToText(decryptionErrorSection);
for (const message of expectedMessages) {
expect(bodyText).toContain(message);
}
});
});

View File

@@ -16,7 +16,7 @@ enum CanRetryType {
ItemSync = 'itemSync',
}
enum ReportItemType {
export enum ReportItemType {
OpenList = 'openList',
CloseList = 'closeList',
}
@@ -255,17 +255,51 @@ export default class ReportService {
section.body.push('');
const errorMessagesToItems: Map<string, ReportItem[]> = new Map();
for (let i = 0; i < decryptionDisabledItems.length; i++) {
const row = decryptionDisabledItems[i];
section.body.push({
text: _('%s: %s', toTitleCase(BaseModel.modelTypeToName(row.type_)), row.id),
const resourceTypeName = toTitleCase(BaseModel.modelTypeToName(row.type_));
const message = _('%s: %s', resourceTypeName, row.id);
const item: ReportItem = {
text: message,
canRetry: true,
canRetryType: CanRetryType.E2EE,
retryHandler: async () => {
await DecryptionWorker.instance().clearDisabledItem(row.type_, row.id);
void DecryptionWorker.instance().scheduleStart();
},
});
};
const itemError = row.reason;
if (itemError) {
// If the error message is known, postpone adding the report item.
// Instead, add it under the error message as a heading
if (errorMessagesToItems.has(itemError)) {
errorMessagesToItems.get(itemError).push(item);
} else {
errorMessagesToItems.set(itemError, [item]);
}
} else {
// If there's no known error, add directly:
section.body.push(item);
}
}
// Categorize any items under each known error:
let errorIdx = 0;
for (const itemError of errorMessagesToItems.keys()) {
section.body.push(_('Items with error: %s', itemError));
errorIdx++;
section.body.push({ type: ReportItemType.OpenList, key: `itemsWithError${errorIdx}` });
// Add all items associated with the header
section.body.push(...errorMessagesToItems.get(itemError));
section.body.push({ type: ReportItemType.CloseList });
}
section = this.addRetryAllHandler(section);

View File

@@ -409,7 +409,7 @@ describe('Synchronizer.e2ee', () => {
expect(disabledItems.length).toBe(1);
expect(disabledItems[0].id).toBe(note.id);
expect((await kvStore().all()).length).toBe(1);
expect((await kvStore().searchByPrefix('decrypt:')).length).toBe(1);
await kvStore().clear();
// Now check that if it fails once but succeed the second time, the note

View File

@@ -287,6 +287,7 @@ async function switchClient(id: number, options: any = null) {
Resource.encryptionService_ = encryptionServices_[id];
BaseItem.revisionService_ = revisionServices_[id];
ResourceFetcher.instance_ = resourceFetchers_[id];
DecryptionWorker.instance_ = decryptionWorker(id);
await Setting.reset();
Setting.settingFilename = settingFilename(id);
@@ -549,7 +550,7 @@ function revisionService(id: number = null) {
function decryptionWorker(id: number = null) {
if (id === null) id = currentClient_;
const o = decryptionWorkers_[id];
o.setKvStore(kvStore(id));
o?.setKvStore(kvStore(id));
return o;
}