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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user