fix(desktop): reject empty encrypted token payloads

This commit is contained in:
Doud-FR 2026-07-31 01:11:30 +02:00 committed by Austin Pickett
parent 6cb459af9e
commit 3553c1b313
2 changed files with 44 additions and 1 deletions

View file

@ -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)
})

View file

@ -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]
}