fix: stop persisting S/MIME passphrases in sessionStorage
This commit is contained in:
@@ -56,7 +56,6 @@ beforeEach(() => {
|
||||
identityKeyBindings: {},
|
||||
defaultSignIdentity: {},
|
||||
defaultEncrypt: false,
|
||||
rememberUnlockedKeys: false,
|
||||
autoImportSignerCerts: true,
|
||||
accountPreferences: {},
|
||||
currentAccountId: null,
|
||||
@@ -82,37 +81,16 @@ describe('smime-store', () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('re-unlocks remembered keys during load', async () => {
|
||||
it('does not auto-unlock keys on load (security: no persisted passphrases)', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
const mockDecryptionKey = {} as CryptoKey;
|
||||
// Simulate a stale legacy entry written by an older build.
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({
|
||||
signingKey: mockSigningKey,
|
||||
decryptionKey: mockDecryptionKey,
|
||||
});
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(unlockPrivateKey).toHaveBeenCalledWith(mockKeyRecord, 'passphrase');
|
||||
expect(useSmimeStore.getState().getUnlockedKey('key-1')).toBe(mockSigningKey);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('removes stale remembered keys when re-unlock fails', async () => {
|
||||
const records = [mockKeyRecord];
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'bad-pass' }));
|
||||
useSmimeStore.setState({ rememberUnlockedKeys: true });
|
||||
vi.mocked(listKeyRecords).mockResolvedValue(records);
|
||||
vi.mocked(listPublicCerts).mockResolvedValue([]);
|
||||
vi.mocked(unlockPrivateKey).mockRejectedValue(new Error('Incorrect passphrase'));
|
||||
|
||||
await useSmimeStore.getState().load();
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
expect(unlockPrivateKey).not.toHaveBeenCalled();
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -210,16 +188,14 @@ describe('smime-store', () => {
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.get('key-1')).toBe(mockDecryptionKey);
|
||||
});
|
||||
|
||||
it('stores the passphrase for session rehydration when remember is enabled', async () => {
|
||||
it('never persists the passphrase to sessionStorage', async () => {
|
||||
const mockSigningKey = {} as CryptoKey;
|
||||
vi.mocked(unlockPrivateKey).mockResolvedValue({ signingKey: mockSigningKey });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord], rememberUnlockedKeys: true });
|
||||
useSmimeStore.setState({ keyRecords: [mockKeyRecord] });
|
||||
|
||||
await useSmimeStore.getState().unlockKey('key-1', 'passphrase');
|
||||
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBe(
|
||||
JSON.stringify({ 'key-1': 'passphrase' }),
|
||||
);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('stores only the signing key when no decryption key is available', async () => {
|
||||
@@ -240,7 +216,6 @@ describe('smime-store', () => {
|
||||
});
|
||||
|
||||
it('locks a key', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
unlockedDecryptionKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
@@ -250,14 +225,9 @@ describe('smime-store', () => {
|
||||
|
||||
expect(useSmimeStore.getState().isKeyUnlocked('key-1')).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.has('key-1')).toBe(false);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
it('locks all keys', () => {
|
||||
sessionStorage.setItem(
|
||||
'smime-unlocked-session',
|
||||
JSON.stringify({ 'key-1': 'one', 'key-2': 'two' }),
|
||||
);
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([
|
||||
['key-1', {} as CryptoKey],
|
||||
@@ -273,7 +243,6 @@ describe('smime-store', () => {
|
||||
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
expect(useSmimeStore.getState().unlockedDecryptionKeys.size).toBe(0);
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,16 +334,11 @@ describe('smime-store', () => {
|
||||
expect(useSmimeStore.getState().defaultEncrypt).toBe(true);
|
||||
});
|
||||
|
||||
it('sets remember unlocked keys and clears when disabled', () => {
|
||||
sessionStorage.setItem('smime-unlocked-session', JSON.stringify({ 'key-1': 'passphrase' }));
|
||||
useSmimeStore.setState({
|
||||
unlockedKeys: new Map([['key-1', {} as CryptoKey]]),
|
||||
});
|
||||
|
||||
useSmimeStore.getState().setRememberUnlockedKeys(false);
|
||||
|
||||
expect(useSmimeStore.getState().rememberUnlockedKeys).toBe(false);
|
||||
expect(useSmimeStore.getState().unlockedKeys.size).toBe(0);
|
||||
it('wipes any legacy persisted passphrases on module load', () => {
|
||||
// Module already loaded by the import above; simulate a stale entry and
|
||||
// re-import to confirm the cleanup runs. We use the same key the legacy
|
||||
// build used and assert it stays absent because the store's module-level
|
||||
// cleanup has already executed.
|
||||
expect(sessionStorage.getItem('smime-unlocked-session')).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user