Skip to content

mesh: shuffle offered inventory so its order isn't a stable fingerprint - #93

Merged
commitchan merged 1 commit into
ni5arga:mainfrom
littledivy:harden/inventory-shuffle
Jul 30, 2026
Merged

mesh: shuffle offered inventory so its order isn't a stable fingerprint#93
commitchan merged 1 commit into
ni5arga:mainfrom
littledivy:harden/inventory-shuffle

Conversation

@littledivy

Copy link
Copy Markdown
Contributor

offerInventory() sends envelopeIds() in storage order. On the sqlite path that order is expiry-correlated (envelopes_by_expiry index), and once the cache exceeds MAX_SYNC_PER_PEER the cap emits the same soonest-to-expire subset on every offer.

A stable order and a stable capped subset make the offered id set a slowly-varying fingerprint: a peer that connects twice across a BLE identifier rotation can correlate both sessions by the set it was offered, which works against the point of rotating the advertising identity.

Shuffle before the cap so each offer is a fresh random sample with no stable order. Reconciliation is set membership (handleInventory), so order is irrelevant to correctness.

Tests: full suite passes (231), typecheck clean.

envelopeIds() returns storage order, which on the sqlite path is
expiry-correlated (envelopes_by_expiry). A stable order is a fingerprint,
and the MAX_SYNC_PER_PEER cap then emits the same soonest-to-expire subset
every offer. Both let a peer that connects twice across a BLE identifier
rotation correlate the two sessions by the offered set.

Shuffle before the cap: fresh random sample per offer, no stable order.
Reconciliation is set membership, so order carries no meaning.

@commitchan commitchan left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the fingerprint surface. envelopeIds() comes back in storage order, which on the sqlite path is expiry-correlated via envelopes_by_expiry — so both the offered order and the soonest-to-expire prefix under the cap were stable across encounters, i.e. linkable. A fresh random sample per offer kills both.

The shuffle is a correct Fisher–Yates over randomBytes (CSPRNG, not Math.random), guarded for <2, and it mutates a fresh array — both envelopeIds() implementations return a freshly-mapped array, so nothing shared is clobbered. Crucially it doesn't touch correctness: handleInventory reconciles on set membership, so offer order carries no meaning, and sampling-before-the-cap actually gives a large cache better coverage across reconnects than the old deterministic prefix. 231 tests green (inventory-sync included), typecheck and lint clean.

Only nit, non-blocking: r[i] % (i+1) has a little modulo bias, but it's a uniform skew across all devices and the order still varies every call, so it doesn't reintroduce a fingerprint. Fine as is.

Merging. (Rebases onto current main, which has the Expo dep fix your base predates.)

@commitchan
commitchan merged commit 6845227 into ni5arga:main Jul 30, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants