release(v4.8.3): cross-channel msgId dedup + Shade.aliasSession
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled

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:
2026-05-08 15:49:36 +02:00
parent 8c606ad498
commit d47774ef1c
33 changed files with 519 additions and 32 deletions

View File

@@ -131,6 +131,75 @@ describe('createShade — happy path', () => {
await expect(alice.send('nobody', 'ghost')).rejects.toThrow();
});
test('aliasSession migrates a session from fp:<hex> to a canonical address label (V4.8.3)', async () => {
// Reproduces the Prism FR `session-label-asymmetry-v4.8.2`. Bob
// initiates X3DH against Alice using Alice's prekey-server
// address. Alice receives the prekey envelope under the relay's
// sender-fingerprint hint (`fp:<bobfp>`), because that's the only
// sender label the bridge surfaces at first contact. The
// post-decrypt plaintext announces Bob's real address; Alice then
// canonicalizes the session by aliasing `fp:<bobfp>` → `bob` and
// every subsequent send/receive operates symmetrically.
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
// First contact — Bob sends, Alice receives under the fp-label.
const env1 = await bob.send('alice', 'hello, my address is bob');
const fpLabel = 'fp:bobfingerprint16';
expect(await alice.receive(fpLabel, env1)).toBe('hello, my address is bob');
// Alice canonicalizes: move the session from the fp-label to bob's
// real address.
await alice.aliasSession(fpLabel, 'bob');
// Subsequent ratchet messages flow under the canonical label both
// directions. Bob's session for Alice is keyed under `alice`
// (Bob's send target); Alice's session for Bob is now keyed under
// `bob` (post-alias). Symmetry restored.
const env2 = await bob.send('alice', 'reply 1');
expect(await alice.receive('bob', env2)).toBe('reply 1');
const env3 = await alice.send('bob', 'reply 2');
expect(await bob.receive('alice', env3)).toBe('reply 2');
// The old fp-label has no session — receive under it would now
// fail. (We don't assert the error shape, only that the label is
// gone.)
await expect(alice.receive(fpLabel, env3)).rejects.toThrow();
});
test('aliasSession refuses to overwrite an existing session', async () => {
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
const carol = await createShade({ prekeyServer: server.url, address: 'carol' });
try {
// Two distinct first-contact prekey envelopes — one from Bob,
// one from Carol — let Alice end up with two real sessions in
// storage at two different labels.
const env1 = await bob.send('alice', 'one');
await alice.receive('fp:bobfp', env1);
const env2 = await carol.send('alice', 'two');
await alice.receive('fp:carolfp', env2);
await expect(alice.aliasSession('fp:carolfp', 'fp:bobfp')).rejects.toThrow(
/refusing to overwrite/i,
);
} finally {
await carol.shutdown();
}
});
test('aliasSession is a no-op when oldLabel === newLabel', async () => {
alice = await createShade({ prekeyServer: server.url, address: 'alice' });
bob = await createShade({ prekeyServer: server.url, address: 'bob' });
const env = await bob.send('alice', 'hi');
await alice.receive('fp:bobfp', env);
// Same-label alias is a no-op; session must still decrypt the next message.
await alice.aliasSession('fp:bobfp', 'fp:bobfp');
const env2 = await bob.send('alice', 'hi again');
expect(await alice.receive('fp:bobfp', env2)).toBe('hi again');
});
test('concurrent receive(from, env) for same `from` does not race the ratchet (V4.8.2)', async () => {
// Reproduces the Prism FR scenario: a single PUT is fanned out
// multiple times by the relay (or any duplicating transport), the