Skip to content

Feat/prem keys - #529

Open
KodeSage wants to merge 2 commits into
codebestia:mainfrom
KodeSage:feat/prem_keys
Open

Feat/prem keys#529
KodeSage wants to merge 2 commits into
codebestia:mainfrom
KodeSage:feat/prem_keys

Conversation

@KodeSage

Copy link
Copy Markdown
Contributor

Description

Warns a device to replenish its one-time prekeys before it runs dry, instead of only discovering the problem at exhaustion.

Once a device's unconsumed one-time prekeys hit zero, every sender fetching its bundle is silently downgraded to a 3-DH session with no forward-secrecy contribution from a one-time key. This PR surfaces the shortfall early, over two surfaces:

  1. oneTimePreKeysRemaining in GET /devices — already present on main; this PR verifies it and documents it as the reconnect path for devices that were offline when the threshold was crossed.
  2. A prekeys_low WebSocket event, emitted after a bundle fetch drops a device below the threshold (default 20, overridable via the new PREKEY_LOW_THRESHOLD env var).

Changes:

  1. New service — services/prekeyLowSignal.ts: owns the threshold, the unconsumed-prekey count helper, and the debounce latch.
  2. Debounce is a latch, not a time window — which is what "once per threshold crossing" actually requires. Implemented as an atomic Redis SET NX EX on prekeys:low:{deviceId}, so two gateways racing on concurrent bundle fetches still produce exactly one emit. Falls back to an in-process Set when Redis is unconfigured (dev/tests) and on Redis errors, degrading to local debounce rather than either spamming the device or going silent.
  3. Bundle fetch (routes/users.ts): the remaining-count query moved inside the existing claiming transaction, so remaining reflects this consumption and cannot race a concurrent fetch into a wrong value. The emit is fire-and-forget and never blocks or fails the bundle response.
  4. Replenishment re-arms the signal (routes/devices.ts): the upload route recounts from the DB rather than deriving currentCount + batch.length, because ON CONFLICT DO NOTHING silently drops duplicate keyIds and would inflate the derived figure. The response now echoes oneTimePreKeysRemaining so clients can confirm they cleared the threshold without a follow-up GET /devices.
  5. Revocation clears the latch: revokeDeviceRow deletes the device's prekeys, so a leftover latch would otherwise suppress the first warning if that identity key is later re-registered (which POST /devices supports).
  6. Docs: new "Low-prekey warning before exhaustion" section in apps/backend/docs/e2ee-onboarding.md covering the event payload, delivery targeting, and debounce semantics; PREKEY_LOW_THRESHOLD added to .env.example.

Event payload:

{
  "deviceId": "uuid",
  "oneTimePreKeysRemaining": 19,
  "threshold": 20
}

Design notes:

  • Targeting — the event goes to the device:{id} room, not the user room. Only the owning device can generate replacement prekeys, and the device: room already routes across horizontally-scaled gateways via the Socket.IO Redis adapter.
  • Offline devices — a device that is disconnected when the threshold is crossed misses the event; there is no queue for it. That is why oneTimePreKeysRemaining on GET /devices matters as the reconnect path. Building socket-event persistence is a larger design decision than this issue calls for, so it is documented rather than implemented.
  • Exhausted fetches don't re-signal — when no key is consumed the count hasn't moved, and the crossing already signalled on the fetch that drained the last key.

Issue numbers

Closes #161

Type of change

  • Bug fix
  • New feature
  • Documentation update
  • Other

Checklist

  • I have read the contributing guidelines
  • I have tested my changes locally
  • My code follows the project's coding standards

Testing

  • 425 backend tests pass (up from 415 — 10 new).
  • New suite src/__tests__/prekeysLow.test.ts covers the acceptance criteria directly: emits below threshold, silent at/above it, at most one emit across a full 19→0 drain, per-device latch isolation, stops firing after replenishment then fires again on the next crossing, and swallows socket-layer failures.
  • users.bundle.test.ts asserts the bundle route feeds the post-consumption count to the signal, and that an exhausted fetch does not re-signal.
  • devices.prekeys.test.ts asserts upload re-arms the latch only once the device is back at or above the threshold.
  • tsc --noEmit produces an identical error count to main (11 lines, all pre-existing in roomManager.ts / uploads.test.ts / stellarListener.ts) — no new type errors.
  • eslint and prettier --check clean on all changed source files.

apps/backend/docs/e2ee-onboarding.md fails prettier --check, but it does so on main as well — running --write would bury this diff in unrelated reformatting, so it was left alone.

@drips-wave

drips-wave Bot commented Jul 30, 2026

Copy link
Copy Markdown

@KodeSage Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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.

Prekey low-watermark replenishment signal

1 participant