release(v4.8.0): sender-fingerprint attribution + Inbox.start race fix

Two unblocking changes for first-contact flows.

Sender attribution: relay captures shortHash(senderSigningKey) at
PUT time (after signature verification, no new trust surface) and
surfaces it on bridge push (IncomingMessage.from) + inbox-fetch
(FetchedBlob.from) + DecryptHandler raw arg. Apps receiving a prekey
envelope from a never-before-seen peer can now bootstrap X3DH via
shade.receive('fp:<hex>', env) — pre-4.8 the wire envelope didn't
authenticate the sender and there was no out-of-band hint to use.
Idempotent ALTER TABLE migrations for SQLite + Postgres add a
sender_fp TEXT column; legacy rows surface as from=undefined
(inter-version compat).

Inbox.start() race: pre-4.8 start() called register() fire-and-forget
AND schedulePoll(0) synchronously, so the first poll on a fresh
address often beat the register HTTP RTT and got SHADE_NOT_FOUND.
start() now defers; register() success kicks schedulePoll(0). Manual
tick() is unaffected (deliberate user action, no gating).

Both reported by Prism. Tests cover all five acceptance criteria
from the sender-attribution request (PUT capture, bridge surface,
fetch surface, inter-version compat, end-to-end pair smoke) plus
the three from the race-fix request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 00:11:59 +02:00
parent 594992a183
commit 1fb59a7076
38 changed files with 705 additions and 67 deletions

View File

@@ -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",

View File

@@ -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:<hex>`) 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),
};

View File

@@ -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:<hex>` 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> | 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', {

View File

@@ -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}$/);
});
});