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

@@ -100,6 +100,10 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
});
members.createIndex('byChannelId', 'channelId');
}
if (oldVersion < 3) {
const aliases = db.createObjectStore('session_aliases_enc', { keyPath: 'alias' });
aliases.createIndex('byCanonical', 'canonical');
}
},
});
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
@@ -212,6 +216,27 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
await this.db.delete('sessions_enc', address);
}
// ─── Session label aliases ─────────────────────────────────
//
// Labels are already the clear-text keyPath of sessions_enc, so the
// mapping between two of them exposes nothing the store didn't hold.
async getSessionAlias(alias: string): Promise<string | null> {
const row = await this.db.get('session_aliases_enc', alias);
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.db.put('session_aliases_enc', { alias, canonical });
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
const tx = this.db.transaction('session_aliases_enc', 'readwrite');
const matches = await tx.store.index('byCanonical').getAllKeys(canonical);
await Promise.all(matches.map((key) => tx.store.delete(key)));
await tx.done;
}
// ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
@@ -466,7 +491,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
// ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
interface MetaRow { key: string; value: string }
interface IdentityRow { id: 1; ciphertext: Uint8Array }
@@ -522,6 +547,11 @@ interface EncryptedShadeSchema extends DBSchema {
signed_prekeys_enc: { key: number; value: SignedPreKeyRow };
one_time_prekeys_enc: { key: number; value: OneTimePreKeyRow };
sessions_enc: { key: string; value: SessionRow };
session_aliases_enc: {
key: string;
value: { alias: string; canonical: string };
indexes: { byCanonical: string };
};
trusted_identities_enc: { key: string; value: TrustedIdentityRow };
retired_identities_enc: {
key: number;

View File

@@ -183,6 +183,30 @@ export class EncryptedPostgresStorage implements StorageProvider {
await this.sql`DELETE FROM shade_sessions_enc WHERE address = ${address}`;
}
// ─── Session label aliases ─────────────────────────────────
//
// Labels are already stored in the clear as the sessions_enc primary
// key, so a mapping between two of them reveals nothing new.
async getSessionAlias(alias: string): Promise<string | null> {
const rows = await this.sql<Array<{ canonical: string }>>`
SELECT canonical FROM shade_session_aliases_enc WHERE alias = ${alias}
`;
return rows.length ? rows[0]!.canonical : null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
await this.sql`
INSERT INTO shade_session_aliases_enc (alias, canonical)
VALUES (${alias}, ${canonical})
ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical
`;
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
await this.sql`DELETE FROM shade_session_aliases_enc WHERE canonical = ${canonical}`;
}
// ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {
@@ -515,6 +539,16 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise<void> {
ciphertext BYTEA NOT NULL
)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_session_aliases_enc (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
)
`;
await sql`
CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_enc_canonical
ON shade_session_aliases_enc(canonical)
`;
await sql`
CREATE TABLE IF NOT EXISTS shade_trusted_identities_enc (
address TEXT PRIMARY KEY,

View File

@@ -52,6 +52,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
getSession: ReturnType<Database['prepare']>;
saveSession: ReturnType<Database['prepare']>;
removeSession: ReturnType<Database['prepare']>;
getSessionAlias: ReturnType<Database['prepare']>;
saveSessionAlias: ReturnType<Database['prepare']>;
removeAliasesFor: ReturnType<Database['prepare']>;
getTrust: ReturnType<Database['prepare']>;
saveTrust: ReturnType<Database['prepare']>;
addRetired: ReturnType<Database['prepare']>;
@@ -134,6 +137,15 @@ export class EncryptedSQLiteStorage implements StorageProvider {
address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL
);
-- Session-label aliases (V4.12). Labels are already stored in the
-- clear as the sessions_enc primary key, so the mapping between two
-- of them reveals nothing new; only session state is encrypted.
CREATE TABLE IF NOT EXISTS session_aliases_enc (
alias TEXT PRIMARY KEY,
canonical TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_session_aliases_enc_canonical
ON session_aliases_enc(canonical);
CREATE TABLE IF NOT EXISTS trusted_identities_enc (
address TEXT PRIMARY KEY,
ciphertext BLOB NOT NULL
@@ -203,6 +215,9 @@ export class EncryptedSQLiteStorage implements StorageProvider {
getSession: this.db.prepare('SELECT ciphertext FROM sessions_enc WHERE address = ?'),
saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions_enc (address, ciphertext) VALUES (?, ?)'),
removeSession: this.db.prepare('DELETE FROM sessions_enc WHERE address = ?'),
getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases_enc WHERE alias = ?'),
saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases_enc (alias, canonical) VALUES (?, ?)'),
removeAliasesFor: this.db.prepare('DELETE FROM session_aliases_enc WHERE canonical = ?'),
getTrust: this.db.prepare('SELECT ciphertext FROM trusted_identities_enc WHERE address = ?'),
saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities_enc (address, ciphertext) VALUES (?, ?)'),
addRetired: this.db.prepare('INSERT OR REPLACE INTO retired_identities_enc (retired_at, ciphertext) VALUES (?, ?)'),
@@ -377,6 +392,21 @@ export class EncryptedSQLiteStorage implements StorageProvider {
this.stmts.removeSession.run(address);
}
// ─── Session label aliases ─────────────────────────────────
async getSessionAlias(alias: string): Promise<string | null> {
const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined;
return row?.canonical ?? null;
}
async saveSessionAlias(alias: string, canonical: string): Promise<void> {
this.stmts.saveSessionAlias.run(alias, canonical);
}
async removeSessionAliasesFor(canonical: string): Promise<void> {
this.stmts.removeAliasesFor.run(canonical);
}
// ─── Trust ─────────────────────────────────────────────────
async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise<boolean> {