diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a674fe..7fd1eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,129 @@ 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.0] — 2026-05-08 — Sender-fingerprint attribution + `Inbox.start()` race fix + +Two unblocking changes for first-contact flows. First, the relay now +captures the sender's signing-key fingerprint at PUT time and surfaces +it on every downstream delivery — bridge push (`IncomingMessage.from`) +and inbox-fetch response (`FetchedBlob.from`). Without it, an app +receiving a prekey envelope from a never-before-seen peer cannot +decrypt it: `shade.receive(from, env)` requires a sender address and +the wire envelope itself doesn't authenticate the sender. The +fingerprint is the same 8-byte hex of SHA-256(senderSigningKey) that +`IncomingMessage.from` was already documented as carrying; the field +just wasn't populated. + +Second, `Inbox.start()` no longer races register vs the first poll. +Pre-fix, a fresh address calling `start()` saw the very first +`/v1/inbox/{addr}/fetch` POST race the register HTTP RTT and return +`SHADE_NOT_FOUND` — confusing 404 in DevTools, ~30s gap until the next +scheduled poll, and inbox-fetch silently dark for the gap (bridge push +covered for it, which is why this slipped through). `start()` now +defers the first poll; `register()` success kicks `schedulePoll(0)`. + +Both reported by Prism (multi-device E2EE terminal). Wave-3 pair +handshake is unblocked: web POSTs pair frame to PC inbox, PC's +`onIncoming` gets `raw.from = "fp:"`, calls +`shade.receive('fp:', env)`, parses plaintext, learns real +address, sends paired-reply. + +### Added + +#### `@shade/inbox-server` +- `InboxStore.putBlob({ ..., senderFp? })` — store interface accepts an + optional 8-byte hex fingerprint. `MemoryInboxStore`, + `SqliteInboxStore` (`@shade/storage-sqlite`), and `PostgresInboxStore` + (`@shade/storage-postgres`) all persist + return it. +- `InboxStore.fetchBlobs(...)` rows expose `senderFp?: string`. + Undefined for legacy rows persisted by a pre-4.8 relay. +- `POST /v1/inbox/:address` route computes `shortHash(senderSigningKey)` + after the sender's signature is verified and forwards it to + `store.putBlob({ ..., senderFp })`. The signature verification path + authorizes the same fingerprint that gets persisted — no new trust + surface. +- `POST /v1/inbox/:address/fetch` response includes `from` per blob + when the row has a fingerprint. Absent on legacy rows. +- Bridge endpoints (`/v1/bridge/{stream,poll,ws}`) now populate + `BridgeWireMessage.from` from the row's `senderFp`. The + `transport-bridge` wire format already accepted `from`; v4.7 just + never filled it. + +#### `@shade/inbox` +- `FetchedBlob.from?: string` — relay-supplied sender fingerprint hint, + parsed from the fetch response. +- `DecryptHandler` raw arg gains `from?: string`. Apps that ignore it + keep working unchanged (back-compat: the field is optional). + +### Fixed + +#### `@shade/inbox` — `Inbox.start()` register/poll race +`start()` no longer schedules the first poll synchronously alongside +the fire-and-forget `register()`. Instead, `register()` success kicks +`schedulePoll(0)`, so the first poll fires after the server has +acknowledged the address. Already-registered instances (where the +local `this.registered` flag is true at `start()` time, e.g. after a +restart that hydrated state) get an immediate poll as before. + +### Storage migrations + +Idempotent ALTER TABLE for live deployments: +- **SQLite** (`@shade/storage-sqlite`): on open, the store does + `PRAGMA table_info(inbox_blobs)` and runs + `ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT` if the column is + missing. Fresh databases get the column from the `CREATE TABLE IF + NOT EXISTS` directly. +- **Postgres** (`@shade/storage-postgres`): `ensureInboxServerTables` + runs `ALTER TABLE shade_inbox_blobs ADD COLUMN IF NOT EXISTS + sender_fp TEXT`. + +Both leave existing rows with `sender_fp = NULL`. The fetch path emits +`from` only when the column is non-empty, so legacy blobs surface as +`from: undefined` (acceptance criterion (4): inter-version compat). + +### Tests +- `packages/shade-inbox/tests/client.test.ts`: + - **Race fix**: spy fetch records the order of `register` and `fetch` + requests; first `fetch` (if any) must follow `register`. Pre-fix + the recording fetch threw "fetch fired before register completed + (race not fixed)". + - **Fetch attribution**: `FetchedBlob.from` matches + `SHA-256(senderSigningKey)[:8]` in hex. + - **DecryptHandler propagation**: `raw.from` arrives in the app's + handler. +- `packages/shade-transport-bridge/tests/bridge.test.ts`: same + fingerprint regression for SSE, WS, and long-poll bridges + (`IncomingMessage.from` non-empty + matches the expected digest). +- `packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts`: + - senderFp round-trip through put + fetch. + - senderFp omitted on put → fetched row has `senderFp: undefined`. + - **Pre-4.8 schema migration**: open a DB seeded with a v4.7 + `inbox_blobs` schema (no `sender_fp` column), reopen via + `SqliteInboxStore`, verify the legacy row survives + new writes + carry the new field. + +### Migration + +None required for app code. Existing handlers that ignore +`raw.from` / `IncomingMessage.from` keep working unchanged. Apps that +want sender-attributed first-contact: + +```ts +inbox.onIncoming(async (raw) => { + const tentativeAddr = raw.from ? `fp:${raw.from}` : null; + if (!tentativeAddr) return null; // legacy relay; drop + const env = decodeEnvelope(raw.ciphertext); + const plaintext = await shade.receive(tentativeAddr, env); + // pair frame announces real address; reconcile fp: → real + return null; +}); +``` + +For Prism specifically: drop the `await this.inbox.register()` +workaround in `apps/web/src/shade/transport.ts` and +`packages/shade-sidecar/src/transport.ts`. `inbox.start()` on 4.8+ +no longer races and the explicit pre-register is redundant. + ## [4.7.0] — 2026-05-07 — Peer-presence events for instant `BroadcastChannel` revoke `BroadcastChannel.removeMember` (v4.6) is the right primitive for revoking a diff --git a/packages/shade-cli/package.json b/packages/shade-cli/package.json index 0616aea..4c8b846 100644 --- a/packages/shade-cli/package.json +++ b/packages/shade-cli/package.json @@ -1,6 +1,6 @@ { "name": "@shade/cli", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "main": "src/cli.ts", "bin": { diff --git a/packages/shade-core/package.json b/packages/shade-core/package.json index cf7cb20..4989a27 100644 --- a/packages/shade-core/package.json +++ b/packages/shade-core/package.json @@ -1,6 +1,6 @@ { "name": "@shade/core", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-crypto-web/package.json b/packages/shade-crypto-web/package.json index f680924..2c2fbfc 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.7.0", + "version": "4.8.0", "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 fa4fb76..c9042d4 100644 --- a/packages/shade-dashboard/package.json +++ b/packages/shade-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "@shade/dashboard", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/packages/shade-files/package.json b/packages/shade-files/package.json index 0effdc4..1823cb5 100644 --- a/packages/shade-files/package.json +++ b/packages/shade-files/package.json @@ -1,6 +1,6 @@ { "name": "@shade/files", - "version": "4.7.0", + "version": "4.8.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 574f580..0ebbc6e 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.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox-server/src/bridge.ts b/packages/shade-inbox-server/src/bridge.ts index 7a583a4..906a486 100644 --- a/packages/shade-inbox-server/src/bridge.ts +++ b/packages/shade-inbox-server/src/bridge.ts @@ -497,6 +497,8 @@ interface BlobRow { ciphertext: Uint8Array; receivedAt: number; expiresAt: number; + /** V4.8 — relay-captured sender fingerprint. Optional for legacy rows. */ + senderFp?: string; } interface BlobWriter { @@ -542,13 +544,26 @@ function serializeBlob(blob: BlobRow): { ciphertext: string; receivedAt: number; expiresAt: number; + from?: string; } { - return { + const out: { + msgId: string; + ciphertext: string; + receivedAt: number; + expiresAt: number; + from?: string; + } = { msgId: blob.msgId, ciphertext: toBase64(blob.ciphertext), receivedAt: blob.receivedAt, expiresAt: blob.expiresAt, }; + // V4.8 — relay-captured sender fingerprint. The transport-bridge + // wire format already accepts `from`; populating it lets receivers + // bootstrap unknown-sender first-contact via `shade.receive('fp:', + // env)` without requiring an out-of-band sender hint. + if (blob.senderFp) out.from = blob.senderFp; + return out; } function buildPollResponse(blobs: BlobRow[], sinceFallback: number): { diff --git a/packages/shade-inbox-server/src/memory-store.ts b/packages/shade-inbox-server/src/memory-store.ts index bdcbaa4..74b3ffb 100644 --- a/packages/shade-inbox-server/src/memory-store.ts +++ b/packages/shade-inbox-server/src/memory-store.ts @@ -5,6 +5,7 @@ interface BlobRow { ciphertext: Uint8Array; receivedAt: number; expiresAt: number; + senderFp?: string; } /** @@ -33,6 +34,7 @@ export class MemoryInboxStore implements InboxStore { msgId: string; ciphertext: Uint8Array; expiresAt: number; + senderFp?: string; }): Promise<{ created: boolean; receivedAt: number }> { const list = this.blobs.get(args.address) ?? []; const existing = list.find((r) => r.msgId === args.msgId); @@ -41,12 +43,14 @@ export class MemoryInboxStore implements InboxStore { // multiple blobs land in the same millisecond. const receivedAt = Math.max(this.nextReceivedAt + 1, Date.now()); this.nextReceivedAt = receivedAt; - list.push({ + const row: BlobRow = { msgId: args.msgId, ciphertext: new Uint8Array(args.ciphertext), receivedAt, expiresAt: args.expiresAt, - }); + }; + if (args.senderFp !== undefined) row.senderFp = args.senderFp; + list.push(row); this.blobs.set(args.address, list); return { created: true, receivedAt }; } @@ -56,18 +60,36 @@ export class MemoryInboxStore implements InboxStore { sinceCursor: number; now: number; limit: number; - }): Promise> { + }): Promise< + Array<{ + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + }> + > { const list = this.blobs.get(args.address) ?? []; return list .filter((r) => r.receivedAt > args.sinceCursor && r.expiresAt > args.now) .sort((a, b) => a.receivedAt - b.receivedAt) .slice(0, args.limit) - .map((r) => ({ - msgId: r.msgId, - ciphertext: new Uint8Array(r.ciphertext), - receivedAt: r.receivedAt, - expiresAt: r.expiresAt, - })); + .map((r) => { + const out: { + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + } = { + msgId: r.msgId, + ciphertext: new Uint8Array(r.ciphertext), + receivedAt: r.receivedAt, + expiresAt: r.expiresAt, + }; + if (r.senderFp !== undefined) out.senderFp = r.senderFp; + return out; + }); } async deleteBlob(address: string, msgId: string): Promise { diff --git a/packages/shade-inbox-server/src/routes.ts b/packages/shade-inbox-server/src/routes.ts index e2de577..042c0bd 100644 --- a/packages/shade-inbox-server/src/routes.ts +++ b/packages/shade-inbox-server/src/routes.ts @@ -255,11 +255,19 @@ export function createInboxRoutes( ); } + // V4.8: capture sender fingerprint at PUT time. The sender's + // signing key was just verified for this request, so the fingerprint + // is bound to the same authentication path that authorized the + // store. Surfaced on bridge push + inbox-fetch responses to + // bootstrap unknown-sender first-contact (X3DH pair handshake). + const senderFp = await shortHash(senderKey); + const result = await store.putBlob({ address, msgId, ciphertext: ctBytes, expiresAt, + senderFp, }); if (result.created) { events?.emit('inbox.blob_stored', { @@ -319,12 +327,22 @@ export function createInboxRoutes( let bytes = 0; const blobs = rows.map((r) => { bytes += r.ciphertext.length; - return { + const out: { + msgId: string; + ciphertext: string; + receivedAt: number; + expiresAt: number; + from?: string; + } = { msgId: r.msgId, ciphertext: toBase64(r.ciphertext), receivedAt: r.receivedAt, expiresAt: r.expiresAt, }; + // V4.8: surface sender fingerprint when present. Empty for blobs + // persisted by a pre-4.8 relay that didn't track sender provenance. + if (r.senderFp) out.from = r.senderFp; + return out; }); const nextCursor = rows.length > 0 ? rows[rows.length - 1]!.receivedAt : sinceCursor; diff --git a/packages/shade-inbox-server/src/store.ts b/packages/shade-inbox-server/src/store.ts index 7d58707..279e7b8 100644 --- a/packages/shade-inbox-server/src/store.ts +++ b/packages/shade-inbox-server/src/store.ts @@ -36,12 +36,20 @@ export interface InboxStore { * **Idempotent**: if a row already exists for `(address, msgId)` the * implementation MUST return `{ created: false }` and leave the existing * row untouched. A fresh insert returns `{ created: true, receivedAt }`. + * + * `senderFp` (V4.8+) is the 8-byte hex of SHA-256(senderSigningKey) + * — captured at PUT time when the relay verified the sender's + * signature. Optional so legacy 4.7 callers compile, but populated by + * `createInboxRoutes` from 4.8 onward and surfaced on bridge push + + * inbox-fetch responses to bootstrap unknown-sender first-contact + * (X3DH pair handshake). */ putBlob(args: { address: string; msgId: string; ciphertext: Uint8Array; expiresAt: number; + senderFp?: string; }): Promise<{ created: boolean; receivedAt: number }>; /** @@ -57,7 +65,20 @@ export interface InboxStore { sinceCursor: number; now: number; limit: number; - }): Promise>; + }): Promise< + Array<{ + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + /** + * Sender fingerprint — 8-byte hex of SHA-256(senderSigningKey) + * captured at PUT time. Empty/undefined for blobs persisted by a + * pre-4.8 relay that didn't track sender provenance. + */ + senderFp?: string; + }> + >; /** * Delete a single blob by `(address, msgId)`. Returns true if a row was diff --git a/packages/shade-inbox/package.json b/packages/shade-inbox/package.json index bd92d75..b34930d 100644 --- a/packages/shade-inbox/package.json +++ b/packages/shade-inbox/package.json @@ -1,6 +1,6 @@ { "name": "@shade/inbox", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-inbox/src/client.ts b/packages/shade-inbox/src/client.ts index aef5f1e..4ade84b 100644 --- a/packages/shade-inbox/src/client.ts +++ b/packages/shade-inbox/src/client.ts @@ -40,6 +40,16 @@ export interface FetchedBlob { receivedAt: number; /** Absolute expiry time (ms since epoch) reported by the server. */ expiresAt: number; + /** + * Sender fingerprint — 8-byte hex of SHA-256(senderSigningKey), + * captured by the relay at PUT time when the sender's signature was + * verified. Empty/undefined when the relay is pre-4.8 or the blob + * predates sender-fingerprint tracking. Use as an unknown-sender + * bootstrap label (`fp:`) for X3DH first-contact; the + * authoritative sender identity is recovered post-decrypt from the + * envelope itself, so `from` is a hint, not a trust anchor. + */ + from?: string; } export interface FetchResult { @@ -151,12 +161,18 @@ export class InboxClient { ); const blobs = Array.isArray(json.blobs) ? json.blobs : []; return { - blobs: blobs.map((b: any) => ({ - msgId: String(b.msgId), - ciphertext: fromBase64(String(b.ciphertext)), - receivedAt: Number(b.receivedAt), - expiresAt: Number(b.expiresAt), - })), + blobs: blobs.map((b: any): FetchedBlob => { + const out: FetchedBlob = { + msgId: String(b.msgId), + ciphertext: fromBase64(String(b.ciphertext)), + receivedAt: Number(b.receivedAt), + expiresAt: Number(b.expiresAt), + }; + // V4.8 — relay-supplied sender fingerprint hint. Optional; absent + // on pre-4.8 relays or for blobs persisted before tracking landed. + if (typeof b.from === 'string' && b.from.length > 0) out.from = b.from; + return out; + }), cursor: Number(json.cursor ?? sinceCursor), hasMore: Boolean(json.hasMore), }; diff --git a/packages/shade-inbox/src/inbox.ts b/packages/shade-inbox/src/inbox.ts index 8597458..0f7fadd 100644 --- a/packages/shade-inbox/src/inbox.ts +++ b/packages/shade-inbox/src/inbox.ts @@ -16,9 +16,20 @@ import { InboxClientEvents, type InboxClientListener } from './events.js'; * decrypt path it owns) and either return a sender-hint for telemetry * (the address the SDK extracted, or `null`) or throw to keep the blob * on the server for a later retry. + * + * V4.8: `raw.from` is the relay-captured sender fingerprint (8-byte hex + * of SHA-256 over the sender's signing key). Empty when the relay is + * pre-4.8 or didn't track the sender. Use it as the `fp:` bootstrap + * label for X3DH first-contact handshakes. */ export type DecryptHandler = ( - raw: { msgId: string; ciphertext: Uint8Array; receivedAt: number; expiresAt: number }, + raw: { + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + from?: string; + }, ) => Promise | string | null | undefined; export interface InboxOptions { @@ -149,6 +160,14 @@ export class Inbox { signingKey: this.options.signingPublicKey, }); this.registered = true; + // V4.8: gate the first poll on register success. `start()` calls + // `register()` fire-and-forget; without this kick, the very first + // `pollOnce()` (scheduled synchronously alongside register) would + // race the register HTTP RTT and return SHADE_NOT_FOUND. The + // pollOnce() guard skips polls until `registered === true`; this + // immediate schedule ensures we don't wait the full pollIntervalMs + // for the next attempt once register lands. + if (this.started) this.schedulePoll(0); } /** Drop the address from the server. Local queue/cursor are preserved. */ @@ -217,7 +236,11 @@ export class Inbox { this.scheduleRegisterRetry(); }); this.scheduleFlush(); - this.schedulePoll(0); + // V4.8: do NOT schedule the first poll synchronously. `register()` + // success kicks `schedulePoll(0)` so the first poll fires after the + // server has acknowledged the address. Pre-fix this raced register + // and burned a 404 on every fresh-address `start()`. + if (this.registered) this.schedulePoll(0); } /** Stop background timers. Pending entries remain in the queue. */ @@ -375,12 +398,20 @@ export class Inbox { let senderHint: string | null = null; try { - const result = await this.incomingHandler({ + const raw: { + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + from?: string; + } = { msgId: blob.msgId, ciphertext: blob.ciphertext, receivedAt: blob.receivedAt, expiresAt: blob.expiresAt, - }); + }; + if (blob.from !== undefined) raw.from = blob.from; + const result = await this.incomingHandler(raw); senderHint = result ?? null; } catch (err) { this.events.emit('inbox.message_decrypt_failed', { diff --git a/packages/shade-inbox/tests/client.test.ts b/packages/shade-inbox/tests/client.test.ts index e071cbf..d86e3cb 100644 --- a/packages/shade-inbox/tests/client.test.ts +++ b/packages/shade-inbox/tests/client.test.ts @@ -326,3 +326,150 @@ describe('InboxClient — default fetch is bound to globalThis', () => { } }); }); + +describe('Inbox.start() — fresh-address register/poll race (V4.8)', () => { + // Regression: pre-4.8 `start()` called `register()` fire-and-forget AND + // `schedulePoll(0)` synchronously, so the first poll often beat the + // register HTTP RTT and got SHADE_NOT_FOUND on a fresh address. Fix: + // start() defers the first poll; register() success kicks it. + test('fresh address: no fetch fires before register completes', async () => { + const store = new MemoryInboxStore(); + const app = createInboxServer({ crypto, store, disableRateLimit: true }); + const alice = await makeIdentity(); + + // Order observed by the server: must be register-then-fetch, never + // fetch-then-register. + const calls: Array<'register' | 'fetch' | 'put'> = []; + let registerArrived = false; + const recordingFetch: typeof fetch = (async (input, init) => { + const u = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (u.includes('/v1/inbox/register')) { + calls.push('register'); + // Hold register for a tick to widen the race window. + await new Promise((r) => setTimeout(r, 25)); + registerArrived = true; + } else if (u.endsWith('/fetch')) { + // Any fetch arriving before register is the race we're guarding + // against. + if (!registerArrived) { + throw new Error('fetch fired before register completed (race not fixed)'); + } + calls.push('fetch'); + } else if (u.includes('/v1/inbox/')) { + calls.push('put'); + } + return honoFetch(app)(input, init); + }) as typeof fetch; + + const inbox = new Inbox({ + baseUrl: 'http://localhost', + ownAddress: 'alice', + crypto, + signingPrivateKey: alice.signingPrivateKey, + signingPublicKey: alice.signingPublicKey, + pollIntervalMs: 30_000, // Long enough that only register's kick triggers. + fetch: recordingFetch, + }); + inbox.onIncoming(() => null); + inbox.start(); + + // Wait until register has completed and the success-kick poll lands. + await new Promise((r) => setTimeout(r, 100)); + inbox.stop(); + + expect(calls[0]).toBe('register'); + // First fetch (if any) must be after register. + const firstFetchIdx = calls.indexOf('fetch'); + if (firstFetchIdx !== -1) { + expect(firstFetchIdx).toBeGreaterThan(calls.indexOf('register')); + } + }); +}); + +describe('FetchedBlob.from — relay-supplied sender fingerprint (V4.8)', () => { + test('inbox-fetch response carries from = 8-byte hex of SHA-256(senderSigningKey)', async () => { + const store = new MemoryInboxStore(); + const app = createInboxServer({ crypto, store, disableRateLimit: true }); + const bob = await makeIdentity(); + const alice = await makeIdentity(); + + const bobClient = new InboxClient({ + baseUrl: 'http://localhost', + crypto, + signingPrivateKey: bob.signingPrivateKey, + fetch: honoFetch(app), + }); + const aliceClient = new InboxClient({ + baseUrl: 'http://localhost', + crypto, + signingPrivateKey: alice.signingPrivateKey, + fetch: honoFetch(app), + }); + + await bobClient.register({ address: 'bob', signingKey: bob.signingPublicKey }); + await aliceClient.put({ + recipientAddress: 'bob', + senderSigningKey: alice.signingPublicKey, + envelope: randBytes(64), + }); + + const fetched = await bobClient.fetch({ address: 'bob' }); + expect(fetched.blobs.length).toBe(1); + const fp = fetched.blobs[0]!.from; + expect(fp).toBeDefined(); + expect(fp).toMatch(/^[0-9a-f]{16}$/); + + // Must be reproducible: SHA-256(alice.signingPublicKey) → first 8 bytes hex. + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + alice.signingPublicKey as unknown as ArrayBuffer, + ); + const expected = Array.from(new Uint8Array(digest).slice(0, 8), (b) => + b.toString(16).padStart(2, '0'), + ).join(''); + expect(fp).toBe(expected); + }); + + test('DecryptHandler raw arg propagates from to the app', async () => { + const store = new MemoryInboxStore(); + const app = createInboxServer({ crypto, store, disableRateLimit: true }); + const bob = await makeIdentity(); + const alice = await makeIdentity(); + + const aliceClient = new InboxClient({ + baseUrl: 'http://localhost', + crypto, + signingPrivateKey: alice.signingPrivateKey, + fetch: honoFetch(app), + }); + const bobInbox = new Inbox({ + baseUrl: 'http://localhost', + ownAddress: 'bob', + crypto, + signingPrivateKey: bob.signingPrivateKey, + signingPublicKey: bob.signingPublicKey, + pollIntervalMs: 0, + fetch: honoFetch(app), + }); + + await bobInbox.register(); + await aliceClient.put({ + recipientAddress: 'bob', + senderSigningKey: alice.signingPublicKey, + envelope: randBytes(40), + }); + + let observed: string | undefined = undefined; + bobInbox.onIncoming((raw) => { + observed = raw.from; + return null; + }); + await bobInbox.tick(); + expect(observed).toMatch(/^[0-9a-f]{16}$/); + }); +}); diff --git a/packages/shade-key-transparency/package.json b/packages/shade-key-transparency/package.json index 6c7a994..527e820 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.7.0", + "version": "4.8.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 4fc805b..5c4b2e9 100644 --- a/packages/shade-keychain/package.json +++ b/packages/shade-keychain/package.json @@ -1,6 +1,6 @@ { "name": "@shade/keychain", - "version": "4.7.0", + "version": "4.8.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 1b8622b..598c1c2 100644 --- a/packages/shade-observability/package.json +++ b/packages/shade-observability/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observability", - "version": "4.7.0", + "version": "4.8.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 325dacd..8ea4c67 100644 --- a/packages/shade-observer/package.json +++ b/packages/shade-observer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/observer", - "version": "4.7.0", + "version": "4.8.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 9afe2b1..3e165af 100644 --- a/packages/shade-proto/package.json +++ b/packages/shade-proto/package.json @@ -1,6 +1,6 @@ { "name": "@shade/proto", - "version": "4.7.0", + "version": "4.8.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 e8e1604..3afa9a5 100644 --- a/packages/shade-recovery/package.json +++ b/packages/shade-recovery/package.json @@ -1,6 +1,6 @@ { "name": "@shade/recovery", - "version": "4.7.0", + "version": "4.8.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 a00b72f..66edf15 100644 --- a/packages/shade-sdk/package.json +++ b/packages/shade-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@shade/sdk", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-server/package.json b/packages/shade-server/package.json index 30ce969..c02735e 100644 --- a/packages/shade-server/package.json +++ b/packages/shade-server/package.json @@ -1,6 +1,6 @@ { "name": "@shade/server", - "version": "4.7.0", + "version": "4.8.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 f0903f4..0aa3b80 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.7.0", + "version": "4.8.0", "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 a3472d3..10f2e00 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.7.0", + "version": "4.8.0", "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 aa0fec0..177934e 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.7.0", + "version": "4.8.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 9ad9fa9..33c5664 100644 --- a/packages/shade-storage-postgres/src/ensure-tables.ts +++ b/packages/shade-storage-postgres/src/ensure-tables.ts @@ -223,9 +223,16 @@ export async function ensureInboxServerTables(sql: Sql): Promise { ciphertext TEXT NOT NULL, received_at BIGINT NOT NULL, expires_at BIGINT NOT NULL, + sender_fp TEXT, PRIMARY KEY (address, msg_id) ) `; + // V4.8 — sender fingerprint column. Idempotent ADD COLUMN for live + // databases that came up under a 4.7-or-earlier schema. + await sql` + ALTER TABLE shade_inbox_blobs + ADD COLUMN IF NOT EXISTS sender_fp TEXT + `; await sql` CREATE INDEX IF NOT EXISTS shade_inbox_addr_expires_idx ON shade_inbox_blobs(address, expires_at) diff --git a/packages/shade-storage-postgres/src/postgres-inbox-store.ts b/packages/shade-storage-postgres/src/postgres-inbox-store.ts index e25d8f1..81c4a8e 100644 --- a/packages/shade-storage-postgres/src/postgres-inbox-store.ts +++ b/packages/shade-storage-postgres/src/postgres-inbox-store.ts @@ -56,18 +56,20 @@ export class PostgresInboxStore implements InboxStore { msgId: string; ciphertext: Uint8Array; expiresAt: number; + senderFp?: string; }): Promise<{ created: boolean; receivedAt: number }> { // ON CONFLICT DO NOTHING + RETURNING keeps it idempotent and atomic. // When a row already exists, we look up its received_at in a follow-up // SELECT. const inserted = await this.sql>` - INSERT INTO shade_inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) + INSERT INTO shade_inbox_blobs (address, msg_id, ciphertext, received_at, expires_at, sender_fp) VALUES ( ${args.address}, ${args.msgId}, ${toBase64(args.ciphertext)}, nextval('shade_inbox_seq'), - ${args.expiresAt} + ${args.expiresAt}, + ${args.senderFp ?? null} ) ON CONFLICT (address, msg_id) DO NOTHING RETURNING received_at::text @@ -90,14 +92,23 @@ export class PostgresInboxStore implements InboxStore { sinceCursor: number; now: number; limit: number; - }): Promise> { + }): Promise< + Array<{ + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + }> + > { const rows = await this.sql>` - SELECT msg_id, ciphertext, received_at::text, expires_at::text + SELECT msg_id, ciphertext, received_at::text, expires_at::text, sender_fp FROM shade_inbox_blobs WHERE address = ${args.address} AND received_at > ${args.sinceCursor} @@ -105,12 +116,22 @@ export class PostgresInboxStore implements InboxStore { ORDER BY received_at ASC LIMIT ${args.limit} `; - return rows.map((r) => ({ - msgId: r.msg_id, - ciphertext: fromBase64(r.ciphertext), - receivedAt: parseInt(r.received_at, 10), - expiresAt: parseInt(r.expires_at, 10), - })); + return rows.map((r) => { + const out: { + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + } = { + msgId: r.msg_id, + ciphertext: fromBase64(r.ciphertext), + receivedAt: parseInt(r.received_at, 10), + expiresAt: parseInt(r.expires_at, 10), + }; + if (r.sender_fp) out.senderFp = r.sender_fp; + return out; + }); } async deleteBlob(address: string, msgId: string): Promise { diff --git a/packages/shade-storage-sqlite/package.json b/packages/shade-storage-sqlite/package.json index 3380e01..033d6dd 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.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-storage-sqlite/src/sqlite-inbox-store.ts b/packages/shade-storage-sqlite/src/sqlite-inbox-store.ts index e9a744f..9276e98 100644 --- a/packages/shade-storage-sqlite/src/sqlite-inbox-store.ts +++ b/packages/shade-storage-sqlite/src/sqlite-inbox-store.ts @@ -53,6 +53,7 @@ export class SqliteInboxStore implements InboxStore { ciphertext TEXT NOT NULL, received_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, + sender_fp TEXT, PRIMARY KEY (address, msg_id) ); CREATE INDEX IF NOT EXISTS idx_inbox_addr_expires @@ -62,6 +63,14 @@ export class SqliteInboxStore implements InboxStore { CREATE INDEX IF NOT EXISTS idx_inbox_expires ON inbox_blobs(expires_at); `); + // V4.8 — sender fingerprint column. Idempotent ALTER for live + // databases that came up under a 4.7-or-earlier schema. + const cols = this.db + .prepare(`PRAGMA table_info(inbox_blobs)`) + .all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'sender_fp')) { + this.db.exec(`ALTER TABLE inbox_blobs ADD COLUMN sender_fp TEXT`); + } } private prepareStatements() { @@ -73,13 +82,13 @@ export class SqliteInboxStore implements InboxStore { deleteOwner: this.db.prepare('DELETE FROM inbox_owners WHERE address = ?'), deleteOwnerBlobs: this.db.prepare('DELETE FROM inbox_blobs WHERE address = ?'), insertBlob: this.db.prepare( - 'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)', + 'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at, sender_fp) VALUES (?, ?, ?, ?, ?, ?)', ), findBlob: this.db.prepare( 'SELECT received_at FROM inbox_blobs WHERE address = ? AND msg_id = ?', ), fetchSince: this.db.prepare( - `SELECT msg_id, ciphertext, received_at, expires_at + `SELECT msg_id, ciphertext, received_at, expires_at, sender_fp FROM inbox_blobs WHERE address = ? AND received_at > ? AND expires_at > ? ORDER BY received_at ASC @@ -124,6 +133,7 @@ export class SqliteInboxStore implements InboxStore { msgId: string; ciphertext: Uint8Array; expiresAt: number; + senderFp?: string; }): Promise<{ created: boolean; receivedAt: number }> { const existing = this.stmts.findBlob.get(args.address, args.msgId) as | { received_at: number } @@ -139,6 +149,7 @@ export class SqliteInboxStore implements InboxStore { toBase64(args.ciphertext), receivedAt, args.expiresAt, + args.senderFp ?? null, ); return { created: true, receivedAt }; } @@ -148,19 +159,43 @@ export class SqliteInboxStore implements InboxStore { sinceCursor: number; now: number; limit: number; - }): Promise> { + }): Promise< + Array<{ + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + }> + > { const rows = this.stmts.fetchSince.all( args.address, args.sinceCursor, args.now, args.limit, - ) as Array<{ msg_id: string; ciphertext: string; received_at: number; expires_at: number }>; - return rows.map((r) => ({ - msgId: r.msg_id, - ciphertext: fromBase64(r.ciphertext), - receivedAt: r.received_at, - expiresAt: r.expires_at, - })); + ) as Array<{ + msg_id: string; + ciphertext: string; + received_at: number; + expires_at: number; + sender_fp: string | null; + }>; + return rows.map((r) => { + const out: { + msgId: string; + ciphertext: Uint8Array; + receivedAt: number; + expiresAt: number; + senderFp?: string; + } = { + msgId: r.msg_id, + ciphertext: fromBase64(r.ciphertext), + receivedAt: r.received_at, + expiresAt: r.expires_at, + }; + if (r.sender_fp) out.senderFp = r.sender_fp; + return out; + }); } async deleteBlob(address: string, msgId: string): Promise { diff --git a/packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts b/packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts index 6a4d047..fd8fe7b 100644 --- a/packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts +++ b/packages/shade-storage-sqlite/tests/sqlite-inbox-store.test.ts @@ -178,6 +178,104 @@ describe('SqliteInboxStore', () => { expect(blobs.length).toBe(0); }); + test('senderFp round-trips through put + fetch (V4.8)', async () => { + const ct = randBytes(40); + const fp = '0123456789abcdef'; + await store.putBlob({ + address: 'bob', + msgId: 'a'.repeat(64), + ciphertext: ct, + expiresAt: Date.now() + 60_000, + senderFp: fp, + }); + const blobs = await store.fetchBlobs({ + address: 'bob', + sinceCursor: 0, + now: Date.now(), + limit: 10, + }); + expect(blobs.length).toBe(1); + expect(blobs[0]!.senderFp).toBe(fp); + }); + + test('senderFp omitted on put → fetched row has senderFp undefined (V4.8 backward-compat)', async () => { + const ct = randBytes(40); + await store.putBlob({ + address: 'bob', + msgId: 'b'.repeat(64), + ciphertext: ct, + expiresAt: Date.now() + 60_000, + }); + const blobs = await store.fetchBlobs({ + address: 'bob', + sinceCursor: 0, + now: Date.now(), + limit: 10, + }); + expect(blobs.length).toBe(1); + expect(blobs[0]!.senderFp).toBeUndefined(); + }); + + test('ALTER TABLE migration adds sender_fp to a pre-4.8 schema (V4.8)', async () => { + // Reproduce a pre-4.8 schema in a fresh DB, then reopen via + // SqliteInboxStore which should run the idempotent ALTER without + // dropping the existing rows. + store.close(); + try { + unlinkSync(dbPath); + } catch {} + try { + unlinkSync(dbPath + '-wal'); + } catch {} + try { + unlinkSync(dbPath + '-shm'); + } catch {} + + const { Database } = await import('bun:sqlite'); + const legacy = new Database(dbPath, { create: true }); + legacy.exec(` + CREATE TABLE inbox_blobs ( + address TEXT NOT NULL, + msg_id TEXT NOT NULL, + ciphertext TEXT NOT NULL, + received_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + PRIMARY KEY (address, msg_id) + ); + `); + legacy.prepare( + 'INSERT INTO inbox_blobs (address, msg_id, ciphertext, received_at, expires_at) VALUES (?, ?, ?, ?, ?)', + ).run('bob', 'c'.repeat(64), 'AAAA', Date.now(), Date.now() + 60_000); + legacy.close(); + + store = new SqliteInboxStore(dbPath); + const blobs = await store.fetchBlobs({ + address: 'bob', + sinceCursor: 0, + now: Date.now(), + limit: 10, + }); + expect(blobs.length).toBe(1); + expect(blobs[0]!.senderFp).toBeUndefined(); + + // New writes after migration carry senderFp. + await store.putBlob({ + address: 'bob', + msgId: 'd'.repeat(64), + ciphertext: randBytes(8), + expiresAt: Date.now() + 60_000, + senderFp: 'feedfacedeadbeef', + }); + const after = await store.fetchBlobs({ + address: 'bob', + sinceCursor: 0, + now: Date.now(), + limit: 10, + }); + const newer = after.find((b) => b.msgId === 'd'.repeat(64)); + expect(newer?.senderFp).toBe('feedfacedeadbeef'); + }); + test('countBlobs ignores expired entries', async () => { const now = Date.now(); await store.putBlob({ diff --git a/packages/shade-streams/package.json b/packages/shade-streams/package.json index cb284bc..44a251a 100644 --- a/packages/shade-streams/package.json +++ b/packages/shade-streams/package.json @@ -1,6 +1,6 @@ { "name": "@shade/streams", - "version": "4.7.0", + "version": "4.8.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 85f1940..26ea932 100644 --- a/packages/shade-transfer/package.json +++ b/packages/shade-transfer/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transfer", - "version": "4.7.0", + "version": "4.8.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 bc74d90..3be7796 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.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts", diff --git a/packages/shade-transport-bridge/tests/bridge.test.ts b/packages/shade-transport-bridge/tests/bridge.test.ts index b7419e9..8744a55 100644 --- a/packages/shade-transport-bridge/tests/bridge.test.ts +++ b/packages/shade-transport-bridge/tests/bridge.test.ts @@ -867,3 +867,87 @@ describe('PresenceBridge — subscribe to remote presence changes', () => { } }); }); + +// ─── V4.8 — sender-fingerprint propagation on bridge push ────────── + +describe('Sender attribution — bridge push surfaces IncomingMessage.from', () => { + test('SSE push carries from = 8-byte hex of SHA-256(senderSigningKey)', async () => { + const h = await bootstrap(); + try { + const received: IncomingMessage[] = []; + const bridge = new SseBridge({ + baseUrl: h.baseUrl, + auth: bobAuth(h), + initialBackoffMs: 50, + maxBackoffMs: 200, + disableAutoReconnect: true, + }); + await bridge.connect({ onMessage: (m) => received.push(m) }); + try { + await putBlob(h, rand(48)); + await waitFor(() => received.length === 1, 5_000); + const fp = received[0]!.from; + expect(fp).toMatch(/^[0-9a-f]{16}$/); + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + h.alice.signingPublicKey as unknown as ArrayBuffer, + ); + const expected = Array.from(new Uint8Array(digest).slice(0, 8), (b) => + b.toString(16).padStart(2, '0'), + ).join(''); + expect(fp).toBe(expected); + } finally { + await bridge.disconnect(); + } + } finally { + h.server.stop(true); + } + }); + + test('WS push carries from likewise', async () => { + const h = await bootstrap(); + try { + const received: IncomingMessage[] = []; + const bridge = new WsBridge({ + baseUrl: h.baseUrl, + auth: bobAuth(h), + connectTimeoutMs: 2_000, + disableAutoReconnect: true, + }); + await bridge.connect({ onMessage: (m) => received.push(m) }); + try { + await putBlob(h, rand(48)); + await waitFor(() => received.length === 1, 5_000); + expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/); + } finally { + await bridge.disconnect(); + } + } finally { + h.server.stop(true); + } + }); + + test('long-poll push carries from likewise', async () => { + const h = await bootstrap(); + try { + const received: IncomingMessage[] = []; + const bridge = new LongPollBridge({ + baseUrl: h.baseUrl, + auth: bobAuth(h), + pollTimeoutMs: 500, + requestTimeoutMs: 1_500, + errorBackoffMs: 50, + }); + await bridge.connect({ onMessage: (m) => received.push(m) }); + try { + await putBlob(h, rand(48)); + await waitFor(() => received.length === 1, 5_000); + expect(received[0]!.from).toMatch(/^[0-9a-f]{16}$/); + } finally { + await bridge.disconnect(); + } + } finally { + h.server.stop(true); + } + }); +}); diff --git a/packages/shade-transport-webrtc/package.json b/packages/shade-transport-webrtc/package.json index a2b8861..98e7df4 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.7.0", + "version": "4.8.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 1303eba..f36961c 100644 --- a/packages/shade-transport/package.json +++ b/packages/shade-transport/package.json @@ -1,6 +1,6 @@ { "name": "@shade/transport", - "version": "4.7.0", + "version": "4.8.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 3760b34..b01f67f 100644 --- a/packages/shade-widgets/package.json +++ b/packages/shade-widgets/package.json @@ -1,6 +1,6 @@ { "name": "@shade/widgets", - "version": "4.7.0", + "version": "4.8.0", "type": "module", "main": "src/index.ts", "types": "src/index.ts",