一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
260 行
9.4 KiB
TypeScript
260 行
9.4 KiB
TypeScript
import { afterAllCleanUp, encryptionService, setupDatabaseAndSynchronizer, switchClient, synchronizerStart } from '../../testing/test-utils';
|
|
import Setting from '../../models/Setting';
|
|
import EncryptionService from '../e2ee/EncryptionService';
|
|
import { localSyncInfo, saveLocalSyncInfo } from '../synchronizer/syncInfoUtils';
|
|
import NoteLockKey, { DecryptedNoteLockKey } from './NoteLockKey';
|
|
import NoteLockSession from './NoteLockSession';
|
|
import NoteLockService from './NoteLockService';
|
|
import eventManager, { EventName, NoteLockSessionChangeEvent } from '../../eventManager';
|
|
|
|
const unlockedSession = async (password = '123456') => {
|
|
const session = NoteLockSession.instance();
|
|
await NoteLockKey.instance().create(password);
|
|
await session.unlock(password);
|
|
return session;
|
|
};
|
|
|
|
const changeSyncedKeyId = (id: string) => {
|
|
const syncInfo = localSyncInfo();
|
|
syncInfo.noteLockKey = { ...syncInfo.noteLockKey, id };
|
|
saveLocalSyncInfo(syncInfo);
|
|
};
|
|
|
|
// Gates NoteLockKey.decrypt() so a lock, reset or teardown can be forced to land while an unlock is
|
|
// still awaiting. Returns a function that lets the decryption resolve.
|
|
const gateDecrypt = (result?: DecryptedNoteLockKey) => {
|
|
let release: ()=> void = () => {};
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const key = NoteLockKey.instance();
|
|
const realDecrypt = key.decrypt.bind(key);
|
|
jest.spyOn(key, 'decrypt').mockImplementation(async password => {
|
|
await gate;
|
|
return result ?? realDecrypt(password);
|
|
});
|
|
return release;
|
|
};
|
|
|
|
// Gates generateMasterKey() so a reset can be held mid-rotation, with the old key still persisted,
|
|
// while another unlock or reset races it. Returns a function that lets the rotation resolve.
|
|
const gateKeyGeneration = () => {
|
|
let release: ()=> void = () => {};
|
|
const gate = new Promise<void>(resolve => { release = resolve; });
|
|
const encryption = EncryptionService.instance();
|
|
const realGenerate = encryption.generateMasterKey.bind(encryption);
|
|
jest.spyOn(encryption, 'generateMasterKey').mockImplementation(async (password, options) => {
|
|
await gate;
|
|
return realGenerate(password, options);
|
|
});
|
|
return release;
|
|
};
|
|
|
|
describe('NoteLockSession', () => {
|
|
|
|
beforeEach(async () => {
|
|
await setupDatabaseAndSynchronizer(1);
|
|
await setupDatabaseAndSynchronizer(2);
|
|
await switchClient(1);
|
|
NoteLockService.destroyInstance();
|
|
NoteLockSession.destroyInstance();
|
|
NoteLockKey.destroyInstance();
|
|
EncryptionService.instance_ = encryptionService();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await afterAllCleanUp();
|
|
});
|
|
|
|
it('should stay locked after create and reset until explicitly unlocked', async () => {
|
|
const session = NoteLockSession.instance();
|
|
const firstKey = await NoteLockKey.instance().create('123456');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
|
|
await session.unlock('123456');
|
|
expect(session.isUnlocked()).toBe(true);
|
|
|
|
const secondKey = await session.reset('654321');
|
|
expect(secondKey.id).not.toBe(firstKey.id);
|
|
expect(session.isUnlocked()).toBe(false);
|
|
|
|
await session.unlock('654321');
|
|
expect(session.isUnlocked()).toBe(true);
|
|
});
|
|
|
|
it('should reject an incorrect password and stay locked', async () => {
|
|
const session = NoteLockSession.instance();
|
|
await NoteLockKey.instance().create('123456');
|
|
await expect(session.unlock('wrong password')).rejects.toThrow();
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should lock and clear the key', async () => {
|
|
const session = await unlockedSession();
|
|
session.lock();
|
|
expect(session.isUnlocked()).toBe(false);
|
|
expect(() => session.decryptedKey()).toThrow('Note lock session is locked');
|
|
});
|
|
|
|
it('should lock when the synced key changes', async () => {
|
|
const session = await unlockedSession();
|
|
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should stay locked when locked while an unlock is in flight', async () => {
|
|
const session = NoteLockSession.instance();
|
|
await NoteLockKey.instance().create('123456');
|
|
const release = gateDecrypt();
|
|
|
|
const unlocking = session.unlock('123456');
|
|
session.lock();
|
|
release();
|
|
|
|
await expect(unlocking).rejects.toThrow('locked while unlocking');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should not repopulate the key when destroyed mid-unlock', async () => {
|
|
const session = NoteLockSession.instance();
|
|
await NoteLockKey.instance().create('123456');
|
|
const release = gateDecrypt();
|
|
|
|
const unlocking = session.unlock('123456');
|
|
NoteLockSession.destroyInstance();
|
|
release();
|
|
|
|
await expect(unlocking).rejects.toThrow('locked while unlocking');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should not install a key that was replaced by sync while unlocking', async () => {
|
|
const session = NoteLockSession.instance();
|
|
const originalKey = await NoteLockKey.instance().create('123456');
|
|
// Generation is unchanged here, but the loaded id no longer matches the decrypted one.
|
|
const release = gateDecrypt({ id: originalKey.id, plainText: 'plaintext' });
|
|
|
|
const unlocking = session.unlock('123456');
|
|
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
|
|
release();
|
|
|
|
await expect(unlocking).rejects.toThrow('key changed while unlocking');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should keep the sync migration id and the first replaced key id through password resets', async () => {
|
|
const session = NoteLockSession.instance();
|
|
const firstKey = await NoteLockKey.instance().create('123456');
|
|
const syncMigrationId = localSyncInfo().syncMigrationId;
|
|
expect(syncMigrationId).toHaveLength(32);
|
|
expect(Setting.value('noteLock.passwordReset')).toBe(false);
|
|
|
|
await session.reset('654321');
|
|
expect(localSyncInfo().syncMigrationId).toBe(syncMigrationId);
|
|
expect(Setting.value('noteLock.passwordReset')).toBe(true);
|
|
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
|
|
|
|
// A second reset before a sync still has to replace the key the sync target holds.
|
|
await session.reset('999999');
|
|
expect(Setting.value('noteLock.keyIdToReset')).toBe(firstKey.id);
|
|
});
|
|
|
|
it('should not install a key that was reset while unlocking', async () => {
|
|
const session = NoteLockSession.instance();
|
|
const originalKey = await NoteLockKey.instance().create('123456');
|
|
const release = gateDecrypt({ id: originalKey.id, plainText: 'plaintext' });
|
|
|
|
const unlocking = session.unlock('123456');
|
|
await session.reset('654321');
|
|
release();
|
|
|
|
await expect(unlocking).rejects.toThrow('locked while unlocking');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should reject an old-password unlock that races a reset mid-rotation', async () => {
|
|
const session = await unlockedSession();
|
|
const releaseRotation = gateKeyGeneration();
|
|
|
|
const resetting = session.reset('654321');
|
|
await expect(session.unlock('123456')).rejects.toThrow('reset is in progress');
|
|
releaseRotation();
|
|
await resetting;
|
|
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should reject a reset that overlaps another rotation', async () => {
|
|
const session = await unlockedSession();
|
|
const releaseRotation = gateKeyGeneration();
|
|
|
|
const firstReset = session.reset('654321');
|
|
await expect(session.reset('abcdef')).rejects.toThrow('A note lock key reset is already in progress');
|
|
releaseRotation();
|
|
await firstReset;
|
|
|
|
expect(session.isUnlocked()).toBe(false);
|
|
});
|
|
|
|
it('should emit a session change event on unlock and on an effective lock only', async () => {
|
|
const events: NoteLockSessionChangeEvent[] = [];
|
|
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
|
|
eventManager.on(EventName.NoteLockSessionChange, listener);
|
|
|
|
try {
|
|
const session = NoteLockSession.instance();
|
|
await NoteLockKey.instance().create('123456');
|
|
|
|
session.lock();
|
|
expect(events).toEqual([]);
|
|
|
|
await session.unlock('123456');
|
|
expect(events).toEqual([{ unlocked: true }]);
|
|
|
|
session.lock();
|
|
session.lock();
|
|
expect(events).toEqual([{ unlocked: true }, { unlocked: false }]);
|
|
|
|
await session.unlock('123456');
|
|
await session.reset('654321');
|
|
expect(events.slice(2)).toEqual([{ unlocked: true }, { unlocked: false }]);
|
|
|
|
await session.unlock('654321');
|
|
NoteLockSession.destroyInstance();
|
|
expect(events.slice(4)).toEqual([{ unlocked: true }, { unlocked: false }]);
|
|
} finally {
|
|
eventManager.off(EventName.NoteLockSessionChange, listener);
|
|
}
|
|
});
|
|
|
|
it('should emit a lock event when the synced key changes', async () => {
|
|
const session = await unlockedSession();
|
|
|
|
const events: NoteLockSessionChangeEvent[] = [];
|
|
const listener = (event: NoteLockSessionChangeEvent) => events.push(event);
|
|
eventManager.on(EventName.NoteLockSessionChange, listener);
|
|
|
|
try {
|
|
changeSyncedKeyId('0123456789abcdef0123456789abcdef');
|
|
expect(session.isUnlocked()).toBe(false);
|
|
expect(events).toEqual([{ unlocked: false }]);
|
|
} finally {
|
|
eventManager.off(EventName.NoteLockSessionChange, listener);
|
|
}
|
|
});
|
|
|
|
it('should sync the encrypted note lock key without auto-unlocking or loading it into the E2EE registry', async () => {
|
|
const createdKey = await NoteLockKey.instance().create('123456');
|
|
NoteLockService.destroyInstance();
|
|
NoteLockSession.destroyInstance();
|
|
NoteLockKey.destroyInstance();
|
|
await synchronizerStart();
|
|
|
|
await switchClient(2);
|
|
EncryptionService.instance_ = encryptionService();
|
|
await synchronizerStart();
|
|
|
|
expect(NoteLockKey.instance().load()).toEqual(createdKey);
|
|
expect(NoteLockSession.instance().isUnlocked()).toBe(false);
|
|
expect(EncryptionService.instance().loadedMasterKeysCount()).toBe(0);
|
|
});
|
|
});
|