1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-24 10:27:10 +02:00

Desktop: Windows portable: Fix keychain-backed storage incorrectly enabled (#10942)

This commit is contained in:
Henry Heino 2024-09-02 04:26:43 -07:00 committed by GitHub
parent 6163364b26
commit fd06c18cf0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 119 additions and 24 deletions

View File

@ -5,6 +5,7 @@ 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'; import KeychainServiceDriverBase from './keychain/KeychainServiceDriverBase';
import shim from '../shim';
type KeychainServiceDriverConstructor = new (appId: string, clientId: string)=> KeychainServiceDriverBase; type KeychainServiceDriverConstructor = new (appId: string, clientId: string)=> KeychainServiceDriverBase;
@ -19,6 +20,14 @@ type KeychainServiceDriverConstructor = new (appId: string, clientId: string)=>
export async function loadKeychainServiceAndSettings(keychainServiceDrivers: KeychainServiceDriverConstructor[]) { export async function loadKeychainServiceAndSettings(keychainServiceDrivers: KeychainServiceDriverConstructor[]) {
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();
// Temporary workaround: For a short time, pre-release versions of Joplin Portable encrypted
// saved keys using the keychain. This can break sync when transferring Joplin between devices.
// To prevent secure keys from being lost, we enable read-only keychain access in portable mode.
if (shim.isPortable()) {
KeychainService.instance().readOnly = true;
}
await KeychainService.instance().initialize( await KeychainService.instance().initialize(
keychainServiceDrivers.map(Driver => new Driver(Setting.value('appId'), clientId)), keychainServiceDrivers.map(Driver => new Driver(Setting.value('appId'), clientId)),
); );

View File

@ -13,18 +13,21 @@ interface SafeStorageMockOptions {
} }
const mockSafeStorage = ({ // Safe storage const mockSafeStorage = ({ // Safe storage
isEncryptionAvailable = jest.fn(() => true), isEncryptionAvailable = () => true,
encryptString = jest.fn(async s => (`e:${s}`)), encryptString = async s => (`e:${s}`),
decryptString = jest.fn(async s => s.substring(2)), decryptString = async (s) => s.substring(2),
}: SafeStorageMockOptions) => { }: SafeStorageMockOptions) => {
const mock = {
isEncryptionAvailable: jest.fn(isEncryptionAvailable),
encryptString: jest.fn(encryptString),
decryptString: jest.fn(decryptString),
getSelectedStorageBackend: jest.fn(() => 'mock'),
};
shim.electronBridge = () => ({ shim.electronBridge = () => ({
safeStorage: { safeStorage: mock,
isEncryptionAvailable,
encryptString,
decryptString,
getSelectedStorageBackend: () => 'mock',
},
}); });
return mock;
}; };
const mockKeytar = () => { const mockKeytar = () => {
@ -51,10 +54,19 @@ const makeDrivers = () => [
new KeychainServiceDriverNode(Setting.value('appId'), Setting.value('clientId')), new KeychainServiceDriverNode(Setting.value('appId'), Setting.value('clientId')),
]; ];
const testSaveLoadSecureSetting = async (expectedPassword: string) => {
Setting.setValue('encryption.masterPassword', expectedPassword);
await Setting.saveAll();
await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe(expectedPassword);
};
describe('KeychainService', () => { describe('KeychainService', () => {
beforeEach(async () => { beforeEach(async () => {
await setupDatabaseAndSynchronizer(0); await setupDatabaseAndSynchronizer(0);
await switchClient(0); await switchClient(0);
KeychainService.instance().readOnly = false;
Setting.setValue('keychain.supported', 1); Setting.setValue('keychain.supported', 1);
shim.electronBridge = null; shim.electronBridge = null;
shim.keytar = null; shim.keytar = null;
@ -91,16 +103,12 @@ describe('KeychainService', () => {
const keytarMock = mockKeytar(); const keytarMock = mockKeytar();
await KeychainService.instance().initialize(makeDrivers()); await KeychainService.instance().initialize(makeDrivers());
Setting.setValue('encryption.masterPassword', 'test-password'); await testSaveLoadSecureSetting('test-password');
await Setting.saveAll();
expect(keytarMock.setPassword).toHaveBeenCalledWith( expect(keytarMock.setPassword).toHaveBeenCalledWith(
`${Setting.value('appId')}.setting.encryption.masterPassword`, `${Setting.value('appId')}.setting.encryption.masterPassword`,
`${Setting.value('clientId')}@joplin`, `${Setting.value('clientId')}@joplin`,
'test-password', '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 () => { test('should re-check for keychain support when a new driver is added', async () => {
@ -125,4 +133,57 @@ describe('KeychainService', () => {
await KeychainService.instance().detectIfKeychainSupported(); await KeychainService.instance().detectIfKeychainSupported();
expect(Setting.value('keychain.supported')).toBe(0); expect(Setting.value('keychain.supported')).toBe(0);
}); });
test('should load settings from a read-only KeychainService if not present in the database', async () => {
mockSafeStorage({});
const service = KeychainService.instance();
await service.initialize(makeDrivers());
expect(await service.setPassword('setting.encryption.masterPassword', 'keychain password')).toBe(true);
service.readOnly = true;
await service.initialize(makeDrivers());
await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe('keychain password');
});
test('settings should be saved to database with a read-only keychain', async () => {
const safeStorage = mockSafeStorage({});
const service = KeychainService.instance();
service.readOnly = true;
await service.initialize(makeDrivers());
await service.detectIfKeychainSupported();
expect(Setting.value('keychain.supported')).toBe(1);
await testSaveLoadSecureSetting('testing...');
expect(safeStorage.encryptString).not.toHaveBeenCalledWith('testing...');
});
test('loading settings with a read-only keychain should prefer the database', async () => {
const safeStorage = mockSafeStorage({});
const service = KeychainService.instance();
await service.initialize(makeDrivers());
// Set an initial value
expect(await service.setPassword('setting.encryption.masterPassword', 'test keychain password')).toBe(true);
service.readOnly = true;
await service.initialize(makeDrivers());
safeStorage.encryptString.mockClear();
Setting.setValue('encryption.masterPassword', 'test database password');
await Setting.saveAll();
await Setting.load();
expect(Setting.value('encryption.masterPassword')).toBe('test database password');
expect(await service.password('setting.encryption.masterPassword')).toBe('test keychain password');
// Should not have attempted to encrypt settings in read-only mode.
expect(safeStorage.encryptString).not.toHaveBeenCalled();
});
}); });

View File

@ -11,6 +11,7 @@ export default class KeychainService extends BaseService {
private keysNeedingMigration_: Set<string>; private keysNeedingMigration_: Set<string>;
private static instance_: KeychainService; private static instance_: KeychainService;
private enabled_ = true; private enabled_ = true;
private readOnly_ = false;
public static instance(): KeychainService { public static instance(): KeychainService {
if (!this.instance_) this.instance_ = new KeychainService(); if (!this.instance_) this.instance_ = new KeychainService();
@ -53,8 +54,17 @@ export default class KeychainService extends BaseService {
this.enabled_ = v; this.enabled_ = v;
} }
public get readOnly() {
return this.readOnly_;
}
public set readOnly(v: boolean) {
this.readOnly_ = v;
}
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;
if (this.readOnly_) return false;
// Optimization: Handles the case where the password doesn't need to change. // Optimization: Handles the case where the password doesn't need to change.
// TODO: Re-evaluate whether this optimization is necessary after refactoring the driver // TODO: Re-evaluate whether this optimization is necessary after refactoring the driver
@ -137,16 +147,29 @@ export default class KeychainService extends BaseService {
Setting.setValue('keychain.supported', -1); Setting.setValue('keychain.supported', -1);
} }
const passwordIsSet = await this.setPassword('zz_testingkeychain', 'mytest'); if (!this.readOnly) {
const passwordIsSet = await this.setPassword('zz_testingkeychain', 'mytest');
if (!passwordIsSet) { if (!passwordIsSet) {
this.logger().info('KeychainService: could not set test password - keychain support will be disabled'); this.logger().info('KeychainService: could not set test password - keychain support will be disabled');
Setting.setValue('keychain.supported', 0); Setting.setValue('keychain.supported', 0);
} else {
const result = await this.password('zz_testingkeychain');
await this.deletePassword('zz_testingkeychain');
this.logger().info('KeychainService: tried to set and get password. Result was:', result);
Setting.setValue('keychain.supported', result === 'mytest' ? 1 : 0);
}
} else { } else {
const result = await this.password('zz_testingkeychain'); // The supported check requires write access to the keychain -- rely on the more
await this.deletePassword('zz_testingkeychain'); // limited support checks done by each driver.
this.logger().info('KeychainService: tried to set and get password. Result was:', result); const supported = this.drivers_.length > 0;
Setting.setValue('keychain.supported', result === 'mytest' ? 1 : 0); Setting.setValue('keychain.supported', supported ? 1 : 0);
if (supported) {
logger.info('Starting KeychainService in read-only mode. Keys will be read, but not written.');
} else {
logger.info('Failed to start in read-only mode -- no supported drivers found.');
}
} }
Setting.setValue('keychain.lastAvailableDrivers', this.drivers_.map(driver => driver.driverId)); Setting.setValue('keychain.lastAvailableDrivers', this.drivers_.map(driver => driver.driverId));
} }

View File

@ -5,7 +5,7 @@ export default class KeychainServiceDriver extends KeychainServiceDriverBase {
public override readonly driverId: string = 'node-keytar'; public override readonly driverId: string = 'node-keytar';
public async supported(): Promise<boolean> { public async supported(): Promise<boolean> {
return !!shim.keytar(); return !!shim.keytar?.();
} }
public async setPassword(name: string, password: string): Promise<boolean> { public async setPassword(name: string, password: string): Promise<boolean> {

View File

@ -76,7 +76,9 @@ export default function versionInfo(packageInfo: PackageInfo, plugins: Plugins)
_('Client ID: %s', Setting.value('clientId')), _('Client ID: %s', Setting.value('clientId')),
_('Sync Version: %s', Setting.value('syncVersion')), _('Sync Version: %s', Setting.value('syncVersion')),
_('Profile Version: %s', reg.db().version()), _('Profile Version: %s', reg.db().version()),
_('Keychain Supported: %s', Setting.value('keychain.supported') >= 1 ? _('Yes') : _('No')), // The portable app temporarily supports read-only keychain access (but disallows
// write).
_('Keychain Supported: %s', (Setting.value('keychain.supported') >= 1 && !shim.isPortable()) ? _('Yes') : _('No')),
]; ];
if (gitInfo) { if (gitInfo) {