fix: make saveState() a read-modify-write merge instead of a full-blob overwrite (closes #304)
Every extension page (the toolbar popup, a dApp approval window, the background's backgroundRefresh()) holds its own in-memory `state`, loaded once, and showView() saves on every navigation. saveState() wrote the entire state blob, so any second page that saved overwrote whatever another page had written since -- a whole wallet, name, addresses and encrypted secret included, with no attacker and no unusual input. saveState() now re-reads storage, diffs the persisted fields against a deep-cloned baseline snapshot taken at this page's last loadState()/saveState(), and writes only the fields that differ. Every other field is carried forward from storage in its loaded-and-normalized shape (normalizePersisted(), shared with loadState()), so a legacy or malformed record a load has always self-healed in memory keeps getting written back even on a save that touched something unrelated. showView() fires saveState() without awaiting it, so two saves from the SAME page can be in flight at once; a FIFO queue serializes them. Deliberately not done, a documented deviation from the plan on the issue: the live `state` of a field this page does not own is not rehydrated from what another page wrote, only the persisted record is. Adopting a concurrently-written value into `state` reintroduced the same clobber one page later, under the fire-and-forget saveState() calling convention every view uses -- caught red by tests/txStatus.test.js. Two writers of the same field still resolve last-writer-wins, documented at the merge point. tests/stateMerge.test.js covers both required cases against the real state.js and showView(): a save from a page loaded before a wallet was added elsewhere, and the approval-window reproduction from the issue. Both were confirmed failing against the prior full-blob write before this fix landed.
This commit is contained in:
26
TODO.md
26
TODO.md
@@ -44,6 +44,32 @@ but the review is broader than any of them.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-20: A second extension page can no longer silently delete a wallet
|
||||
([#304](https://git.eeqj.de/sneak/AutistMask/issues/304)). `saveState()` wrote
|
||||
the entire state blob, and every extension page — the toolbar popup, a dApp
|
||||
approval window, `backgroundRefresh()` — holds its own in-memory `state`,
|
||||
loaded once, with `showView()` saving on every navigation; a second page that
|
||||
saved after a first had written something new overwrote it, no attacker or
|
||||
unusual input required. `saveState()` is now a read-modify-write: it re-reads
|
||||
storage, diffs the persisted fields against a deep-cloned `baseline` snapshot
|
||||
taken at the last `loadState()`/`saveState()` on that page, and writes only
|
||||
the fields that actually changed — everything else is carried forward from
|
||||
storage in its loaded-and-normalized shape (`normalizePersisted()`, shared
|
||||
with `loadState()`), so a legacy or malformed record a load has always
|
||||
self-healed in memory keeps getting written back even on a save that touched
|
||||
something else entirely. `showView()` fires `saveState()` on every navigation
|
||||
without awaiting it, so two saves from the same page can be in flight at once;
|
||||
a FIFO queue serializes them rather than letting a slow one finish after a
|
||||
later one and re-derive a stale answer. Deliberately not done: the live
|
||||
`state` of a field this page does not own is not rehydrated from what another
|
||||
page wrote, only the persisted record is — adopting a concurrently-written
|
||||
value into `state` reintroduced the same clobber one page later, caught by
|
||||
`tests/txStatus.test.js` red. Two writers of the same field still resolve
|
||||
last-writer-wins, documented at the merge point. `tests/stateMerge.test.js`
|
||||
covers the two-page save and the approval-window reproduction from the issue —
|
||||
add a wallet in one page, force a save from a second page loaded before it,
|
||||
both wallets survive — each demonstrated failing against the unfixed full-blob
|
||||
write.
|
||||
- 2026-08-20: A forgotten password no longer wedges the wallet
|
||||
([#312](https://git.eeqj.de/sneak/AutistMask/issues/312)). Deleting a wallet
|
||||
was password-gated and importing its recovery phrase again was refused as a
|
||||
|
||||
Reference in New Issue
Block a user