You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-07-16 00:14:34 +02:00
Desktop: Use Electron safeStorage
for keychain support (#10535)
This commit is contained in:
@ -1102,8 +1102,10 @@ packages/lib/services/interop/Module.js
|
|||||||
packages/lib/services/interop/types.js
|
packages/lib/services/interop/types.js
|
||||||
packages/lib/services/joplinCloudUtils.js
|
packages/lib/services/joplinCloudUtils.js
|
||||||
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
|
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
|
||||||
|
packages/lib/services/keychain/KeychainService.test.js
|
||||||
packages/lib/services/keychain/KeychainService.js
|
packages/lib/services/keychain/KeychainService.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriver.dummy.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.mobile.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1080,8 +1080,10 @@ packages/lib/services/interop/Module.js
|
|||||||
packages/lib/services/interop/types.js
|
packages/lib/services/interop/types.js
|
||||||
packages/lib/services/joplinCloudUtils.js
|
packages/lib/services/joplinCloudUtils.js
|
||||||
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
|
packages/lib/services/joplinServer/personalizedUserContentBaseUrl.js
|
||||||
|
packages/lib/services/keychain/KeychainService.test.js
|
||||||
packages/lib/services/keychain/KeychainService.js
|
packages/lib/services/keychain/KeychainService.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriver.dummy.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.mobile.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
packages/lib/services/keychain/KeychainServiceDriver.node.js
|
||||||
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
packages/lib/services/keychain/KeychainServiceDriverBase.js
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import ElectronAppWrapper from './ElectronAppWrapper';
|
import ElectronAppWrapper from './ElectronAppWrapper';
|
||||||
import shim from '@joplin/lib/shim';
|
import shim from '@joplin/lib/shim';
|
||||||
import { _, setLocale } from '@joplin/lib/locale';
|
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 { dirname, toSystemSlashes } from '@joplin/lib/path-utils';
|
||||||
import { fileUriToPath } from '@joplin/utils/url';
|
import { fileUriToPath } from '@joplin/utils/url';
|
||||||
import { urlDecode } from '@joplin/lib/string-utils';
|
import { urlDecode } from '@joplin/lib/string-utils';
|
||||||
@ -485,6 +485,21 @@ export class Bridge {
|
|||||||
return nativeImage.createFromPath(path);
|
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;
|
let bridge_: Bridge = null;
|
||||||
|
@ -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
|
// 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
|
// check the productName key but is not doing it, so here set the
|
||||||
// application name to the right string.
|
// application name to the right string.
|
||||||
electronApp.name = packageInfo.name;
|
electronApp.setName(packageInfo.name);
|
||||||
|
|
||||||
process.on('unhandledRejection', (reason, p) => {
|
process.on('unhandledRejection', (reason, p) => {
|
||||||
console.error('Unhandled promise rejection', p, 'reason:', reason);
|
console.error('Unhandled promise rejection', p, 'reason:', reason);
|
||||||
|
@ -624,7 +624,7 @@ async function initialize(dispatch: Dispatch) {
|
|||||||
reg.logger().info('Database is ready.');
|
reg.logger().info('Database is ready.');
|
||||||
reg.logger().info('Loading settings...');
|
reg.logger().info('Loading settings...');
|
||||||
|
|
||||||
await loadKeychainServiceAndSettings(KeychainServiceDriverMobile);
|
await loadKeychainServiceAndSettings([KeychainServiceDriverMobile]);
|
||||||
await migrateMasterPassword();
|
await migrateMasterPassword();
|
||||||
|
|
||||||
if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
|
if (!Setting.value('clientId')) Setting.setValue('clientId', uuid.create());
|
||||||
|
@ -4,8 +4,8 @@ import shim from './shim';
|
|||||||
const { setupProxySettings } = require('./shim-init-node');
|
const { setupProxySettings } = require('./shim-init-node');
|
||||||
import BaseService from './services/BaseService';
|
import BaseService from './services/BaseService';
|
||||||
import reducer, { getNotesParent, serializeNotesParent, setStore, State } from './reducer';
|
import reducer, { getNotesParent, serializeNotesParent, setStore, State } from './reducer';
|
||||||
import KeychainServiceDriver from './services/keychain/KeychainServiceDriver.node';
|
import KeychainServiceDriverNode from './services/keychain/KeychainServiceDriver.node';
|
||||||
import KeychainServiceDriverDummy from './services/keychain/KeychainServiceDriver.dummy';
|
import KeychainServiceDriverElectron from './services/keychain/KeychainServiceDriver.electron';
|
||||||
import { setLocale } from './locale';
|
import { setLocale } from './locale';
|
||||||
import KvStore from './services/KvStore';
|
import KvStore from './services/KvStore';
|
||||||
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
import SyncTargetJoplinServer from './SyncTargetJoplinServer';
|
||||||
@ -754,10 +754,13 @@ export default class BaseApplication {
|
|||||||
|
|
||||||
reg.setDb(this.database_);
|
reg.setDb(this.database_);
|
||||||
BaseModel.setDb(this.database_);
|
BaseModel.setDb(this.database_);
|
||||||
|
KvStore.instance().setDb(reg.db());
|
||||||
|
|
||||||
setRSA(RSA);
|
setRSA(RSA);
|
||||||
|
|
||||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
await loadKeychainServiceAndSettings(
|
||||||
|
options.keychainEnabled ? [KeychainServiceDriverElectron, KeychainServiceDriverNode] : [],
|
||||||
|
);
|
||||||
await migrateMasterPassword();
|
await migrateMasterPassword();
|
||||||
await handleSyncStartupOperation();
|
await handleSyncStartupOperation();
|
||||||
|
|
||||||
@ -841,7 +844,6 @@ export default class BaseApplication {
|
|||||||
|
|
||||||
BaseItem.revisionService_ = RevisionService.instance();
|
BaseItem.revisionService_ = RevisionService.instance();
|
||||||
|
|
||||||
KvStore.instance().setDb(reg.db());
|
|
||||||
|
|
||||||
BaseItem.encryptionService_ = EncryptionService.instance();
|
BaseItem.encryptionService_ = EncryptionService.instance();
|
||||||
BaseItem.shareService_ = ShareService.instance();
|
BaseItem.shareService_ = ShareService.instance();
|
||||||
|
@ -142,7 +142,6 @@ export type SettingMetadataSection = {
|
|||||||
export type MetadataBySection = SettingMetadataSection[];
|
export type MetadataBySection = SettingMetadataSection[];
|
||||||
|
|
||||||
class Setting extends BaseModel {
|
class Setting extends BaseModel {
|
||||||
|
|
||||||
public static schemaUrl = 'https://joplinapp.org/schema/settings.json';
|
public static schemaUrl = 'https://joplinapp.org/schema/settings.json';
|
||||||
|
|
||||||
// For backward compatibility
|
// For backward compatibility
|
||||||
@ -976,19 +975,10 @@ class Setting extends BaseModel {
|
|||||||
// Also we don't control what happens on the keychain - the values can be edited or deleted
|
// 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,
|
// outside the application. For that reason, we rewrite it every time the values are saved,
|
||||||
// even if, internally, they haven't changed.
|
// even if, internally, they haven't changed.
|
||||||
// As an optimisation, we check if the value exists on the keychain before writing it again.
|
|
||||||
try {
|
try {
|
||||||
const passwordName = `setting.${s.key}`;
|
const passwordName = `setting.${s.key}`;
|
||||||
const currentValue = await this.keychainService().password(passwordName);
|
const wasSet = await this.keychainService().setPassword(passwordName, valueAsString);
|
||||||
if (currentValue !== valueAsString) {
|
if (wasSet) continue;
|
||||||
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;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Could not set setting on the keychain. Will be saved to database instead: ${s.key}:`, error);
|
logger.error(`Could not set setting on the keychain. Will be saved to database instead: ${s.key}:`, error);
|
||||||
}
|
}
|
||||||
|
@ -928,6 +928,7 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
|||||||
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
|
collapsedFolderIds: { value: [] as string[], type: SettingItemType.Array, public: false },
|
||||||
|
|
||||||
'keychain.supported': { value: -1, type: SettingItemType.Int, 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.ftsEnabled': { value: -1, type: SettingItemType.Int, public: false },
|
||||||
'db.fuzzySearchEnabled': { 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 },
|
'encryption.enabled': { value: false, type: SettingItemType.Bool, public: false },
|
||||||
|
@ -4,6 +4,9 @@ import KeychainService from './keychain/KeychainService';
|
|||||||
import Setting from '../models/Setting';
|
import Setting from '../models/Setting';
|
||||||
import uuid from '../uuid';
|
import uuid from '../uuid';
|
||||||
import { migrateLocalSyncInfo } from './synchronizer/syncInfoUtils';
|
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.
|
// This function takes care of initialising both the keychain service and settings.
|
||||||
//
|
//
|
||||||
@ -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
|
// 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.
|
// possible to initialise the KS service without the settings.
|
||||||
// The solution is to fetch just the client ID directly from the database.
|
// 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(keychainServiceDrivers: KeychainServiceDriverConstructor[]) {
|
||||||
export async function loadKeychainServiceAndSettings(KeychainServiceDriver: any) {
|
|
||||||
const clientIdSetting = await Setting.loadOne('clientId');
|
const clientIdSetting = await Setting.loadOne('clientId');
|
||||||
const clientId = clientIdSetting ? clientIdSetting.value : uuid.create();
|
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());
|
Setting.setKeychainService(KeychainService.instance());
|
||||||
await Setting.load();
|
await Setting.load();
|
||||||
|
|
||||||
|
128
packages/lib/services/keychain/KeychainService.test.ts
Normal file
128
packages/lib/services/keychain/KeychainService.test.ts
Normal file
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -1,10 +1,14 @@
|
|||||||
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
||||||
import Setting from '../../models/Setting';
|
import Setting from '../../models/Setting';
|
||||||
import BaseService from '../BaseService';
|
import BaseService from '../BaseService';
|
||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
|
||||||
|
const logger = Logger.create('KeychainService');
|
||||||
|
|
||||||
export default class KeychainService extends BaseService {
|
export default class KeychainService extends BaseService {
|
||||||
|
|
||||||
private driver: KeychainServiceDriverBase;
|
private drivers_: KeychainServiceDriverBase[];
|
||||||
|
private keysNeedingMigration_: Set<string>;
|
||||||
private static instance_: KeychainService;
|
private static instance_: KeychainService;
|
||||||
private enabled_ = true;
|
private enabled_ = true;
|
||||||
|
|
||||||
@ -13,9 +17,23 @@ export default class KeychainService extends BaseService {
|
|||||||
return this.instance_;
|
return this.instance_;
|
||||||
}
|
}
|
||||||
|
|
||||||
public initialize(driver: KeychainServiceDriverBase) {
|
// The drivers list should be provided in order of preference, with the most preferred driver
|
||||||
if (!driver.appId || !driver.clientId) throw new Error('appId and clientId must be set on the KeychainServiceDriver');
|
// first. If not present in the first supported driver, the keychain service will attempt to
|
||||||
this.driver = driver;
|
// migrate keys to it.
|
||||||
|
public async initialize(drivers: KeychainServiceDriverBase[]) {
|
||||||
|
if (drivers.some(driver => !driver.appId || !driver.clientId)) {
|
||||||
|
throw new Error('appId and clientId must be set on the KeychainServiceDriver');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drivers_ = [];
|
||||||
|
this.keysNeedingMigration_ = new Set();
|
||||||
|
for (const driver of drivers) {
|
||||||
|
if (await driver.supported()) {
|
||||||
|
this.drivers_.push(driver);
|
||||||
|
} else {
|
||||||
|
logger.warn(`Driver unsupported:${driver.driverId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is to programatically disable the keychain service, whether keychain
|
// This is to programatically disable the keychain service, whether keychain
|
||||||
@ -38,32 +56,87 @@ export default class KeychainService extends BaseService {
|
|||||||
public async setPassword(name: string, password: string): Promise<boolean> {
|
public async setPassword(name: string, password: string): Promise<boolean> {
|
||||||
if (!this.enabled) return false;
|
if (!this.enabled) return false;
|
||||||
|
|
||||||
|
// Optimization: Handles the case where the password doesn't need to change.
|
||||||
|
// TODO: Re-evaluate whether this optimization is necessary after refactoring the driver
|
||||||
|
// logic.
|
||||||
|
if (!this.keysNeedingMigration_.has(name) && await this.password(name) === password) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Due to a bug in macOS, this may throw an exception "The user name or passphrase you entered is not correct."
|
// Due to a bug in macOS, this may throw an exception "The user name or passphrase you entered is not correct."
|
||||||
// The fix is to open Keychain Access.app. Right-click on the login keychain and try locking it and then unlocking it again.
|
// The fix is to open Keychain Access.app. Right-click on the login keychain and try locking it and then unlocking it again.
|
||||||
// https://github.com/atom/node-keytar/issues/76
|
// https://github.com/atom/node-keytar/issues/76
|
||||||
return this.driver.setPassword(name, password);
|
let i = 0;
|
||||||
|
let didSet = false;
|
||||||
|
for (; i < this.drivers_.length && !didSet; i++) {
|
||||||
|
didSet = await this.drivers_[i].setPassword(name, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (didSet && this.keysNeedingMigration_.has(name)) {
|
||||||
|
logger.info(`Marking key ${name} as copied to new keychain backend...`);
|
||||||
|
|
||||||
|
// At this point, the key has been saved in drivers[i - 1].
|
||||||
|
//
|
||||||
|
// Deleting the key from the less-preferred drivers would complete the
|
||||||
|
// migration. However, to allow users to roll back to a previous Joplin
|
||||||
|
// version without data loss, avoid deleting old keys here.
|
||||||
|
|
||||||
|
this.keysNeedingMigration_.delete(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return didSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async password(name: string): Promise<string> {
|
public async password(name: string): Promise<string> {
|
||||||
if (!this.enabled) return null;
|
if (!this.enabled) return null;
|
||||||
|
|
||||||
return this.driver.password(name);
|
let foundInPreferredDriver = true;
|
||||||
|
let password: string|null = null;
|
||||||
|
for (const driver of this.drivers_) {
|
||||||
|
password = await driver.password(name);
|
||||||
|
if (password) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
foundInPreferredDriver = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password && !foundInPreferredDriver) {
|
||||||
|
this.keysNeedingMigration_.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return password;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePassword(name: string): Promise<void> {
|
public async deletePassword(name: string): Promise<void> {
|
||||||
if (!this.enabled) return;
|
if (!this.enabled) return;
|
||||||
|
|
||||||
await this.driver.deletePassword(name);
|
for (const driver of this.drivers_) {
|
||||||
|
await driver.deletePassword(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async detectIfKeychainSupported() {
|
public async detectIfKeychainSupported() {
|
||||||
this.logger().info('KeychainService: checking if keychain supported');
|
this.logger().info('KeychainService: checking if keychain supported');
|
||||||
|
|
||||||
if (Setting.value('keychain.supported') >= 0) {
|
const lastAvailableDrivers = Setting.value('keychain.lastAvailableDrivers');
|
||||||
|
const availableDriversChanged = (() => {
|
||||||
|
if (lastAvailableDrivers.length !== this.drivers_.length) return true;
|
||||||
|
return this.drivers_.some(driver => {
|
||||||
|
return !lastAvailableDrivers.includes(driver.driverId);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
const checkAlreadyDone = Setting.value('keychain.supported') >= 0;
|
||||||
|
if (checkAlreadyDone && !availableDriversChanged) {
|
||||||
this.logger().info('KeychainService: check was already done - skipping. Supported:', Setting.value('keychain.supported'));
|
this.logger().info('KeychainService: check was already done - skipping. Supported:', Setting.value('keychain.supported'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (availableDriversChanged) {
|
||||||
|
// Reset supported -- this allows the test .setPassword to work.
|
||||||
|
Setting.setValue('keychain.supported', -1);
|
||||||
|
}
|
||||||
|
|
||||||
const passwordIsSet = await this.setPassword('zz_testingkeychain', 'mytest');
|
const passwordIsSet = await this.setPassword('zz_testingkeychain', 'mytest');
|
||||||
|
|
||||||
if (!passwordIsSet) {
|
if (!passwordIsSet) {
|
||||||
@ -75,6 +148,6 @@ export default class KeychainService extends BaseService {
|
|||||||
this.logger().info('KeychainService: tried to set and get password. Result was:', result);
|
this.logger().info('KeychainService: tried to set and get password. Result was:', result);
|
||||||
Setting.setValue('keychain.supported', result === 'mytest' ? 1 : 0);
|
Setting.setValue('keychain.supported', result === 'mytest' ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
Setting.setValue('keychain.lastAvailableDrivers', this.drivers_.map(driver => driver.driverId));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
||||||
|
|
||||||
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
||||||
|
public override readonly driverId = 'dummy';
|
||||||
|
|
||||||
|
public async supported() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public async setPassword(/* name:string, password:string*/): Promise<boolean> {
|
public async setPassword(/* name:string, password:string*/): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
@ -14,4 +19,6 @@ export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async upgradeStorageBackend(_secureKeys: string[], _newDatabaseVersion: number): Promise<void> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
||||||
|
import shim from '../../shim';
|
||||||
|
import Logger from '@joplin/utils/Logger';
|
||||||
|
import KvStore from '../KvStore';
|
||||||
|
import Setting from '../../models/Setting';
|
||||||
|
|
||||||
|
const logger = Logger.create('KeychainServiceDriver.node');
|
||||||
|
|
||||||
|
const canUseSafeStorage = () => {
|
||||||
|
return !!shim.electronBridge?.()?.safeStorage?.isEncryptionAvailable();
|
||||||
|
};
|
||||||
|
|
||||||
|
const kvStorePrefix = 'KeychainServiceDriver.secureStore.';
|
||||||
|
|
||||||
|
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
||||||
|
public override readonly driverId = 'electron-safeStorage';
|
||||||
|
|
||||||
|
public constructor(appId: string, clientId: string) {
|
||||||
|
super(appId, clientId);
|
||||||
|
|
||||||
|
if (canUseSafeStorage() && shim.isLinux()) {
|
||||||
|
logger.info('KeychainService Linux backend: ', shim.electronBridge()?.safeStorage?.getSelectedStorageBackend());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async supported() {
|
||||||
|
return canUseSafeStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setPassword(name: string, password: string): Promise<boolean> {
|
||||||
|
if (canUseSafeStorage()) {
|
||||||
|
logger.debug('Saving password with electron safe storage. ID: ', name);
|
||||||
|
|
||||||
|
const encrypted = await shim.electronBridge().safeStorage.encryptString(password);
|
||||||
|
await KvStore.instance().setValue(`${kvStorePrefix}${name}`, encrypted);
|
||||||
|
} else {
|
||||||
|
// Unsupported.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async password(name: string): Promise<string> {
|
||||||
|
let result: string|null = null;
|
||||||
|
|
||||||
|
if (canUseSafeStorage()) {
|
||||||
|
const data = await KvStore.instance().value<string>(`${kvStorePrefix}${name}`);
|
||||||
|
if (data !== null) {
|
||||||
|
try {
|
||||||
|
result = await shim.electronBridge().safeStorage.decryptString(data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('Decryption of a setting failed. Corrupted data or new keychain password? Error:', e);
|
||||||
|
if (shim.isLinux() && Setting.value('env') === 'dev') {
|
||||||
|
logger.warn('If running Joplin in development mode with NodeJS installed from the Snap store, consider retrying with NodeJS installed from a different source.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deletePassword(name: string): Promise<void> {
|
||||||
|
if (canUseSafeStorage()) {
|
||||||
|
logger.debug('Trying to delete encrypted password with id ', name);
|
||||||
|
await KvStore.instance().deleteValue(`${kvStorePrefix}${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,11 @@
|
|||||||
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
||||||
|
|
||||||
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
||||||
|
public override readonly driverId: string = 'mobile-unknown';
|
||||||
|
|
||||||
|
public async supported(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public async setPassword(/* name:string, password:string*/): Promise<boolean> {
|
public async setPassword(/* name:string, password:string*/): Promise<boolean> {
|
||||||
return false;
|
return false;
|
||||||
@ -14,4 +19,6 @@ export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async upgradeStorageBackend(_secureKeys: string[], _newDatabaseVersion: number): Promise<void> {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,20 +2,22 @@ import KeychainServiceDriverBase from './KeychainServiceDriverBase';
|
|||||||
import shim from '../../shim';
|
import shim from '../../shim';
|
||||||
|
|
||||||
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
export default class KeychainServiceDriver extends KeychainServiceDriverBase {
|
||||||
|
public override readonly driverId: string = 'node-keytar';
|
||||||
|
|
||||||
|
public async supported(): Promise<boolean> {
|
||||||
|
return !!shim.keytar();
|
||||||
|
}
|
||||||
|
|
||||||
public async setPassword(name: string, password: string): Promise<boolean> {
|
public async setPassword(name: string, password: string): Promise<boolean> {
|
||||||
if (!shim.keytar()) return false;
|
|
||||||
await shim.keytar().setPassword(`${this.appId}.${name}`, `${this.clientId}@joplin`, password);
|
await shim.keytar().setPassword(`${this.appId}.${name}`, `${this.clientId}@joplin`, password);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async password(name: string): Promise<string> {
|
public async password(name: string): Promise<string> {
|
||||||
if (!shim.keytar()) return null;
|
|
||||||
return shim.keytar().getPassword(`${this.appId}.${name}`, `${this.clientId}@joplin`);
|
return shim.keytar().getPassword(`${this.appId}.${name}`, `${this.clientId}@joplin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePassword(name: string): Promise<void> {
|
public async deletePassword(name: string): Promise<void> {
|
||||||
if (!shim.keytar()) return;
|
|
||||||
await shim.keytar().deletePassword(`${this.appId}.${name}`, `${this.clientId}@joplin`);
|
await shim.keytar().deletePassword(`${this.appId}.${name}`, `${this.clientId}@joplin`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,10 +16,12 @@ abstract class KeychainServiceDriverBase {
|
|||||||
return this.clientId_;
|
return this.clientId_;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public abstract readonly driverId: string;
|
||||||
|
public abstract supported(): Promise<boolean>;
|
||||||
|
public abstract setPassword(name: string, password: string): Promise<boolean>;
|
||||||
public abstract setPassword(name: string, password: string): Promise<boolean>;
|
public abstract setPassword(name: string, password: string): Promise<boolean>;
|
||||||
public abstract password(name: string): Promise<string>;
|
public abstract password(name: string): Promise<string>;
|
||||||
public abstract deletePassword(name: string): Promise<void>;
|
public abstract deletePassword(name: string): Promise<void>;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default KeychainServiceDriverBase;
|
export default KeychainServiceDriverBase;
|
||||||
|
@ -19,6 +19,12 @@ export interface PdfInfo {
|
|||||||
pageCount: number;
|
pageCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Keytar {
|
||||||
|
setPassword(key: string, client: string, password: string): Promise<void>;
|
||||||
|
getPassword(key: string, client: string): Promise<string|null>;
|
||||||
|
deletePassword(key: string, client: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
interface FetchOptions {
|
interface FetchOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
@ -485,8 +491,7 @@ const shim = {
|
|||||||
return (shim.isWindows() || shim.isMac()) && !shim.isPortable();
|
return (shim.isWindows() || shim.isMac()) && !shim.isPortable();
|
||||||
},
|
},
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
keytar: (): Keytar => {
|
||||||
keytar: (): any => {
|
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ import uuid from '../uuid';
|
|||||||
import ResourceService from '../services/ResourceService';
|
import ResourceService from '../services/ResourceService';
|
||||||
import KeymapService from '../services/KeymapService';
|
import KeymapService from '../services/KeymapService';
|
||||||
import KvStore from '../services/KvStore';
|
import KvStore from '../services/KvStore';
|
||||||
import KeychainServiceDriver from '../services/keychain/KeychainServiceDriver.node';
|
import KeychainServiceDriverNode from '../services/keychain/KeychainServiceDriver.node';
|
||||||
import KeychainServiceDriverDummy from '../services/keychain/KeychainServiceDriver.dummy';
|
import KeychainServiceDriverDummy from '../services/keychain/KeychainServiceDriver.dummy';
|
||||||
import FileApiDriverJoplinServer from '../file-api-driver-joplinServer';
|
import FileApiDriverJoplinServer from '../file-api-driver-joplinServer';
|
||||||
import OneDriveApi from '../onedrive-api';
|
import OneDriveApi from '../onedrive-api';
|
||||||
@ -281,6 +281,7 @@ async function switchClient(id: number, options: any = null) {
|
|||||||
|
|
||||||
currentClient_ = id;
|
currentClient_ = id;
|
||||||
BaseModel.setDb(databases_[id]);
|
BaseModel.setDb(databases_[id]);
|
||||||
|
KvStore.instance().setDb(databases_[id]);
|
||||||
|
|
||||||
BaseItem.encryptionService_ = encryptionServices_[id];
|
BaseItem.encryptionService_ = encryptionServices_[id];
|
||||||
Resource.encryptionService_ = encryptionServices_[id];
|
Resource.encryptionService_ = encryptionServices_[id];
|
||||||
@ -296,7 +297,7 @@ async function switchClient(id: number, options: any = null) {
|
|||||||
Setting.setConstant('pluginDir', pluginDir(id));
|
Setting.setConstant('pluginDir', pluginDir(id));
|
||||||
Setting.setConstant('isSubProfile', false);
|
Setting.setConstant('isSubProfile', false);
|
||||||
|
|
||||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
await loadKeychainServiceAndSettings([options.keychainEnabled ? KeychainServiceDriverNode : KeychainServiceDriverDummy]);
|
||||||
|
|
||||||
Setting.setValue('sync.target', syncTargetId());
|
Setting.setValue('sync.target', syncTargetId());
|
||||||
Setting.setValue('sync.wipeOutFailSafe', false); // To keep things simple, always disable fail-safe unless explicitly set in the test itself
|
Setting.setValue('sync.wipeOutFailSafe', false); // To keep things simple, always disable fail-safe unless explicitly set in the test itself
|
||||||
@ -360,7 +361,7 @@ async function setupDatabase(id: number = null, options: any = null) {
|
|||||||
if (databases_[id]) {
|
if (databases_[id]) {
|
||||||
BaseModel.setDb(databases_[id]);
|
BaseModel.setDb(databases_[id]);
|
||||||
await clearDatabase(id);
|
await clearDatabase(id);
|
||||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
await loadKeychainServiceAndSettings([options.keychainEnabled ? KeychainServiceDriverNode : KeychainServiceDriverDummy]);
|
||||||
Setting.setValue('sync.target', syncTargetId());
|
Setting.setValue('sync.target', syncTargetId());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -379,7 +380,7 @@ async function setupDatabase(id: number = null, options: any = null) {
|
|||||||
|
|
||||||
BaseModel.setDb(databases_[id]);
|
BaseModel.setDb(databases_[id]);
|
||||||
await clearSettingFile(id);
|
await clearSettingFile(id);
|
||||||
await loadKeychainServiceAndSettings(options.keychainEnabled ? KeychainServiceDriver : KeychainServiceDriverDummy);
|
await loadKeychainServiceAndSettings([options.keychainEnabled ? KeychainServiceDriverNode : KeychainServiceDriverDummy]);
|
||||||
|
|
||||||
reg.setDb(databases_[id]);
|
reg.setDb(databases_[id]);
|
||||||
Setting.setValue('sync.target', syncTargetId());
|
Setting.setValue('sync.target', syncTargetId());
|
||||||
|
Reference in New Issue
Block a user