Summary
persistPrekeys() in src/lib/app-state.tsx:114-127 is fired unawaited on every onPrekeysChanged event (every message send or receive). It calls saveLocalPrekeys / savePeerPrekeys in src/lib/db.ts:694-723, which each run as DELETE FROM ... followed by a loop of separate INSERT calls with no transaction, unlike wipeEverything() (db.ts:788) which correctly wraps its wipe in withExclusiveTransactionAsync.
panicWipe() (app-state.tsx:358-414) calls mesh.dropSecrets() then db.wipeEverything(), but never cancels, awaits, or fences off a persistPrekeys() call that was already in flight when the wipe started. Nothing stops that stray write from landing on the same SQLite connection after, or interleaved with, the wipe's transaction commits.
Why it matters
If a message was processed (consuming or replenishing an OTK) right before the user hits panic wipe, the snapshot for that write already contains a real SPK/OTK secret. The wipe can report success while that secret gets written straight back into receive_keys / one_time_keys / peer_prekeys moments later.
This is the exact scenario the forward secrecy claim depends on not happening (docs/FORWARD-SECRECY.md: "The recipient deletes that OTK secret on open, compromise afterwards cannot open that ciphertext") and undermines threat model A6, phone seized after the fact, panic wipe should leave nothing recoverable.
Evidence
src/lib/db.ts:694-709 saveLocalPrekeys, no transaction
src/lib/db.ts:711-723 savePeerPrekeys, no transaction
src/lib/db.ts:788 wipeEverything uses withExclusiveTransactionAsync, so the pattern exists elsewhere but was not applied here
src/lib/app-state.tsx:114-127 persistPrekeys, captures snapshot synchronously then does two separate unguarded await db.save... calls
src/lib/app-state.tsx:168-171 mesh.onPrekeysChanged fires void persistPrekeys(cur), fire and forget, no queue or mutex
src/lib/app-state.tsx:358-414 panicWipe, no fence against an in-flight persist call, no cancellation of the periodic syncKeys interval either
PoC steps
- Establish an active conversation so OTKs are being consumed or replenished on
onPrekeysChanged.
- Trigger an inbound message that fires
onPrekeysChanged right before calling panic wipe, so persistPrekeys() has captured a snapshot with live secret bytes and is mid saveLocalPrekeys/savePeerPrekeys when wipeEverything() starts.
- After wipe reports success, inspect
receive_keys / one_time_keys / peer_prekeys. If the interleaving landed the stray write after the wipe's deletes, the secret is back on disk.
Proposed fix
- Wrap
saveLocalPrekeys / savePeerPrekeys each in their own transaction, same pattern as wipeEverything().
- Add a wiped fence flag checked by
persistPrekeys before it writes, set at the very start of panicWipe().
- Cancel the periodic
syncKeys interval and null mesh.onPrekeysChanged as part of the wipe sequence.
Done when
Found during a codebase review.
Summary
persistPrekeys()insrc/lib/app-state.tsx:114-127is fired unawaited on everyonPrekeysChangedevent (every message send or receive). It callssaveLocalPrekeys/savePeerPrekeysinsrc/lib/db.ts:694-723, which each run asDELETE FROM ...followed by a loop of separateINSERTcalls with no transaction, unlikewipeEverything()(db.ts:788) which correctly wraps its wipe inwithExclusiveTransactionAsync.panicWipe()(app-state.tsx:358-414) callsmesh.dropSecrets()thendb.wipeEverything(), but never cancels, awaits, or fences off apersistPrekeys()call that was already in flight when the wipe started. Nothing stops that stray write from landing on the same SQLite connection after, or interleaved with, the wipe's transaction commits.Why it matters
If a message was processed (consuming or replenishing an OTK) right before the user hits panic wipe, the snapshot for that write already contains a real SPK/OTK secret. The wipe can report success while that secret gets written straight back into
receive_keys/one_time_keys/peer_prekeysmoments later.This is the exact scenario the forward secrecy claim depends on not happening (
docs/FORWARD-SECRECY.md: "The recipient deletes that OTK secret on open, compromise afterwards cannot open that ciphertext") and undermines threat model A6, phone seized after the fact, panic wipe should leave nothing recoverable.Evidence
src/lib/db.ts:694-709saveLocalPrekeys, no transactionsrc/lib/db.ts:711-723savePeerPrekeys, no transactionsrc/lib/db.ts:788wipeEverythinguseswithExclusiveTransactionAsync, so the pattern exists elsewhere but was not applied heresrc/lib/app-state.tsx:114-127persistPrekeys, captures snapshot synchronously then does two separate unguardedawait db.save...callssrc/lib/app-state.tsx:168-171mesh.onPrekeysChangedfiresvoid persistPrekeys(cur), fire and forget, no queue or mutexsrc/lib/app-state.tsx:358-414panicWipe, no fence against an in-flight persist call, no cancellation of the periodicsyncKeysinterval eitherPoC steps
onPrekeysChanged.onPrekeysChangedright before calling panic wipe, sopersistPrekeys()has captured a snapshot with live secret bytes and is midsaveLocalPrekeys/savePeerPrekeyswhenwipeEverything()starts.receive_keys/one_time_keys/peer_prekeys. If the interleaving landed the stray write after the wipe's deletes, the secret is back on disk.Proposed fix
saveLocalPrekeys/savePeerPrekeyseach in their own transaction, same pattern aswipeEverything().persistPrekeysbefore it writes, set at the very start ofpanicWipe().syncKeysinterval and nullmesh.onPrekeysChangedas part of the wipe sequence.Done when
Found during a codebase review.