release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed. 1. `Inbox.acceptBridgeFrame(blob)` + shared 4096-entry msgId LRU. The relay durably stores blobs and pushes them to every active delivery channel; without a cross-channel ack the bridge frame ran first and the next inbox-poll re-dispatched the same blob ~30 s later, tripping on consumed prekeys. Bridge consumers now plumb pushed frames through `acceptBridgeFrame`, which shares the dedup gate + ack path with `pollOnce`. Whichever channel delivers first wins; the other acks-and-skips. Inbox records the msgId before the ack so a parallel poll can't observe an in-flight ack window. 2. `Shade.aliasSession(oldLabel, newLabel)`. First-contact forces the receiver to label the new session by the relay's sender fingerprint hint (`fp:<senderfp>`); the post-decrypt plaintext typically announces the peer's real address. Aliasing moves session, trusted identity, peer-verification, and identity- version under the canonical label. Holds the per-peer mutex on both labels (lexicographic order) so concurrent crypto ops can't observe a half-moved state. Refuses to overwrite an existing session at the new label. Wire change: `IncomingMessage.expiresAt?` now surfaces the relay's expiry so receivers can pass bridge frames straight to `acceptBridgeFrame` without inventing a TTL. Tests cover bridge-then-poll, poll-then-bridge, aliasSession happy path, refuse-to-overwrite, and same-label no-op. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
106
CHANGELOG.md
106
CHANGELOG.md
@@ -5,6 +5,112 @@ All notable changes to Shade are documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession`
|
||||
|
||||
Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed
|
||||
against. V4.8.2 closed the same-channel duplicate (8× via WS bridge
|
||||
became 1×); V4.8.3 closes the cross-channel duplicate (bridge push +
|
||||
inbox-poll catching up still delivered the same `msgId` twice) and
|
||||
adds the missing `Shade.aliasSession` primitive that lets receivers
|
||||
canonicalize their first-contact `fp:<senderfp>` label to the peer's
|
||||
real address once the plaintext announces it.
|
||||
|
||||
**(1) `Inbox.acceptBridgeFrame(blob)` + shared msgId LRU.** The
|
||||
relay's `Inbox.send` durably stores the blob and pushes it to every
|
||||
active delivery channel. Without a client-side cross-channel ack, a
|
||||
recipient running both a bridge and an inbox-poll cycle processed the
|
||||
same blob twice — the bridge frame ran first, the
|
||||
30 s-cadence inbox-poll fetched it again, and the duplicate dispatch
|
||||
tripped on already-consumed one-time prekeys
|
||||
(`one-time prekey not found: <id>`) or surfaced as duplicate
|
||||
`shade.receive` work even when the canonical first delivery had
|
||||
succeeded. The new `Inbox.acceptBridgeFrame(blob)` plumbs bridge
|
||||
deliveries through the same dispatch + ack pipeline that `pollOnce`
|
||||
uses; both paths share a 4096-entry msgId LRU so whichever channel
|
||||
delivers first wins, and the other channel acks-and-skips when the
|
||||
same `msgId` comes back around. The relay drops the blob on either
|
||||
ack so subsequent polls don't see it.
|
||||
|
||||
**(2) `Shade.aliasSession(oldLabel, newLabel)`.** First-contact
|
||||
forces the receiver to label the new session by the relay's
|
||||
sender-fingerprint hint (`fp:<senderfp>` — the only sender label
|
||||
visible at receive-time per the V4.8 sender-attribution feature),
|
||||
because the receiver doesn't yet know the sender's prekey-server
|
||||
address. The post-decrypt plaintext typically *announces* the
|
||||
sender's address; without an SDK primitive to canonicalize, every
|
||||
subsequent `send`/`receive` would either fail
|
||||
(`Failed to decrypt message — wrong key or tampered data`) or
|
||||
require app-level fp ↔ address translation around every call.
|
||||
`aliasSession` moves the per-peer storage rows (session, trusted
|
||||
identity, peer-verification record, identity-version counter) under
|
||||
the new label, holding the per-peer mutex on **both** labels for the
|
||||
duration so concurrent encrypt/decrypt can't observe a half-moved
|
||||
state. The send/receive `encryptChains` + `decryptChains` queues for
|
||||
the old label are also dropped so future operations start fresh.
|
||||
Refuses to overwrite an existing session under the new label
|
||||
(call `resetSession(newLabel)` first if that's intentional).
|
||||
|
||||
Both reported by Prism (multi-device E2EE terminal). Wave-3 pair
|
||||
handshake's web-side `paired` reply now decrypts cleanly,
|
||||
`BroadcastChannel.addMember` accepts the sender-address-vs-bilateral
|
||||
cross-check, and steady-state heartbeats / terminal traffic don't
|
||||
log a duplicate "OPK not found" per envelope.
|
||||
|
||||
### Added
|
||||
|
||||
#### `@shade/inbox`
|
||||
- `Inbox.acceptBridgeFrame(blob: FetchedBlob): Promise<boolean>` —
|
||||
feed a bridge-pushed envelope through the same dispatch + ack +
|
||||
dedup pipeline as `pollOnce`. Returns `true` when newly dispatched,
|
||||
`false` for a duplicate or a handler-rejected blob. Wire-up
|
||||
pattern documented inline.
|
||||
- A 4096-entry FIFO msgId LRU (`deliveredIds` + `deliveredOrder`) is
|
||||
shared between `acceptBridgeFrame` and `pollOnce` so cross-channel
|
||||
duplicates are skipped (and acked) without re-running
|
||||
`incomingHandler`.
|
||||
- `Inbox.handleBlob` now records every successfully-dispatched
|
||||
msgId before issuing the ack, eliminating the
|
||||
ack-in-flight window where a parallel `pollOnce` could see the
|
||||
blob and re-dispatch.
|
||||
|
||||
#### `@shade/transport-bridge`
|
||||
- `IncomingMessage.expiresAt?: number` — relay-assigned absolute
|
||||
expiry, surfaced from the wire envelope so receivers can pass it
|
||||
straight to `Inbox.acceptBridgeFrame` without inventing a TTL.
|
||||
`decodeWireMessage` populates it when the wire message includes
|
||||
one (V4.8.3 relay onward).
|
||||
|
||||
#### `@shade/sdk`
|
||||
- `Shade.aliasSession(oldLabel: string, newLabel: string): Promise<void>` —
|
||||
rename a session and its companion per-peer rows. Throws on
|
||||
no-such-session-for-oldLabel and refuses-to-overwrite-newLabel.
|
||||
|
||||
#### `@shade/core`
|
||||
- `ShadeSessionManager.aliasSession(oldLabel, newLabel)` — the
|
||||
primitive backing `Shade.aliasSession`. Holds the per-peer mutex
|
||||
on both labels (acquired in lexicographic order) so the rename is
|
||||
atomic w.r.t. concurrent crypto ops.
|
||||
- `ShadeEventMap['session.aliased']: { oldLabel, newLabel }` — emitted
|
||||
on a successful rename. Surfaced for observability dashboards.
|
||||
|
||||
### Tests
|
||||
- `packages/shade-inbox/tests/client.test.ts` — two new cases:
|
||||
bridge-then-poll and poll-then-bridge, both asserting exactly one
|
||||
`incomingHandler` dispatch per `inbox.send`.
|
||||
- `packages/shade-sdk/tests/sdk.test.ts` — new `aliasSession`
|
||||
cases: happy-path canonicalization (Bob initiates as `alice`, Alice
|
||||
receives under `fp:bobfp`, aliases to `bob`, subsequent ratchet
|
||||
exchange in both directions decrypts cleanly), refuses-to-overwrite,
|
||||
same-label no-op.
|
||||
|
||||
### Migration
|
||||
|
||||
None. `acceptBridgeFrame` and `aliasSession` are additive. Existing
|
||||
bridge consumers that don't call `acceptBridgeFrame` keep working as
|
||||
before — they just don't get cross-channel dedup, and the same
|
||||
duplicate-on-poll behavior persists. `aliasSession` callers are
|
||||
opt-in.
|
||||
|
||||
## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup
|
||||
|
||||
Two interlocking robustness fixes for the first-contact / duplicate-fan-out
|
||||
|
||||
Reference in New Issue
Block a user