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

@@ -0,0 +1,165 @@
import { describe, test, expect, beforeEach } from 'bun:test';
import { SubtleCryptoProvider, MemoryStorage } from '@shade/crypto-web';
import { ShadeSessionManager } from '../src/index.js';
const crypto = new SubtleCryptoProvider();
/**
* Durable session-label aliases (V4.12).
*
* THE BUG THIS FILE EXISTS TO KILL — diagnosed live in Prism:
*
* A phone pairs with a host. First contact forces the host to label
* the session by the only sender hint the relay surfaces, an 8-byte
* signing-key fingerprint (`fp:<hex>`). The pair handshake then
* announces the phone's canonical address and the host calls
* `aliasSession(fp:<hex> → device:<addr>)`, which moved the session
* on disk and dropped the binding.
*
* The phone, however, keeps sending under `fp:<hex>` — its transport
* derives the same label from the same relay hint every time. While
* the host process lived, an in-memory map papered over the gap.
* After a restart that map was empty, every inbound ratchet frame
* resolved to `fp:<hex>`, found no session, and failed. The phone
* held a perfectly valid session so it never re-ran X3DH — meaning
* the failure was permanent and self-inflicted, not transient.
*
* Observed as `No session for address: fp:579c3b335d66e2c0` on every
* receive for three days, with the phone's RPCs timing out forever.
*
* The fix: `aliasSession` persists the binding, and session lookup
* follows it. These tests pin the restart behaviour specifically —
* a same-process test cannot fail the way production did.
*/
describe('session label aliases', () => {
let alice: ShadeSessionManager;
let bob: ShadeSessionManager;
let aliceStorage: MemoryStorage;
let bobStorage: MemoryStorage;
/** The first-contact label Alice is forced to use for Bob. */
const FP = 'fp:579c3b335d66e2c0';
beforeEach(async () => {
aliceStorage = new MemoryStorage();
bobStorage = new MemoryStorage();
alice = new ShadeSessionManager(crypto, aliceStorage);
bob = new ShadeSessionManager(crypto, bobStorage);
await alice.initialize();
await bob.initialize();
});
/**
* Bob initiates X3DH against Alice, exactly like a phone reaching a
* host it just scanned. Returns Bob's first (prekey) envelope.
*/
async function bobInitiates(target: ShadeSessionManager, initiator: ShadeSessionManager) {
const otpks = await target.generateOneTimePreKeys(10);
const bundle = await target.createPreKeyBundle();
const otpk = otpks[0]!;
bundle.oneTimePreKey = { keyId: otpk.keyId, publicKey: otpk.keyPair.publicKey };
await initiator.initSessionFromBundle('alice', bundle);
}
/** Simulate a host restart: fresh manager, same durable storage. */
async function restartAlice(): Promise<ShadeSessionManager> {
const revived = new ShadeSessionManager(crypto, aliceStorage);
await revived.initialize();
return revived;
}
test('an aliased session still decrypts under the old label after a restart', async () => {
await bobInitiates(alice, bob);
// First contact lands under the fingerprint label.
const env1 = await bob.encrypt('alice', 'hello, my address is bob');
expect(await alice.decrypt(FP, env1)).toBe('hello, my address is bob');
// Alice canonicalizes to Bob's announced address.
await alice.aliasSession(FP, 'bob');
// The host restarts. Storage survives; every in-memory map does not.
const alice2 = await restartAlice();
// Bob has a valid session and keeps sending under the same label he
// always has. Before the fix this threw NoSessionError forever.
const env2 = await bob.encrypt('alice', 'still here after restart');
expect(await alice2.decrypt(FP, env2)).toBe('still here after restart');
});
test('the host can reply under the old label after a restart', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
// Decrypting is only half of it — a host that cannot encrypt back
// leaves every RPC hanging just the same.
const reply = await alice2.encrypt(FP, 'reply from the host');
expect(await bob.decrypt('alice', reply)).toBe('reply from the host');
});
test('a live session under the label wins over an alias (re-link)', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'first pairing');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
// Bob reinstalls: brand-new identity, same relay fingerprint label.
const bob2Storage = new MemoryStorage();
const bob2 = new ShadeSessionManager(crypto, bob2Storage);
await bob2.initialize();
await bobInitiates(alice2, bob2);
// The prekey envelope must establish a FRESH session under FP rather
// than being redirected into the stale aliased one.
const fresh1 = await bob2.encrypt('alice', 'fresh contact');
expect(await alice2.decrypt(FP, fresh1)).toBe('fresh contact');
// And subsequent ratchet frames must keep using that new session.
const fresh2 = await bob2.encrypt('alice', 'second message');
expect(await alice2.decrypt(FP, fresh2)).toBe('second message');
});
test('resolveSessionLabel reports where the state actually lives', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
await alice.aliasSession(FP, 'bob');
const alice2 = await restartAlice();
expect(await alice2.resolveSessionLabel(FP)).toBe('bob');
// An unaliased label resolves to itself.
expect(await alice2.resolveSessionLabel('carol')).toBe('carol');
});
test('resetSession drops aliases pointing at the cleared session', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
await alice.aliasSession(FP, 'bob');
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
await alice.resetSession('bob');
// A dangling alias would redirect the next first-contact frame into
// a session that no longer exists, defeating the reset.
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
expect(await alice.resolveSessionLabel(FP)).toBe(FP);
});
test('aliasing persists the binding to storage', async () => {
await bobInitiates(alice, bob);
const env1 = await bob.encrypt('alice', 'hi');
await alice.decrypt(FP, env1);
expect(await aliceStorage.getSessionAlias(FP)).toBeNull();
await alice.aliasSession(FP, 'bob');
expect(await aliceStorage.getSessionAlias(FP)).toBe('bob');
});
});