diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 9f94ffc..65cf5d4 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index af32a2d..8507a00 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-core/src/session.ts b/packages/shade-core/src/session.ts index 62649a5..90468d7 100644 --- a/packages/shade-core/src/session.ts +++ b/packages/shade-core/src/session.ts @@ -86,6 +86,14 @@ export class ShadeSessionManager { * fully concurrent. */ private readonly peerOpChains = new Map>(); + /** + * Memoized `alias → canonical` lookups; `null` records "no alias" so a + * label without one costs a single storage read for the life of the + * process instead of one per encrypt/decrypt. Aliases change only via + * the mutators in this class, each of which clears the whole map — it + * holds at most one entry per peer, so rebuilding is cheap. + */ + private readonly aliasCache = new Map(); constructor( private readonly crypto: CryptoProvider, @@ -151,6 +159,46 @@ export class ShadeSessionManager { } } + /** + * Map a session label onto the label its state actually lives under. + * + * `aliasSession` moves a session from a first-contact label (typically + * `fp:`) to the peer's canonical address, but the peer keeps + * sending under the old one. The persisted alias lets us follow that + * move across restarts — see the alias block in `StorageProvider`. + * + * A live session under `label` always wins: after a re-link the peer + * re-runs X3DH and a fresh session is established under the + * first-contact label again, and that new session — not the stale + * alias target — is the one that can decrypt what follows. + * + * Resolves one hop only. Aliases are always written pointing at a + * canonical label, so a chain would mean corrupt state; following it + * would risk a loop for no legitimate gain. + * + * MUST be called before taking the peer mutex: locking the alias while + * mutating the canonical session would let an aliased caller and a + * canonical caller ratchet the same state concurrently. + */ + private async resolveLabel(label: string): Promise { + let canonical = this.aliasCache.get(label); + if (canonical === undefined) { + canonical = (await this.storage.getSessionAlias?.(label)) ?? null; + this.aliasCache.set(label, canonical); + } + if (canonical === null || canonical === label) return label; + if (await this.storage.getSession(label)) return label; + return canonical; + } + + /** + * Public label resolution for callers that need to know where a + * peer's state lives (e.g. a transport routing an inbound frame). + */ + async resolveSessionLabel(label: string): Promise { + return this.resolveLabel(label); + } + /** Get the event emitter (if observability is enabled) */ getEvents(): ShadeEventEmitter | undefined { return this.events; @@ -268,6 +316,11 @@ export class ShadeSessionManager { */ async resetSession(address: string): Promise { await this.storage.removeSession(address); + // Aliases pointing here are now dangling — drop them so the next + // first-contact frame resolves to its own label and establishes the + // fresh session this reset exists to force. + await this.storage.removeSessionAliasesFor?.(address); + this.aliasCache.clear(); this.events?.emit('session.removed', { address }); // Note: we keep the trusted identity; new session will verify against it. } @@ -336,6 +389,14 @@ export class ShadeSessionManager { await this.storage.bumpPeerIdentityVersion(newLabel); } await this.storage.removeSession(oldLabel); + // Remember the move durably. The peer goes on sending under + // `oldLabel` — its transport derives the same first-contact label + // from our relay hint every time — so without this record every + // inbound frame after a restart resolves to a label whose session + // we just removed, and the peer (holding a valid session, never + // re-running X3DH) can never recover on its own. + await this.storage.saveSessionAlias?.(oldLabel, newLabel); + this.aliasCache.clear(); this.events?.emit('session.aliased', { oldLabel, newLabel }); } @@ -349,6 +410,10 @@ export class ShadeSessionManager { // because isTrustedIdentity() compares not retrieves; we just emit the new hash) await this.storage.saveTrustedIdentity(address, newIdentityKey); await this.storage.removeSession(address); + // The peer rotated identity — any alias into the old session is + // dangling and must not redirect frames meant for the new one. + await this.storage.removeSessionAliasesFor?.(address); + this.aliasCache.clear(); if (this.events) { const newHash = await shortHash(this.crypto, newIdentityKey); @@ -516,14 +581,19 @@ export class ShadeSessionManager { * Subsequent messages are standard RatchetMessages. */ async encrypt(address: string, plaintext: string): Promise { - return this.withSpan('encrypt', address, async () => { - const session = await this.storage.getSession(address); - if (!session) throw new NoSessionError(address); + // Follow a persisted alias so a caller still holding a first-contact + // label reaches the session that alias moved to — without this, a + // restarted host can decrypt a peer's frames but not reply to them. + // Resolved before the mutex so the lock lands on the canonical label. + const target = await this.resolveLabel(address); + return this.withSpan('encrypt', target, async () => { + const session = await this.storage.getSession(target); + if (!session) throw new NoSessionError(target); const ratchetMsg = await ratchetEncrypt(this.crypto, session, enc.encode(plaintext)); this.events?.emit('message.encrypted', { - address, + address: target, counter: ratchetMsg.counter, ciphertextSize: ratchetMsg.ciphertext.length, }); @@ -532,7 +602,7 @@ export class ShadeSessionManager { const x3dh = (session as any).__x3dh; if (x3dh) { delete (session as any).__x3dh; - await this.storage.saveSession(address, session); + await this.storage.saveSession(target, session); const preKeyMsg: PreKeyMessage = { registrationId: x3dh.registrationId, @@ -546,16 +616,16 @@ export class ShadeSessionManager { type: 'prekey', content: preKeyMsg, timestamp: Date.now(), - senderAddress: address, + senderAddress: target, }; } - await this.storage.saveSession(address, session); + await this.storage.saveSession(target, session); return { type: 'ratchet', content: ratchetMsg, timestamp: Date.now(), - senderAddress: address, + senderAddress: target, }; }); } @@ -564,11 +634,17 @@ export class ShadeSessionManager { * Decrypt a message from a peer. Handles both PreKeyMessage and RatchetMessage. */ async decrypt(address: string, envelope: ShadeEnvelope): Promise { - return this.withSpan('decrypt', address, async () => { + // A prekey envelope carries its own X3DH material and establishes a + // fresh session, which must land under the label it arrived on — + // that is exactly what a re-link looks like. Only ratchet envelopes, + // which need state that already exists, follow an alias. + const target = + envelope.type === 'prekey' ? address : await this.resolveLabel(address); + return this.withSpan('decrypt', target, async () => { if (envelope.type === 'prekey') { - return this.decryptPreKeyMessage(address, envelope.content as PreKeyMessage); + return this.decryptPreKeyMessage(target, envelope.content as PreKeyMessage); } - return this.decryptRatchetMessage(address, envelope.content as RatchetMessage); + return this.decryptRatchetMessage(target, envelope.content as RatchetMessage); }); } diff --git a/packages/shade-core/src/storage.ts b/packages/shade-core/src/storage.ts index bcd830b..98cc3b1 100644 --- a/packages/shade-core/src/storage.ts +++ b/packages/shade-core/src/storage.ts @@ -165,6 +165,36 @@ export interface StorageProvider { /** Remove session for a peer */ removeSession(address: string): Promise; + // ─── Session label aliases (V4.12) ──────────────────────── + // + // First contact forces the receiver to label a session by the only + // sender hint the relay surfaces — an 8-byte signing-key fingerprint + // (`fp:`). A later in-band announcement reveals the peer's + // canonical address and `aliasSession` moves the session there. + // + // The peer, however, keeps sending under whatever label its own + // transport derives — which for a fingerprint-hinted relay is still + // `fp:`. Before V4.12 that binding lived only in the consumer's + // memory: after a restart the alias was gone, inbound ratchet frames + // resolved to `fp:`, found no session there, and failed forever + // (the peer holds a valid session so it never re-runs X3DH). + // + // Persisting the alias makes the binding survive restarts, so + // `getSession` can follow it. Optional so third-party storage + // implementations keep compiling — they simply lose alias recovery. + + /** + * Record that `alias` names the same peer session as `canonical`. + * Idempotent upsert on `alias`. + */ + saveSessionAlias?(alias: string, canonical: string): Promise; + + /** Resolve an alias to its canonical label (null when unaliased). */ + getSessionAlias?(alias: string): Promise; + + /** Drop every alias pointing at `canonical` (session teardown). */ + removeSessionAliasesFor?(canonical: string): Promise; + /** Check if we trust a remote identity key (for TOFU or pinned keys) */ isTrustedIdentity(address: string, identityKey: Uint8Array): Promise; diff --git a/packages/shade-core/tests/session-aliases.test.ts b/packages/shade-core/tests/session-aliases.test.ts new file mode 100644 index 0000000..134eaa8 --- /dev/null +++ b/packages/shade-core/tests/session-aliases.test.ts @@ -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:`). The pair handshake then + * announces the phone's canonical address and the host calls + * `aliasSession(fp: → device:)`, which moved the session + * on disk and dropped the binding. + * + * The phone, however, keeps sending under `fp:` — 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:`, 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 { + 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'); + }); +}); diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index 36cfaa9..88a2dd0 100644 --- a/packages/shade-crypto-web/package.json +++ b/packages/shade-crypto-web/package.json @@ -1,6 +1,6 @@ { "name": "@shade/crypto-web", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/src/memory-storage.ts b/packages/shade-crypto-web/src/memory-storage.ts index 5daa311..3a5dd63 100644 --- a/packages/shade-crypto-web/src/memory-storage.ts +++ b/packages/shade-crypto-web/src/memory-storage.ts @@ -15,6 +15,8 @@ export class MemoryStorage implements StorageProvider { private signedPreKeys = new Map(); private oneTimePreKeys = new Map(); private sessions = new Map(); + /** alias → canonical session label (V4.12). */ + private sessionAliases = new Map(); private trustedIdentities = new Map(); private retiredIdentities: RetiredIdentity[] = []; @@ -82,6 +84,22 @@ export class MemoryStorage implements StorageProvider { this.sessions.delete(address); } + // ─── Session label aliases ──────────────────────────────── + + async getSessionAlias(alias: string): Promise { + return this.sessionAliases.get(alias) ?? null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + this.sessionAliases.set(alias, canonical); + } + + async removeSessionAliasesFor(canonical: string): Promise { + for (const [alias, target] of this.sessionAliases) { + if (target === canonical) this.sessionAliases.delete(alias); + } + } + // ─── Trust ──────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index 1ae2bca..7cac51d 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index ceee454..9b83e9d 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.11.2", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/package.json b/packages/shade-inbox-server/package.json index 86e15b0..f03d923 100644 --- a/packages/shade-inbox-server/package.json +++ b/packages/shade-inbox-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox-server", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index 0985146..15ac946 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index cd1605f..03d4a0b 100644 --- a/packages/shade-key-transparency/package.json +++ b/packages/shade-key-transparency/package.json @@ -1,6 +1,6 @@ { "name": "@shade/key-transparency", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-keychain/package.json b/packages/shade-keychain/package.json index dc20efb..299056e 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observability/package.json b/packages/shade-observability/package.json index a131151..f43b24b 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-observer/package.json b/packages/shade-observer/package.json index 7bbe454..bf6a136 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-proto/package.json b/packages/shade-proto/package.json index 823782f..0b498bd 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-recovery/package.json b/packages/shade-recovery/package.json index b13b963..4b46238 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-sdk/package.json b/packages/shade-sdk/package.json index 8127f8b..f18855d 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -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", diff --git a/packages/shade-sdk/src/shade.ts b/packages/shade-sdk/src/shade.ts index 1b6ffa1..bc5b9c8 100644 --- a/packages/shade-sdk/src/shade.ts +++ b/packages/shade-sdk/src/shade.ts @@ -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:` 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 { + 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 diff --git a/packages/shade-sdk/tests/sdk.test.ts b/packages/shade-sdk/tests/sdk.test.ts index ac5f993..db4e20e 100644 --- a/packages/shade-sdk/tests/sdk.test.ts +++ b/packages/shade-sdk/tests/sdk.test.ts @@ -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 () => { diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 474490f..4d31225 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/package.json b/packages/shade-storage-encrypted/package.json index 2456c17..7ca65a4 100644 --- a/packages/shade-storage-encrypted/package.json +++ b/packages/shade-storage-encrypted/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-encrypted", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts b/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts index 23c9956..62987da 100644 --- a/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts +++ b/packages/shade-storage-encrypted/src/storage/encrypted-indexeddb.ts @@ -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 { + const row = await this.db.get('session_aliases_enc', alias); + return row?.canonical ?? null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + await this.db.put('session_aliases_enc', { alias, canonical }); + } + + async removeSessionAliasesFor(canonical: string): Promise { + 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 { @@ -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; diff --git a/packages/shade-storage-encrypted/src/storage/encrypted-postgres.ts b/packages/shade-storage-encrypted/src/storage/encrypted-postgres.ts index 8ba3d7b..6701d5e 100644 --- a/packages/shade-storage-encrypted/src/storage/encrypted-postgres.ts +++ b/packages/shade-storage-encrypted/src/storage/encrypted-postgres.ts @@ -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 { + const rows = await this.sql>` + SELECT canonical FROM shade_session_aliases_enc WHERE alias = ${alias} + `; + return rows.length ? rows[0]!.canonical : null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + 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 { + await this.sql`DELETE FROM shade_session_aliases_enc WHERE canonical = ${canonical}`; + } + // ─── Trust ───────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { @@ -515,6 +539,16 @@ export async function ensureEncryptedClientTables(sql: Sql): Promise { 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, diff --git a/packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts b/packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts index 1976044..69fc51e 100644 --- a/packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts +++ b/packages/shade-storage-encrypted/src/storage/encrypted-sqlite.ts @@ -52,6 +52,9 @@ export class EncryptedSQLiteStorage implements StorageProvider { getSession: ReturnType; saveSession: ReturnType; removeSession: ReturnType; + getSessionAlias: ReturnType; + saveSessionAlias: ReturnType; + removeAliasesFor: ReturnType; getTrust: ReturnType; saveTrust: ReturnType; addRetired: ReturnType; @@ -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 { + const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined; + return row?.canonical ?? null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + this.stmts.saveSessionAlias.run(alias, canonical); + } + + async removeSessionAliasesFor(canonical: string): Promise { + this.stmts.removeAliasesFor.run(canonical); + } + // ─── Trust ───────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 97fe1f7..4fb8286 100644 --- a/packages/shade-storage-indexeddb/package.json +++ b/packages/shade-storage-indexeddb/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-indexeddb", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/src/indexeddb-storage.ts b/packages/shade-storage-indexeddb/src/indexeddb-storage.ts index b2efd4d..02c0bf9 100644 --- a/packages/shade-storage-indexeddb/src/indexeddb-storage.ts +++ b/packages/shade-storage-indexeddb/src/indexeddb-storage.ts @@ -74,6 +74,10 @@ export class IndexedDBStorage implements StorageProvider { const members = db.createObjectStore('broadcastMembers', { keyPath: ['channelId', 'peerAddress'] }); members.createIndex('byChannelId', 'channelId'); } + if (oldVersion < 3) { + const aliases = db.createObjectStore('sessionAliases', { keyPath: 'alias' }); + aliases.createIndex('byCanonical', 'canonical'); + } }, }); return new IndexedDBStorage(db); @@ -174,6 +178,24 @@ export class IndexedDBStorage implements StorageProvider { await this.db.delete('sessions', address); } + // ─── Session label aliases ──────────────────────────────── + + async getSessionAlias(alias: string): Promise { + const row = await this.db.get('sessionAliases', alias); + return row?.canonical ?? null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + await this.db.put('sessionAliases', { alias, canonical }); + } + + async removeSessionAliasesFor(canonical: string): Promise { + const tx = this.db.transaction('sessionAliases', '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 { @@ -360,7 +382,7 @@ export class IndexedDBStorage implements StorageProvider { // ─── Schema ──────────────────────────────────────────────── -const SCHEMA_VERSION = 2; +const SCHEMA_VERSION = 3; interface IdentityRow { id: 1; @@ -390,6 +412,11 @@ interface SessionRow { stateJson: string; } +interface SessionAliasRow { + alias: string; + canonical: string; +} + interface TrustedIdentityRow { address: string; identityKey: string; @@ -457,6 +484,11 @@ interface ShadeSchema extends DBSchema { signedPreKeys: { key: number; value: SignedPreKeyRow }; oneTimePreKeys: { key: number; value: OneTimePreKeyRow }; sessions: { key: string; value: SessionRow }; + sessionAliases: { + key: string; + value: SessionAliasRow; + indexes: { byCanonical: string }; + }; trustedIdentities: { key: string; value: TrustedIdentityRow }; retiredIdentities: { key: number; diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 9a878a0..03a8263 100644 --- a/packages/shade-storage-postgres/package.json +++ b/packages/shade-storage-postgres/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-postgres", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/src/ensure-tables.ts b/packages/shade-storage-postgres/src/ensure-tables.ts index 5d35e4a..47e2e8c 100644 --- a/packages/shade-storage-postgres/src/ensure-tables.ts +++ b/packages/shade-storage-postgres/src/ensure-tables.ts @@ -43,6 +43,16 @@ export async function ensureClientTables(sql: Sql): Promise { state_json TEXT NOT NULL ) `; + await sql` + CREATE TABLE IF NOT EXISTS shade_session_aliases ( + alias TEXT PRIMARY KEY, + canonical TEXT NOT NULL + ) + `; + await sql` + CREATE INDEX IF NOT EXISTS idx_shade_session_aliases_canonical + ON shade_session_aliases(canonical) + `; await sql` CREATE TABLE IF NOT EXISTS shade_trusted_identities ( address TEXT PRIMARY KEY, diff --git a/packages/shade-storage-postgres/src/postgres-storage.ts b/packages/shade-storage-postgres/src/postgres-storage.ts index e90478f..46cbce0 100644 --- a/packages/shade-storage-postgres/src/postgres-storage.ts +++ b/packages/shade-storage-postgres/src/postgres-storage.ts @@ -160,6 +160,27 @@ export class PostgresStorage implements StorageProvider { await this.sql`DELETE FROM shade_sessions WHERE address = ${address}`; } + // ─── Session label aliases ──────────────────────────────── + + async getSessionAlias(alias: string): Promise { + const rows = await this.sql>` + SELECT canonical FROM shade_session_aliases WHERE alias = ${alias} + `; + return rows.length ? rows[0]!.canonical : null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + await this.sql` + INSERT INTO shade_session_aliases (alias, canonical) + VALUES (${alias}, ${canonical}) + ON CONFLICT (alias) DO UPDATE SET canonical = EXCLUDED.canonical + `; + } + + async removeSessionAliasesFor(canonical: string): Promise { + await this.sql`DELETE FROM shade_session_aliases WHERE canonical = ${canonical}`; + } + // ─── Trust ──────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 8a1e52f..4dd8f6b 100644 --- a/packages/shade-storage-sqlite/package.json +++ b/packages/shade-storage-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@shade/storage-sqlite", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/src/sqlite-storage.ts b/packages/shade-storage-sqlite/src/sqlite-storage.ts index 0f0d801..8dfd35e 100644 --- a/packages/shade-storage-sqlite/src/sqlite-storage.ts +++ b/packages/shade-storage-sqlite/src/sqlite-storage.ts @@ -41,6 +41,9 @@ export class SQLiteStorage implements StorageProvider { getSession: ReturnType; saveSession: ReturnType; removeSession: ReturnType; + getSessionAlias: ReturnType; + saveSessionAlias: ReturnType; + removeAliasesFor: ReturnType; getTrust: ReturnType; saveTrust: ReturnType; addRetired: ReturnType; @@ -100,6 +103,12 @@ export class SQLiteStorage implements StorageProvider { address TEXT PRIMARY KEY, state_json TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS session_aliases ( + alias TEXT PRIMARY KEY, + canonical TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_session_aliases_canonical + ON session_aliases(canonical); CREATE TABLE IF NOT EXISTS trusted_identities ( address TEXT PRIMARY KEY, identity_key TEXT NOT NULL @@ -179,6 +188,9 @@ export class SQLiteStorage implements StorageProvider { getSession: this.db.prepare('SELECT state_json FROM sessions WHERE address = ?'), saveSession: this.db.prepare('INSERT OR REPLACE INTO sessions (address, state_json) VALUES (?, ?)'), removeSession: this.db.prepare('DELETE FROM sessions WHERE address = ?'), + getSessionAlias: this.db.prepare('SELECT canonical FROM session_aliases WHERE alias = ?'), + saveSessionAlias: this.db.prepare('INSERT OR REPLACE INTO session_aliases (alias, canonical) VALUES (?, ?)'), + removeAliasesFor: this.db.prepare('DELETE FROM session_aliases WHERE canonical = ?'), getTrust: this.db.prepare('SELECT identity_key FROM trusted_identities WHERE address = ?'), saveTrust: this.db.prepare('INSERT OR REPLACE INTO trusted_identities (address, identity_key) VALUES (?, ?)'), addRetired: this.db.prepare('INSERT INTO retired_identities (data_json, retired_at) VALUES (?, ?)'), @@ -337,6 +349,21 @@ export class SQLiteStorage implements StorageProvider { this.stmts.removeSession.run(address); } + // ─── Session label aliases ──────────────────────────────── + + async getSessionAlias(alias: string): Promise { + const row = this.stmts.getSessionAlias.get(alias) as { canonical: string } | undefined; + return row?.canonical ?? null; + } + + async saveSessionAlias(alias: string, canonical: string): Promise { + this.stmts.saveSessionAlias.run(alias, canonical); + } + + async removeSessionAliasesFor(canonical: string): Promise { + this.stmts.removeAliasesFor.run(canonical); + } + // ─── Trust ──────────────────────────────────────────────── async isTrustedIdentity(address: string, identityKey: Uint8Array): Promise { diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index 4d1e66e..df603ba 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transfer/package.json b/packages/shade-transfer/package.json index 98fc95a..212ff02 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/package.json b/packages/shade-transport-bridge/package.json index b6411b9..2b66722 100644 --- a/packages/shade-transport-bridge/package.json +++ b/packages/shade-transport-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-bridge", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index 73f9c4d..58700b8 100644 --- a/packages/shade-transport-webrtc/package.json +++ b/packages/shade-transport-webrtc/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport-webrtc", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport/package.json b/packages/shade-transport/package.json index ea81100..8afac65 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-widgets/package.json b/packages/shade-widgets/package.json index 2f68617..5d40ee2 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.11.1", + "version": "4.12.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/tests/consumer-strict/tsconfig.json b/tests/consumer-strict/tsconfig.json index 9f407e5..8936101 100644 --- a/tests/consumer-strict/tsconfig.json +++ b/tests/consumer-strict/tsconfig.json @@ -14,7 +14,6 @@ "noEmit": true, "types": ["bun-types"], "ignoreDeprecations": "6.0", - "baseUrl": ".", "paths": { "@shade/core": ["../../packages/shade-core/src/index.ts"], "@shade/proto": ["../../packages/shade-proto/src/index.ts"],