diff --git a/CHANGELOG.md b/CHANGELOG.md index fa5f117..a7440b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,112 @@ All notable changes to Shade are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.8.3] — 2026-05-08 — Cross-channel msgId dedup + `Shade.aliasSession` + +Two follow-ups to the V4.8.2 duplicate-fan-out fixes Prism filed +against. V4.8.2 closed the same-channel duplicate (8× via WS bridge +became 1×); V4.8.3 closes the cross-channel duplicate (bridge push + +inbox-poll catching up still delivered the same `msgId` twice) and +adds the missing `Shade.aliasSession` primitive that lets receivers +canonicalize their first-contact `fp:` label to the peer's +real address once the plaintext announces it. + +**(1) `Inbox.acceptBridgeFrame(blob)` + shared msgId LRU.** The +relay's `Inbox.send` durably stores the blob and pushes it to every +active delivery channel. Without a client-side cross-channel ack, a +recipient running both a bridge and an inbox-poll cycle processed the +same blob twice — the bridge frame ran first, the +30 s-cadence inbox-poll fetched it again, and the duplicate dispatch +tripped on already-consumed one-time prekeys +(`one-time prekey not found: `) or surfaced as duplicate +`shade.receive` work even when the canonical first delivery had +succeeded. The new `Inbox.acceptBridgeFrame(blob)` plumbs bridge +deliveries through the same dispatch + ack pipeline that `pollOnce` +uses; both paths share a 4096-entry msgId LRU so whichever channel +delivers first wins, and the other channel acks-and-skips when the +same `msgId` comes back around. The relay drops the blob on either +ack so subsequent polls don't see it. + +**(2) `Shade.aliasSession(oldLabel, newLabel)`.** First-contact +forces the receiver to label the new session by the relay's +sender-fingerprint hint (`fp:` — the only sender label +visible at receive-time per the V4.8 sender-attribution feature), +because the receiver doesn't yet know the sender's prekey-server +address. The post-decrypt plaintext typically *announces* the +sender's address; without an SDK primitive to canonicalize, every +subsequent `send`/`receive` would either fail +(`Failed to decrypt message — wrong key or tampered data`) or +require app-level fp ↔ address translation around every call. +`aliasSession` moves the per-peer storage rows (session, trusted +identity, peer-verification record, identity-version counter) under +the new label, holding the per-peer mutex on **both** labels for the +duration so concurrent encrypt/decrypt can't observe a half-moved +state. The send/receive `encryptChains` + `decryptChains` queues for +the old label are also dropped so future operations start fresh. +Refuses to overwrite an existing session under the new label +(call `resetSession(newLabel)` first if that's intentional). + +Both reported by Prism (multi-device E2EE terminal). Wave-3 pair +handshake's web-side `paired` reply now decrypts cleanly, +`BroadcastChannel.addMember` accepts the sender-address-vs-bilateral +cross-check, and steady-state heartbeats / terminal traffic don't +log a duplicate "OPK not found" per envelope. + +### Added + +#### `@shade/inbox` +- `Inbox.acceptBridgeFrame(blob: FetchedBlob): Promise` — + feed a bridge-pushed envelope through the same dispatch + ack + + dedup pipeline as `pollOnce`. Returns `true` when newly dispatched, + `false` for a duplicate or a handler-rejected blob. Wire-up + pattern documented inline. +- A 4096-entry FIFO msgId LRU (`deliveredIds` + `deliveredOrder`) is + shared between `acceptBridgeFrame` and `pollOnce` so cross-channel + duplicates are skipped (and acked) without re-running + `incomingHandler`. +- `Inbox.handleBlob` now records every successfully-dispatched + msgId before issuing the ack, eliminating the + ack-in-flight window where a parallel `pollOnce` could see the + blob and re-dispatch. + +#### `@shade/transport-bridge` +- `IncomingMessage.expiresAt?: number` — relay-assigned absolute + expiry, surfaced from the wire envelope so receivers can pass it + straight to `Inbox.acceptBridgeFrame` without inventing a TTL. + `decodeWireMessage` populates it when the wire message includes + one (V4.8.3 relay onward). + +#### `@shade/sdk` +- `Shade.aliasSession(oldLabel: string, newLabel: string): Promise` — + rename a session and its companion per-peer rows. Throws on + no-such-session-for-oldLabel and refuses-to-overwrite-newLabel. + +#### `@shade/core` +- `ShadeSessionManager.aliasSession(oldLabel, newLabel)` — the + primitive backing `Shade.aliasSession`. Holds the per-peer mutex + on both labels (acquired in lexicographic order) so the rename is + atomic w.r.t. concurrent crypto ops. +- `ShadeEventMap['session.aliased']: { oldLabel, newLabel }` — emitted + on a successful rename. Surfaced for observability dashboards. + +### Tests +- `packages/shade-inbox/tests/client.test.ts` — two new cases: + bridge-then-poll and poll-then-bridge, both asserting exactly one + `incomingHandler` dispatch per `inbox.send`. +- `packages/shade-sdk/tests/sdk.test.ts` — new `aliasSession` + cases: happy-path canonicalization (Bob initiates as `alice`, Alice + receives under `fp:bobfp`, aliases to `bob`, subsequent ratchet + exchange in both directions decrypts cleanly), refuses-to-overwrite, + same-label no-op. + +### Migration + +None. `acceptBridgeFrame` and `aliasSession` are additive. Existing +bridge consumers that don't call `acceptBridgeFrame` keep working as +before — they just don't get cross-channel dedup, and the same +duplicate-on-poll behavior persists. `aliasSession` callers are +opt-in. + ## [4.8.2] — 2026-05-08 — Per-`from` decrypt serialization + per-connection bridge dedup Two interlocking robustness fixes for the first-contact / duplicate-fan-out diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 2d3aa1e..5fa57ad 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.8.2", + "version": "4.8.3", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index 05e4276..66fd812 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-core/src/events.ts b/packages/shade-core/src/events.ts index 388753a..ea2b886 100644 --- a/packages/shade-core/src/events.ts +++ b/packages/shade-core/src/events.ts @@ -26,6 +26,8 @@ export interface ShadeEventMap { 'identity.rotated': { newFingerprint: string }; 'session.created': { address: string; remoteIdentityKeyHash: string }; 'session.removed': { address: string }; + /** V4.8.3 — emitted when `aliasSession` moves a peer's per-peer state. */ + 'session.aliased': { oldLabel: string; newLabel: string }; 'message.encrypted': { address: string; counter: number; ciphertextSize: number }; 'message.decrypted': { address: string; counter: number; plaintextSize: number }; 'ratchet.dh_step': { address: string }; diff --git a/packages/shade-core/src/session.ts b/packages/shade-core/src/session.ts index f11d6f1..1d481ae 100644 --- a/packages/shade-core/src/session.ts +++ b/packages/shade-core/src/session.ts @@ -265,6 +265,73 @@ export class ShadeSessionManager { // Note: we keep the trusted identity; new session will verify against it. } + /** + * Move every per-peer storage row for `oldLabel` (session, trusted + * identity, peer-verification, identity-version counter) to + * `newLabel`. Used to canonicalize sessions when first-contact + * forces the receiver to label by sender-fingerprint hint + * (`fp:`) and a later in-band announcement reveals the peer's + * canonical address. + * + * Holds the per-peer mutex on **both** labels for the whole + * migration so concurrent encrypt/decrypt for either label can't + * observe a half-moved state. Locks are taken in lexicographic + * order to avoid deadlocks if two callers alias in opposite + * directions. + * + * Throws if no session exists for `oldLabel`. Throws (refuses to + * overwrite) if a session already exists for `newLabel`. No-ops + * when `oldLabel === newLabel`. + * + * V4.8.3 — Prism FR `session-label-asymmetry-v4.8.2.md`. + */ + async aliasSession(oldLabel: string, newLabel: string): Promise { + if (oldLabel === newLabel) return; + const [first, second] = oldLabel < newLabel ? [oldLabel, newLabel] : [newLabel, oldLabel]; + await this.runUnderPeerLock(first, () => + this.runUnderPeerLock(second, () => this.aliasUnderLock(oldLabel, newLabel)), + ); + } + + private async aliasUnderLock(oldLabel: string, newLabel: string): Promise { + const session = await this.storage.getSession(oldLabel); + if (!session) throw new NoSessionError(oldLabel); + const collision = await this.storage.getSession(newLabel); + if (collision) { + throw new Error( + `aliasSession: refusing to overwrite an existing session for "${newLabel}". ` + + `If you want to replace it, call resetSession("${newLabel}") first.`, + ); + } + // Move the session. + await this.storage.saveSession(newLabel, session); + // Re-pin trust under the new label using the session's stored DH + // identity key — `saveTrustedIdentity` is the same primitive that + // the X3DH initiator/responder uses, and the DH key in `session` + // is the value that was pinned at session-establish time. The old + // pin under `oldLabel` is harmless leftover (the storage interface + // has no remove for trust pins) and would only be re-checked if a + // fresh X3DH against `oldLabel` somehow happened later. + await this.storage.saveTrustedIdentity(newLabel, session.remoteIdentityKey); + // Migrate the peer-verification record if present. + const verification = await this.storage.getPeerVerification(oldLabel); + if (verification) { + await this.storage.savePeerVerification({ + ...verification, + peerAddress: newLabel, + }); + await this.storage.removePeerVerification(oldLabel); + } + // Carry the identity-version counter forward so peer rotation + // history is preserved. + const oldVersion = await this.storage.getPeerIdentityVersion(oldLabel); + for (let i = 1; i < oldVersion; i++) { + await this.storage.bumpPeerIdentityVersion(newLabel); + } + await this.storage.removeSession(oldLabel); + this.events?.emit('session.aliased', { oldLabel, newLabel }); + } + /** * Accept a changed remote identity. This should only be called after * verifying the new identity out-of-band (e.g., comparing fingerprints). diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index f442a42..72a1e80 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-dashboard/package.json b/packages/shade-dashboard/package.json index 17c7d94..89410dc 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.8.2", + "version": "4.8.3", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index dbf0796..a906924 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.8.2", + "version": "4.8.3", "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 180f43e..2d77b70 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.8.2", + "version": "4.8.3", "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 83a6d42..e3f973a 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/src/inbox.ts b/packages/shade-inbox/src/inbox.ts index 0f7fadd..7090d47 100644 --- a/packages/shade-inbox/src/inbox.ts +++ b/packages/shade-inbox/src/inbox.ts @@ -63,6 +63,14 @@ export interface InboxOptions { const DEFAULT_TTL_SECONDS = 7 * 24 * 60 * 60; const DEFAULT_POLL_INTERVAL_MS = 30_000; const DEFAULT_MAX_ATTEMPTS = 10; +/** + * Cap for the cross-channel msgId dedup LRU. Each entry is a 64-char hex + * string; 4096 entries ≈ 256 KiB of overhead, plenty of headroom for + * bursty traffic (the LRU only needs to span the window between a bridge + * push and the next inbox-poll catching up — typically 30 s × the + * recipient's throughput). + */ +const DEFAULT_DEDUP_LRU_CAP = 4096; /** * High-level inbox orchestrator. @@ -105,6 +113,23 @@ export class Inbox { private started = false; private registered = false; + /** + * Bounded msgId dedup window. Used by both the inbox-poll path + * (`pollOnce` → `handleBlob`) and the bridge-push path + * (`acceptBridgeFrame`). The relay stores blobs durably and pushes + * them to every active delivery channel; without a shared dedup gate + * here the recipient processes the same envelope twice — once from + * the bridge, again from the next inbox-poll. The duplicate receive + * trips on consumed one-time prekeys ("OPK not found") and pollutes + * logs even when the canonical first delivery succeeded. See V4.8.3 + * Prism FR `cross-channel-duplicate-fanout-v4.8.2.md`. + * + * Insertion order is FIFO; the oldest msgId is evicted once the LRU + * exceeds `DEFAULT_DEDUP_LRU_CAP`. + */ + private readonly deliveredIds = new Set(); + private readonly deliveredOrder: string[] = []; + constructor(private readonly options: InboxOptions) { const clientOptions: ConstructorParameters[0] = { baseUrl: options.baseUrl, @@ -380,9 +405,52 @@ export class Inbox { return total; } + /** + * Feed a blob delivered by a bridge transport (WS / SSE / long-poll + * push) into the same dispatch + ack pipeline that `pollOnce` uses. + * + * Wire-up pattern: + * ```ts + * const bridge = new FallbackBridgeTransport([...]); + * await bridge.connect({ + * onMessage: async (msg) => { + * await inbox.acceptBridgeFrame({ + * msgId: msg.msgId!, // present on v4.8+ relays + * ciphertext: msg.bytes, + * receivedAt: msg.receivedAt, + * expiresAt: msg.expiresAt ?? Date.now() + 7 * 24 * 3600 * 1000, + * ...(msg.from !== undefined ? { from: msg.from } : {}), + * }); + * }, + * }); + * ``` + * + * The Inbox's bounded msgId LRU is shared between this path and + * `pollOnce`, so whichever channel delivers first wins; the + * other channel acks-and-skips when the same msgId comes back + * around. Both paths also DELETE the blob from the relay on success + * so subsequent polls don't see it either. + * + * Returns `true` if the blob was newly dispatched, `false` if it + * was a duplicate or rejected by the handler (handler still gets a + * chance to retry on the next poll if it threw). + */ + async acceptBridgeFrame(blob: FetchedBlob): Promise { + return this.handleBlob(blob); + } + private async handleBlob(blob: FetchedBlob): Promise { if (!this.incomingHandler) return false; + // Cross-channel msgId dedup. If the bridge already delivered this + // blob, the inbox-poll copy must not re-dispatch (would re-trigger + // X3DH / consume an OPK we no longer have). We still ack so the + // relay drops the now-redundant copy. + if (this.deliveredIds.has(blob.msgId)) { + await this.ackQuietly(blob.msgId); + return false; + } + // Defense-in-depth: verify msgId ↔ ciphertext at the client too. A // server bug or malicious operator can't sneak a different blob past // the client's hash check. @@ -422,17 +490,34 @@ export class Inbox { return false; } - try { - await this.client.ack({ address: this.options.ownAddress, msgId: blob.msgId }); - } catch (err) { - // Decryption succeeded; ack just failed. Will be retried later, and - // the duplicate-message ratchet check on `Shade.receive` will dedupe. - console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message); - } + // Mark before the ack so a slow-network ack doesn't leave a window + // where a parallel pollOnce sees the same msgId and re-dispatches. + this.recordDelivered(blob.msgId); + await this.ackQuietly(blob.msgId); this.events.emit('inbox.message_received', { senderHint, msgId: blob.msgId, }); return true; } + + private async ackQuietly(msgId: string): Promise { + try { + await this.client.ack({ address: this.options.ownAddress, msgId }); + } catch (err) { + // Dispatch (or skip) succeeded; the ack just failed. Next poll + // will see the blob again and the dedup gate above will skip it. + console.warn('[Shade] Inbox ack failed (will retry):', (err as Error).message); + } + } + + private recordDelivered(msgId: string): void { + if (this.deliveredIds.has(msgId)) return; + this.deliveredIds.add(msgId); + this.deliveredOrder.push(msgId); + if (this.deliveredOrder.length > DEFAULT_DEDUP_LRU_CAP) { + const evicted = this.deliveredOrder.shift()!; + this.deliveredIds.delete(evicted); + } + } } diff --git a/packages/shade-inbox/tests/client.test.ts b/packages/shade-inbox/tests/client.test.ts index d86e3cb..6728fd0 100644 --- a/packages/shade-inbox/tests/client.test.ts +++ b/packages/shade-inbox/tests/client.test.ts @@ -170,6 +170,120 @@ describe('Inbox orchestrator', () => { expect(seen[1]!.to).toBe('carol'); }); + test('cross-channel dedup: acceptBridgeFrame + pollOnce never re-dispatch the same msgId (V4.8.3)', async () => { + // Reproduces the Prism FR `cross-channel-duplicate-fanout-v4.8.2`: + // a single relay PUT was being delivered twice — once via WS bridge + // push, again ~30 s later via inbox-poll catching up. Both copies + // would dispatch `shade.receive`, the second one tripping on + // already-consumed prekeys. The cross-channel msgId LRU inside + // Inbox is the dedup gate; this test exercises it directly via + // `acceptBridgeFrame` followed by `pollOnce`. + const store = new MemoryInboxStore(); + const app = createInboxServer({ crypto, store, disableRateLimit: true }); + const bob = await makeIdentity(); + const alice = await makeIdentity(); + + const bobInbox = new Inbox({ + baseUrl: 'http://localhost', + ownAddress: 'bob', + crypto, + signingPrivateKey: bob.signingPrivateKey, + signingPublicKey: bob.signingPublicKey, + pollIntervalMs: 0, + fetch: honoFetch(app), + }); + await bobInbox.register(); + + // Alice PUTs a blob via the relay HTTP API. + const ct = randBytes(64); + const msgId = await computeMsgId(ct); + const aliceClient = new InboxClient({ + baseUrl: 'http://localhost', + crypto, + signingPrivateKey: alice.signingPrivateKey, + fetch: honoFetch(app), + }); + const putResult = await aliceClient.put({ + recipientAddress: 'bob', + senderSigningKey: alice.signingPublicKey, + envelope: ct, + }); + expect(putResult.idempotent).toBe(false); + + const dispatched: string[] = []; + bobInbox.onIncoming(async (raw) => { + dispatched.push(raw.msgId); + return null; + }); + + // Simulate the bridge push arriving first. + await bobInbox.acceptBridgeFrame({ + msgId, + ciphertext: ct, + receivedAt: putResult.receivedAt, + expiresAt: Date.now() + 60_000, + }); + expect(dispatched).toEqual([msgId]); + + // The inbox-poll path catches up next — without dedup it would + // re-dispatch. With the LRU it acks-and-skips. + const polled = await bobInbox.tick(); + expect(polled.received).toBe(0); + expect(dispatched).toEqual([msgId]); // still one entry + }); + + test('cross-channel dedup also covers poll-first then bridge-second order', async () => { + const store = new MemoryInboxStore(); + const app = createInboxServer({ crypto, store, disableRateLimit: true }); + const bob = await makeIdentity(); + const alice = await makeIdentity(); + + const bobInbox = new Inbox({ + baseUrl: 'http://localhost', + ownAddress: 'bob', + crypto, + signingPrivateKey: bob.signingPrivateKey, + signingPublicKey: bob.signingPublicKey, + pollIntervalMs: 0, + fetch: honoFetch(app), + }); + await bobInbox.register(); + const ct = randBytes(48); + const msgId = await computeMsgId(ct); + const aliceClient = new InboxClient({ + baseUrl: 'http://localhost', + crypto, + signingPrivateKey: alice.signingPrivateKey, + fetch: honoFetch(app), + }); + const putRes = await aliceClient.put({ + recipientAddress: 'bob', + senderSigningKey: alice.signingPublicKey, + envelope: ct, + }); + + const dispatched: string[] = []; + bobInbox.onIncoming(async (raw) => { + dispatched.push(raw.msgId); + return null; + }); + + // Poll first. + const polled = await bobInbox.tick(); + expect(polled.received).toBe(1); + + // Bridge frame for the same msgId arrives after the poll already + // dispatched + ack'd it — must be a no-op. + const handled = await bobInbox.acceptBridgeFrame({ + msgId, + ciphertext: ct, + receivedAt: putRes.receivedAt, + expiresAt: Date.now() + 60_000, + }); + expect(handled).toBe(false); + expect(dispatched).toEqual([msgId]); + }); + test('flush retries on transient server failure', async () => { const store = new MemoryInboxStore(); const app = createInboxServer({ crypto, store, disableRateLimit: true }); diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index 623d0fc..82831b8 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.8.2", + "version": "4.8.3", "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 c106aca..373e708 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.8.2", + "version": "4.8.3", "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 b455c41..8f6660f 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.8.2", + "version": "4.8.3", "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 289c7c2..3949d7a 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.8.2", + "version": "4.8.3", "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 e4f741f..2cd5b39 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.8.2", + "version": "4.8.3", "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 89dd8bf..60115fe 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.8.2", + "version": "4.8.3", "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 897af6d..7932a26 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -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", diff --git a/packages/shade-sdk/src/shade.ts b/packages/shade-sdk/src/shade.ts index c2abb28..3c5dd6b 100644 --- a/packages/shade-sdk/src/shade.ts +++ b/packages/shade-sdk/src/shade.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:` — 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 { + 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 diff --git a/packages/shade-sdk/tests/sdk.test.ts b/packages/shade-sdk/tests/sdk.test.ts index aa6e361..ac5f993 100644 --- a/packages/shade-sdk/tests/sdk.test.ts +++ b/packages/shade-sdk/tests/sdk.test.ts @@ -131,6 +131,75 @@ describe('createShade — happy path', () => { await expect(alice.send('nobody', 'ghost')).rejects.toThrow(); }); + test('aliasSession migrates a session from fp: 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:`), 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:` → `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 diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index aaa6eca..51ec8cf 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.8.2", + "version": "4.8.3", "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 e5a3939..f2619d5 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-indexeddb/package.json b/packages/shade-storage-indexeddb/package.json index 7d2a37c..b94d206 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-postgres/package.json b/packages/shade-storage-postgres/package.json index 98fc8f9..7898fb8 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 155d96e..766f151 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index ac031d2..7e54476 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.8.2", + "version": "4.8.3", "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 22c8d63..851ab2e 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.8.2", + "version": "4.8.3", "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 f99e52d..ed00853 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.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/src/types.ts b/packages/shade-transport-bridge/src/types.ts index 9eb09d7..d9ff83a 100644 --- a/packages/shade-transport-bridge/src/types.ts +++ b/packages/shade-transport-bridge/src/types.ts @@ -28,6 +28,14 @@ export interface IncomingMessage { receivedAt: number; /** Relay-assigned message id (sha256 of ciphertext). Useful for ack/dedup. */ msgId?: string; + /** + * Absolute expiry (ms since epoch) reported by the relay. Surfaced for + * symmetry with `Inbox.handleBlob` so that + * `Inbox.acceptBridgeFrame(msg)` can be wired directly without the + * caller having to invent a TTL. Optional — pre-V4.8.3 relays / non- + * inbox bridges don't populate it. + */ + expiresAt?: number; } /** Subscriber callback. Bridges MAY invoke it concurrently. */ @@ -83,6 +91,7 @@ export function decodeWireMessage(wire: BridgeWireMessage): IncomingMessage { receivedAt: wire.receivedAt, }; if (wire.msgId !== undefined) msg.msgId = wire.msgId; + if (wire.expiresAt !== undefined) msg.expiresAt = wire.expiresAt; return msg; } diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index d8919e8..291ce52 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.8.2", + "version": "4.8.3", "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 21d62aa..3b14b3c 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.8.2", + "version": "4.8.3", "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 0d29e78..e1c3cbe 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.8.2", + "version": "4.8.3", "type": "module", "main": "src/index.ts", "types": "src/index.ts",