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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@shade/sdk",
|
||||
"version": "4.8.2",
|
||||
"version": "4.8.3",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -655,6 +655,41 @@ export class Shade {
|
||||
await this.gates.revoke(address);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every per-peer storage row for `oldLabel` (session, trusted
|
||||
* identity, peer-verification, identity-version counter) to
|
||||
* `newLabel`. Use this when first-contact forced you to label a
|
||||
* session by the relay's sender-fingerprint hint
|
||||
* (`fp:<hex>` — see `IncomingMessage.from` / `FetchedBlob.from`) and
|
||||
* the just-decrypted plaintext announces the peer's canonical
|
||||
* address: alias once and every subsequent
|
||||
* `send`/`receive`/broadcast cross-check operates under the
|
||||
* announced label, no app-side fp ↔ address mapping needed for the
|
||||
* receive path.
|
||||
*
|
||||
* The rename is atomic from a per-peer-mutex perspective — both
|
||||
* labels are locked for the duration so concurrent encrypt/decrypt
|
||||
* can't observe a half-moved state. Throws if `oldLabel` has no
|
||||
* session, or if `newLabel` already does (refuses to overwrite —
|
||||
* call `resetSession` first if that's intentional).
|
||||
*
|
||||
* After alias, the SDK's internal serialization queues
|
||||
* (`encryptChains`, `decryptChains`) for `oldLabel` are dropped so
|
||||
* future operations don't queue behind a stale chain.
|
||||
*
|
||||
* V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`.
|
||||
*/
|
||||
async aliasSession(oldLabel: string, newLabel: string): Promise<void> {
|
||||
if (!this.initialized) throw new Error('Not initialized');
|
||||
await this.manager.aliasSession(oldLabel, newLabel);
|
||||
// The SDK's per-`from` chains are keyed by label; drop the old
|
||||
// entries so future `send`/`receive` to either label start with a
|
||||
// fresh queue rather than chaining off whatever was last in flight
|
||||
// for `oldLabel`.
|
||||
this.encryptChains.delete(oldLabel);
|
||||
this.decryptChains.delete(oldLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a peer's rotated identity. Bumps the per-peer identity-version
|
||||
* counter so any earlier verification automatically goes stale, then
|
||||
|
||||
@@ -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