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:
@@ -63,6 +63,14 @@ export interface InboxOptions {
|
||||
const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 30_000;
|
||||
const DEFAULT_MAX_ATTEMPTS = 10;
|
||||
/**
|
||||
* Cap for the cross-channel msgId dedup LRU. Each entry is a 64-char hex
|
||||
* string; 4096 entries ≈ 256 KiB of overhead, plenty of headroom for
|
||||
* bursty traffic (the LRU only needs to span the window between a bridge
|
||||
* push and the next inbox-poll catching up — typically 30 s × the
|
||||
* recipient's throughput).
|
||||
*/
|
||||
const DEFAULT_DEDUP_LRU_CAP = 4096;
|
||||
|
||||
/**
|
||||
* High-level inbox orchestrator.
|
||||
@@ -105,6 +113,23 @@ export class Inbox {
|
||||
private started = false;
|
||||
private registered = false;
|
||||
|
||||
/**
|
||||
* Bounded msgId dedup window. Used by both the inbox-poll path
|
||||
* (`pollOnce` → `handleBlob`) and the bridge-push path
|
||||
* (`acceptBridgeFrame`). The relay stores blobs durably and pushes
|
||||
* them to every active delivery channel; without a shared dedup gate
|
||||
* here the recipient processes the same envelope twice — once from
|
||||
* the bridge, again from the next inbox-poll. The duplicate receive
|
||||
* trips on consumed one-time prekeys ("OPK not found") and pollutes
|
||||
* logs even when the canonical first delivery succeeded. See V4.8.3
|
||||
* Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`.
|
||||
*
|
||||
* Insertion order is FIFO; the oldest msgId is evicted once the LRU
|
||||
* exceeds `DEFAULT_DEDUP_LRU_CAP`.
|
||||
*/
|
||||
private readonly deliveredIds = new Set<string>();
|
||||
private readonly deliveredOrder: string[] = [];
|
||||
|
||||
constructor(private readonly options: InboxOptions) {
|
||||
const clientOptions: ConstructorParameters<typeof InboxClient>[0] = {
|
||||
baseUrl: options.baseUrl,
|
||||
@@ -380,9 +405,52 @@ export class Inbox {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a blob delivered by a bridge transport (WS / SSE / long-poll
|
||||
* push) into the same dispatch + ack pipeline that `pollOnce` uses.
|
||||
*
|
||||
* Wire-up pattern:
|
||||
* ```ts
|
||||
* const bridge = new FallbackBridgeTransport([...]);
|
||||
* await bridge.connect({
|
||||
* onMessage: async (msg) => {
|
||||
* await inbox.acceptBridgeFrame({
|
||||
* msgId: msg.msgId!, // present on v4.8+ relays
|
||||
* ciphertext: msg.bytes,
|
||||
* receivedAt: msg.receivedAt,
|
||||
* expiresAt: msg.expiresAt ?? Date.now() + 7 * 24 * 3600 * 1000,
|
||||
* ...(msg.from !== undefined ? { from: msg.from } : {}),
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* The Inbox's bounded msgId LRU is shared between this path and
|
||||
* `pollOnce`, so whichever channel delivers first wins; the
|
||||
* other channel acks-and-skips when the same msgId comes back
|
||||
* around. Both paths also DELETE the blob from the relay on success
|
||||
* so subsequent polls don't see it either.
|
||||
*
|
||||
* Returns `true` if the blob was newly dispatched, `false` if it
|
||||
* was a duplicate or rejected by the handler (handler still gets a
|
||||
* chance to retry on the next poll if it threw).
|
||||
*/
|
||||
async acceptBridgeFrame(blob: FetchedBlob): Promise<boolean> {
|
||||
return this.handleBlob(blob);
|
||||
}
|
||||
|
||||
private async handleBlob(blob: FetchedBlob): Promise<boolean> {
|
||||
if (!this.incomingHandler) return false;
|
||||
|
||||
// Cross-channel msgId dedup. If the bridge already delivered this
|
||||
// blob, the inbox-poll copy must not re-dispatch (would re-trigger
|
||||
// X3DH / consume an OPK we no longer have). We still ack so the
|
||||
// relay drops the now-redundant copy.
|
||||
if (this.deliveredIds.has(blob.msgId)) {
|
||||
await this.ackQuietly(blob.msgId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Defense-in-depth: verify msgId ↔ ciphertext at the client too. A
|
||||
// server bug or malicious operator can't sneak a different blob past
|
||||
// the client's hash check.
|
||||
@@ -422,17 +490,34 @@ export class Inbox {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId });
|
||||
} catch (err) {
|
||||
// Decryption succeeded; ack just failed. Will be retried later, and
|
||||
// the duplicate-message ratchet check on `Shade.receive` will dedupe.
|
||||
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
|
||||
}
|
||||
// Mark before the ack so a slow-network ack doesn't leave a window
|
||||
// where a parallel pollOnce sees the same msgId and re-dispatches.
|
||||
this.recordDelivered(blob.msgId);
|
||||
await this.ackQuietly(blob.msgId);
|
||||
this.events.emit('inbox.message_received', {
|
||||
senderHint,
|
||||
msgId: blob.msgId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private async ackQuietly(msgId: string): Promise<void> {
|
||||
try {
|
||||
await this.client.ack({ address: this.options.ownAddress, msgId });
|
||||
} catch (err) {
|
||||
// Dispatch (or skip) succeeded; the ack just failed. Next poll
|
||||
// will see the blob again and the dedup gate above will skip it.
|
||||
console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private recordDelivered(msgId: string): void {
|
||||
if (this.deliveredIds.has(msgId)) return;
|
||||
this.deliveredIds.add(msgId);
|
||||
this.deliveredOrder.push(msgId);
|
||||
if (this.deliveredOrder.length > DEFAULT_DEDUP_LRU_CAP) {
|
||||
const evicted = this.deliveredOrder.shift()!;
|
||||
this.deliveredIds.delete(evicted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user