Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
60 changes: 60 additions & 0 deletions src/main/__tests__/storage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,66 @@ describe('createDefaultStorage — pending verifiers', () => {
});
});

describe('createDefaultStorage — read-path plaintext policy (SEC-1592)', () => {
// Models a local, same-user attacker who can write the electron-store JSON
// file but has no OS-keychain access: they inject `plain:`-prefixed values the
// SDK would never write while encryption is available and allowPlaintext=false.
const forgedSession: Session = {
accessToken: 'eyJATTACKER',
refreshToken: 'rt_ATTACKER',
user: { id: 'user_ATTACKER', email: 'attacker@evil.example' } as unknown as User,
};

it('rejects an injected plain: session when encryption is available', () => {
const store = makeStore();
store.raw.set('session', `plain:${JSON.stringify(forgedSession)}`);
const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) });
expect(storage.getSession()).toBeNull();
});

it('ignores an injected plain: cookiePassword and generates a fresh one', () => {
const store = makeStore();
const attackerKnown = 'A'.repeat(32);
store.raw.set('cookiePassword', `plain:${attackerKnown}`);
const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) });
expect(storage.getOrCreateCookiePassword()).not.toBe(attackerKnown);
});

it('rejects injected plain: pending verifiers', () => {
const store = makeStore();
store.raw.set(
'pendingVerifiers',
`plain:${JSON.stringify({
'attacker-state': { value: 'sealed', expiresAt: Date.now() + 600000 },
})}`,
);
const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) });
expect(storage.takePendingVerifier('attacker-state')).toBeNull();
});

it('still honors plain: values when allowPlaintext is enabled (encryption unavailable)', () => {
const store = makeStore();
store.raw.set('session', `plain:${JSON.stringify(session)}`);
const storage = createDefaultStorage({
store,
safeStorage: makeSafeStorage(false),
allowPlaintext: true,
});
expect(storage.getSession()).toEqual(session);
});

it('still honors plain: values when allowPlaintext is enabled (encryption available)', () => {
const store = makeStore();
store.raw.set('session', `plain:${JSON.stringify(session)}`);
const storage = createDefaultStorage({
store,
safeStorage: makeSafeStorage(true),
allowPlaintext: true,
});
expect(storage.getSession()).toEqual(session);
});
});

describe('createDefaultStorage — default electron bindings', () => {
it('constructs without injected deps (uses mocked electron + electron-store)', () => {
// Exercises the default `safeStorage` / `new ElectronStore()` branch.
Expand Down
7 changes: 7 additions & 0 deletions src/main/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ export function createDefaultStorage(opts: CreateStorageOptions = {}): TokenStor
return ss.decryptString(buf);
}
if (stored.startsWith(PLAINTEXT_PREFIX)) {
// Mirror the write-path policy: the SDK only ever writes a plaintext
// value when `allowPlaintext` is enabled. Rejecting `plain:` otherwise
// prevents a local file writer from injecting a value the SDK would
// never have produced (forged session / cookiePassword / verifiers).
if (!allowPlaintext) {
return null;
}
return stored.slice(PLAINTEXT_PREFIX.length);
}
return null;
Expand Down
Loading