Skip to content

[High] Panic wipe can resurrect a wiped OTK secret through an unfenced persistPrekeys race #70

Description

@konkomaji

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

  1. Establish an active conversation so OTKs are being consumed or replenished on onPrekeysChanged.
  2. 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.
  3. 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

  • Prekey table writes are transactional
  • A wipe in progress cannot be raced by an in-flight persist write, regression test covers the interleaving
  • Periodic sync/callback is torn down as part of panicWipe

Found during a codebase review.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions