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:
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user