diff --git a/apps/desktop/electron/native-token-store.test.ts b/apps/desktop/electron/native-token-store.test.ts index 83478b42e60..bf22b4cbde0 100644 --- a/apps/desktop/electron/native-token-store.test.ts +++ b/apps/desktop/electron/native-token-store.test.ts @@ -326,3 +326,34 @@ test('an unusable keychain fails the write loudly and writes nothing', () => { // ...and must not clobber the tokens already on disk. assert.equal(broken.fileText(), before) }) + +test('an encrypt that returns null is refused rather than blanking the stored entry', () => { + const existing = createFakeDisk() + + persistNativeTokenSet(GATEWAY, TOKENS, existing.io) + + const before = existing.fileText() + const nulled = createFakeDisk(before, { encrypt: () => null }) + let writes = 0 + + // Spy that still delegates, so a stray write would show up in BOTH the + // counter and the file text. + const io = { + ...nulled.io, + writeStoreText: (text: string) => { + writes += 1 + nulled.io.writeStoreText(text) + } + } + + // A quiet null is the same failure as a throw and must be just as loud. + assert.throws( + () => persistNativeTokenSet(GATEWAY, { ...TOKENS, accessToken: 'AT-new' }, io), + /refusing to overwrite stored native tokens/ + ) + assert.equal(writes, 0, 'the store file must not be written at all') + // Byte-for-byte unchanged... + assert.equal(nulled.fileText(), before) + // ...and the original token set still loads, refresh token intact. + assert.deepEqual(loadNativeTokenSet(GATEWAY, createFakeDisk(before).io), TOKENS) +}) diff --git a/apps/desktop/electron/native-token-store.ts b/apps/desktop/electron/native-token-store.ts index 1adb00a5a20..b99c0aa9481 100644 --- a/apps/desktop/electron/native-token-store.ts +++ b/apps/desktop/electron/native-token-store.ts @@ -34,6 +34,8 @@ export interface NativeTokenStoreIo { /** * Encrypt one plaintext blob. main.ts passes the strict safeStorage helper, * which THROWS when the OS keychain is unavailable — that must stay loud. + * A `null` return is treated as the same authoritative failure: the caller + * throws rather than persisting an empty entry over good tokens. */ encrypt: (plaintext: string) => StoredTokenSecret | null /** Decrypt a stored payload; returns '' when it cannot be read. */ @@ -76,7 +78,17 @@ export function persistNativeTokenSet(baseUrl: string, tokens: NativeTokenSet | // plaintext on disk. Deliberately outside the try below: an unusable // keychain is an authoritative write failure and must surface to the // caller, not be logged away as if the tokens were saved. - store[baseUrl] = io.encrypt(JSON.stringify(tokens)) + const secret = io.encrypt(JSON.stringify(tokens)) + + if (!secret) { + // A null blob is the same failure as a throw, only quieter. Storing it + // would replace a good entry with nothing: the write would report + // success, the next launch would show signed out, and the refresh token + // would be unrecoverable. Fail before touching the store. + throw new Error('Secure token storage returned no encrypted payload; refusing to overwrite stored native tokens.') + } + + store[baseUrl] = secret } else { delete store[baseUrl] }