fix(session): remember where aliasSession moved a session

aliasSession knew that two labels name the same peer, then threw that
knowledge away. The binding lived only in the caller's memory, so a
restart lost it — and the peer could not repair it from its side.

First contact forces the receiver to label a session by the only sender
hint a relay surfaces, an 8-byte signing-key fingerprint (`fp:<hex>`).
Once the peer announces its canonical address, aliasSession moves the
session there. But the peer keeps sending under `fp:<hex>`, because its
transport derives the same label from the same hint every time. After a
restart the session sat under the canonical address, inbound frames
resolved to `fp:<hex>`, and nothing matched. The peer held a valid
session so it never re-ran X3DH: the failure was permanent, and only a
manual re-link cleared it.

Observed in Prism as `No session for address: fp:579c3b335d66e2c0` on
every receive for three days, with a phone whose every RPC timed out.

StorageProvider gains saveSessionAlias / getSessionAlias /
removeSessionAliasesFor, optional so third-party implementations keep
compiling, and implemented across all seven backends. Lookups resolve
through resolveLabel(), which runs BEFORE the peer mutex — locking the
alias while mutating the canonical session would let an aliased and a
canonical caller ratchet the same state concurrently.

A live session under a label always wins over an alias, and prekey
envelopes never resolve: both keep a re-link establishing a fresh
session instead of being redirected into the stale one. Aliases are
dropped in resetSession and acceptIdentityChange, and memoized so the
hot path costs no extra read.

The sdk.test.ts case that asserted a dead fp-label encoded the old
behaviour; it now pins the new contract.

Verified: 1166 tests pass (from 1160). With alias persistence disabled
as a negative control, 5 of the 6 new tests fail, including both
restart cases.

Also drops `baseUrl` from the consumer-strict tsconfig — removed in
TS 6.0, and it was failing the typecheck that gates publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 19:36:31 +02:00
parent b44acf867b
commit 96c20cb4b2
39 changed files with 536 additions and 43 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/sdk",
"version": "4.11.1",
"version": "4.12.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -701,6 +701,22 @@ export class Shade {
this.decryptChains.delete(oldLabel);
}
/**
* Resolve a session label to the label its state actually lives under,
* following any alias left behind by `aliasSession`.
*
* Transports need this to route by the canonical address after a
* restart, when the only sender hint they hold is the first-contact
* `fp:<hex>` label. `encrypt`/`decrypt` resolve internally — this is
* for callers that must know the address itself.
*
* V4.12 — durable session-label aliases.
*/
async resolveSessionLabel(label: string): Promise<string> {
if (!this.initialized) throw new Error('Not initialized');
return this.manager.resolveSessionLabel(label);
}
/**
* Accept a peer's rotated identity. Bumps the per-peer identity-version
* counter so any earlier verification automatically goes stale, then

View File

@@ -162,10 +162,15 @@ describe('createShade — happy path', () => {
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();
// V4.12: the fp-label is no longer a dead end. `aliasSession` leaves
// a durable binding behind, so a peer that keeps sending under the
// first-contact label — which is exactly what a fingerprint-hinted
// relay makes it do — still reaches the canonicalized session.
// See `shade-core/tests/session-aliases.test.ts` for the restart
// behaviour this binding exists to protect.
expect(await alice.resolveSessionLabel(fpLabel)).toBe('bob');
const env4 = await bob.send('alice', 'reply 3');
expect(await alice.receive(fpLabel, env4)).toBe('reply 3');
});
test('aliasSession refuses to overwrite an existing session', async () => {