Skip to content

Desktop: Use Electron safeStorage for keychain support #10535

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
35b0518
Use Electron safeStorage on all platforms
personalizedrefrigerator Jun 3, 2024
908f7b0
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jun 5, 2024
bff73ea
Migration: Re-run the keychain support check on Linux systems
personalizedrefrigerator Jun 6, 2024
c6ea45e
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jun 8, 2024
44b2644
Partial refactor: Break KeychainServiceDriver into .keytar and .elect…
personalizedrefrigerator Jun 8, 2024
e329501
Revert "Partial refactor: Break KeychainServiceDriver into .keytar an…
personalizedrefrigerator Jun 8, 2024
5375f21
Use KvStore, try to fix errors on dev mode Linux
personalizedrefrigerator Jun 8, 2024
6fb23c4
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jun 17, 2024
a0f4028
WIP: Migrate keys from keytar to safestorage
personalizedrefrigerator Jun 17, 2024
6f5b616
WIP: Improve key migration (not yet working)
personalizedrefrigerator Jun 17, 2024
d311efc
Fix migration logic
personalizedrefrigerator Jun 18, 2024
3c83b64
Add test
personalizedrefrigerator Jun 18, 2024
9024e87
Remove unnecessary eslint-disable
personalizedrefrigerator Jun 18, 2024
840ce6f
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jun 20, 2024
331bbca
Fix keychain support check not re-run on Linux
personalizedrefrigerator Jun 20, 2024
c6dd816
Update to-do comment
personalizedrefrigerator Jun 20, 2024
ec73e42
Merge branch 'dev' into pr/desktop/use-electron-safestorage
personalizedrefrigerator Jun 24, 2024
f5cbeab
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jul 1, 2024
af9180e
Add additional test, restrict migration to Electron app
personalizedrefrigerator Jul 1, 2024
cf962d5
Refactor KeychainService driver logic
personalizedrefrigerator Jul 2, 2024
44e670e
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jul 17, 2024
436a84f
Fix keychain support not enabled on Linux after safestorage becomes a…
personalizedrefrigerator Jul 17, 2024
9a36813
Rename variable for consistency with other code
personalizedrefrigerator Jul 17, 2024
df7428e
Merge branch 'dev' into pr/desktop/use-electron-safestorage
personalizedrefrigerator Jul 17, 2024
d4fbc35
Merge branch 'dev' into pr/desktop/use-electron-safestorage
personalizedrefrigerator Jul 18, 2024
39885e4
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Jul 26, 2024
1d5eb8e
Update comment -- the keychain optimization is now present in a diffe…
personalizedrefrigerator Jul 26, 2024
ad905fc
Merge remote-tracking branch 'upstream/dev' into pr/desktop/use-elect…
personalizedrefrigerator Aug 1, 2024
6646a6a
Avoid deleting keys from old storage backends when migrating
personalizedrefrigerator Aug 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -1071,8 +1071,10 @@ packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.test.js
packages/lib/services/keychain/KeychainService.js
packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.mobile.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -1050,8 +1050,10 @@ packages/lib/services/interop/Module.js
packages/lib/services/interop/types.js
packages/lib/services/joplinCloudUtils.js
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
packages/lib/services/keychain/KeychainService.test.js
packages/lib/services/keychain/KeychainService.js
packages/lib/services/keychain/KeychainServiceDriver.dummy.js
packages/lib/services/keychain/KeychainServiceDriver.electron.js
packages/lib/services/keychain/KeychainServiceDriver.mobile.js
packages/lib/services/keychain/KeychainServiceDriver.node.js
packages/lib/services/keychain/KeychainServiceDriverBase.js
Expand Down
17 changes: 16 additions & 1 deletion packages/app-desktop/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import ElectronAppWrapper from './ElectronAppWrapper';
import shim from '@joplin/lib/shim';
import { _, setLocale } from '@joplin/lib/locale';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions } from 'electron';
import { BrowserWindow, nativeTheme, nativeImage, shell, dialog, MessageBoxSyncOptions, safeStorage } from 'electron';
import { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
import { fileUriToPath } from '@joplin/utils/url';
import { urlDecode } from '@joplin/lib/string-utils';
Expand Down Expand Up @@ -485,6 +485,21 @@ export class Bridge {
return nativeImage.createFromPath(path);
}

public safeStorage = {
isEncryptionAvailable() {
return safeStorage.isEncryptionAvailable();
},
encryptString(data: string) {
return safeStorage.encryptString(data).toString('base64');
},
decryptString(base64Data: string) {
return safeStorage.decryptString(Buffer.from(base64Data, 'base64'));
},

getSelectedStorageBackend() {
return safeStorage.getSelectedStorageBackend();
},
};
}

let bridge_: Bridge = null;
Expand Down
2 changes: 1 addition & 1 deletion packages/app-desktop/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const registerCustomProtocols = require('./utils/customProtocols/registerCustomP
// our case it's a string like "@joplin/app-desktop". It's also supposed to
// check the productName key but is not doing it, so here set the
// application name to the right string.
electronApp.name = packageInfo.name;
electronApp.setName(packageInfo.name);

process.on('unhandledRejection', (reason, p) => {
console.error('Unhandled promise rejection', p, 'reason:', reason);
Expand Down
2 changes: 1 addition & 1 deletion packages/app-mobile/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ async function initialize(dispatch: Dispatch) {
reg.logger().info('Database is ready.');
reg.logger().info('Loading settings...');

await loadKeychainServiceAndSettings(KeychainServiceDriverMobile);
await loadKeychainServiceAndSettings([KeychainServiceDriverMobile]);
await migrateMasterPassword();

if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
Expand Down
10 changes: 6 additions & 4 deletions packages/lib/BaseApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import shim from './shim';
const { setupProxySettings } = require('./shim-init-node');
import BaseService from './services/BaseService';
import reducer, { getNotesParent, serializeNotesParent, setStore, State } from './reducer';
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
import KeychainServiceDriverDummy from './services/keychain/KeychainServiceDriver.dummy';
import KeychainServiceDriverNode from './services/keychain/KeychainServiceDriver.node';
import KeychainServiceDriverElectron from './services/keychain/KeychainServiceDriver.electron';
import { setLocale } from './locale';
import KvStore from './services/KvStore';
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
Expand Down Expand Up @@ -754,10 +754,13 @@ export default class BaseApplication {

reg.setDb(this.database_);
BaseModel.setDb(this.database_);
KvStore.instance().setDb(reg.db());

setRSA(RSA);

await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
await loadKeychainServiceAndSettings(
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
);
await migrateMasterPassword();
await handleSyncStartupOperation();

Expand Down Expand Up @@ -841,7 +844,6 @@ export default class BaseApplication {

BaseItem.revisionService_ = RevisionService.instance();

KvStore.instance().setDb(reg.db());

BaseItem.encryptionService_ = EncryptionService.instance();
BaseItem.shareService_ = ShareService.instance();
Expand Down
14 changes: 2 additions & 12 deletions packages/lib/models/Setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,6 @@ export type SettingMetadataSection = {
export type MetadataBySection = SettingMetadataSection[];

class Setting extends BaseModel {

public static schemaUrl = 'https://joplinapp.org/schema/settings.json';

// For backward compatibility
Expand Down Expand Up @@ -974,19 +973,10 @@ class Setting extends BaseModel {
// Also we don't control what happens on the keychain - the values can be edited or deleted
// outside the application. For that reason, we rewrite it every time the values are saved,
// even if, internally, they haven't changed.
// As an optimisation, we check if the value exists on the keychain before writing it again.
try {
const passwordName = `setting.${s.key}`;
const currentValue = await this.keychainService().password(passwordName);
if (currentValue !== valueAsString) {
const wasSet = await this.keychainService().setPassword(passwordName, valueAsString);
if (wasSet) continue;
} else {
// The value is already in the keychain - so nothing to do
// Make sure to `continue` here otherwise it will save the password
// in clear text in the database.
continue;
}
const wasSet = await this.keychainService().setPassword(passwordName, valueAsString);
if (wasSet) continue;
} catch (error) {
logger.error(`Could not set setting on the keychain. Will be saved to database instead: ${s.key}:`, error);
}
Expand Down
1 change: 1 addition & 0 deletions packages/lib/models/settings/builtInMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -920,6 +920,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },

'keychain.supported': { value: -1, type: SettingItemType.Int, public: false },
'keychain.lastAvailableDrivers': { value: [] as string[], type: SettingItemType.Array, public: false },
'db.ftsEnabled': { value: -1, type: SettingItemType.Int, public: false },
'db.fuzzySearchEnabled': { value: -1, type: SettingItemType.Int, public: false },
'encryption.enabled': { value: false, type: SettingItemType.Bool, public: false },
Expand Down
10 changes: 7 additions & 3 deletions packages/lib/services/SettingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import KeychainService from './keychain/KeychainService';
import Setting from '../models/Setting';
import uuid from '../uuid';
import { migrateLocalSyncInfo } from './synchronizer/syncInfoUtils';
import KeychainServiceDriverBase from './keychain/KeychainServiceDriverBase';

type KeychainServiceDriverConstructor = new (appId: string, clientId: string)=> KeychainServiceDriverBase;

// This function takes care of initialising both the keychain service and settings.
//
Expand All @@ -13,11 +16,12 @@ import { migrateLocalSyncInfo } from './synchronizer/syncInfoUtils';
// In other words, it's not possible to load the settings without the KS service and it's not
// possible to initialise the KS service without the settings.
// The solution is to fetch just the client ID directly from the database.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export async function loadKeychainServiceAndSettings(KeychainServiceDriver: any) {
export async function loadKeychainServiceAndSettings(keychainServiceDrivers: KeychainServiceDriverConstructor[]) {
const clientIdSetting = await Setting.loadOne('clientId');
const clientId = clientIdSetting ? clientIdSetting.value : uuid.create();
KeychainService.instance().initialize(new KeychainServiceDriver(Setting.value('appId'), clientId));
await KeychainService.instance().initialize(
keychainServiceDrivers.map(Driver => new Driver(Setting.value('appId'), clientId)),
);
Setting.setKeychainService(KeychainService.instance());
await Setting.load();

Expand Down
128 changes: 128 additions & 0 deletions packages/lib/services/keychain/KeychainService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import Setting from '../../models/Setting';
import shim from '../../shim';
import { switchClient, setupDatabaseAndSynchronizer } from '../../testing/test-utils';
import KeychainService from './KeychainService';
import KeychainServiceDriverDummy from './KeychainServiceDriver.dummy';
import KeychainServiceDriverElectron from './KeychainServiceDriver.electron';
import KeychainServiceDriverNode from './KeychainServiceDriver.node';

interface SafeStorageMockOptions {
isEncryptionAvailable?: ()=> boolean;
encryptString?: (str: string)=> Promise<string|null>;
decryptString?: (str: string)=> Promise<string|null>;
}

const mockSafeStorage = ({ // Safe storage
isEncryptionAvailable = jest.fn(() => true),
encryptString = jest.fn(async s => (`e:${s}`)),
decryptString = jest.fn(async s => s.substring(2)),
}: SafeStorageMockOptions) => {
shim.electronBridge = () => ({
safeStorage: {
isEncryptionAvailable,
encryptString,
decryptString,
getSelectedStorageBackend: () => 'mock',
},
});
};

const mockKeytar = () => {
const storage = new Map<string, string>();

const keytarMock = {
getPassword: jest.fn(async (key, client) => {
return storage.get(`${client}--${key}`);
}),
setPassword: jest.fn(async (key, client, password) => {
if (!password) throw new Error('Keytar doesn\'t support empty passwords.');
storage.set(`${client}--${key}`, password);
}),
deletePassword: jest.fn(async (key, client) => {
storage.delete(`${client}--${key}`);
}),
};
shim.keytar = () => keytarMock;
return keytarMock;
};

const makeDrivers = () => [
new KeychainServiceDriverElectron(Setting.value('appId'), Setting.value('clientId')),
new KeychainServiceDriverNode(Setting.value('appId'), Setting.value('clientId')),
];

describe('KeychainService', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(0);
await switchClient(0);
Setting.setValue('keychain.supported', 1);
shim.electronBridge = null;
shim.keytar = null;
});

test('should copy keys from keytar to safeStorage', async () => {
const keytarMock = mockKeytar();
await KeychainService.instance().initialize(makeDrivers());

// Set a secure setting
Setting.setValue('encryption.masterPassword', 'testing');
await Setting.saveAll();

mockSafeStorage({});

await KeychainService.instance().initialize(makeDrivers());
await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe('testing');

await Setting.saveAll();

// For now, passwords should not be removed from old backends -- this allows
// users to revert to an earlier version of Joplin without data loss.
expect(keytarMock.deletePassword).not.toHaveBeenCalled();

expect(shim.electronBridge().safeStorage.encryptString).toHaveBeenCalled();
expect(shim.electronBridge().safeStorage.encryptString).toHaveBeenCalledWith('testing');

await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe('testing');
});

test('should use keytar when safeStorage is unavailable', async () => {
const keytarMock = mockKeytar();
await KeychainService.instance().initialize(makeDrivers());

Setting.setValue('encryption.masterPassword', 'test-password');
await Setting.saveAll();
expect(keytarMock.setPassword).toHaveBeenCalledWith(
`${Setting.value('appId')}.setting.encryption.masterPassword`,
`${Setting.value('clientId')}@joplin`,
'test-password',
);

await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe('test-password');
});

test('should re-check for keychain support when a new driver is added', async () => {
mockKeytar();
mockSafeStorage({});
Setting.setValue('keychain.supported', -1);

await KeychainService.instance().initialize([
new KeychainServiceDriverDummy(Setting.value('appId'), Setting.value('clientId')),
]);
await KeychainService.instance().detectIfKeychainSupported();

expect(Setting.value('keychain.supported')).toBe(0);

// Should re-run the check after keytar and safeStorage are available.
await KeychainService.instance().initialize(makeDrivers());
await KeychainService.instance().detectIfKeychainSupported();
expect(Setting.value('keychain.supported')).toBe(1);

// Should re-run the check if safeStorage and keytar are both no longer available.
await KeychainService.instance().initialize([]);
await KeychainService.instance().detectIfKeychainSupported();
expect(Setting.value('keychain.supported')).toBe(0);
});
});
Loading
Loading