release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

Lands the broadcast-channel primitive Prism asked for in
Docs/shade-feature-request-sender-keys.md. The crypto in
@shade/core/sender-keys.ts was already in place; this release wires
it up as a first-class app-facing API, adds the persistence schema
across all six storage backends (memory, sqlite, indexeddb +
encrypted variants), introduces wire type 0x21 in @shade/proto,
and ships Prism's three acceptance tests verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 15:55:34 +02:00
parent 2b1b4d6630
commit 2c400d7094
42 changed files with 1606 additions and 49 deletions

View File

@@ -168,6 +168,82 @@ describe('EncryptedSQLiteStorage', () => {
expect(await store.getStreamState('stream-1')).not.toBeNull(); // active rows untouched
});
test('broadcast channel + member round-trip (V4.6)', async () => {
const channel = {
channelId: 'c-1',
ownerRole: 'sender' as const,
ownerAddress: 'pc',
label: 'output',
generation: 0,
chainKey: randBytes(32),
iteration: 0,
signingPublicKey: randBytes(32),
signingPrivateKey: randBytes(32),
createdAt: 1_700_000_000_000,
updatedAt: 1_700_000_000_000,
};
await store.saveBroadcastChannel(channel);
const fetched = await store.getBroadcastChannel('c-1');
expect(fetched).not.toBeNull();
expect(fetched!.ownerRole).toBe('sender');
expect(fetched!.label).toBe('output');
expect(fetched!.chainKey).toEqual(channel.chainKey);
expect(fetched!.signingPrivateKey).toEqual(channel.signingPrivateKey);
// Add two members + verify list + remove.
await store.saveBroadcastMember({
channelId: 'c-1',
peerAddress: 'mobile-a',
joinedAt: 1_700_000_000_001,
removedAt: null,
});
await store.saveBroadcastMember({
channelId: 'c-1',
peerAddress: 'mobile-b',
joinedAt: 1_700_000_000_002,
removedAt: null,
});
let members = await store.getBroadcastMembers('c-1');
expect(members.map((m) => m.peerAddress)).toEqual(['mobile-a', 'mobile-b']);
// Mark one removed.
await store.saveBroadcastMember({
channelId: 'c-1',
peerAddress: 'mobile-a',
joinedAt: 1_700_000_000_001,
removedAt: 1_700_000_000_500,
});
members = await store.getBroadcastMembers('c-1');
expect(members.find((m) => m.peerAddress === 'mobile-a')?.removedAt).toBe(1_700_000_000_500);
// List + delete cascade.
expect((await store.listBroadcastChannels())).toHaveLength(1);
await store.removeBroadcastChannel('c-1');
expect(await store.getBroadcastChannel('c-1')).toBeNull();
expect((await store.getBroadcastMembers('c-1'))).toHaveLength(0);
});
test('broadcast channel sealed: receiver-side row has no private key', async () => {
await store.saveBroadcastChannel({
channelId: 'c-2',
ownerRole: 'receiver',
ownerAddress: 'pc',
generation: 1,
chainKey: randBytes(32),
iteration: 5,
signingPublicKey: randBytes(32),
// no signingPrivateKey
createdAt: 1,
updatedAt: 1,
});
const r = await store.getBroadcastChannel('c-2');
expect(r).not.toBeNull();
expect(r!.ownerRole).toBe('receiver');
expect(r!.signingPrivateKey).toBeUndefined();
expect(r!.iteration).toBe(5);
expect(r!.generation).toBe(1);
});
test('rejects open with wrong key (fingerprint mismatch)', async () => {
await store.saveIdentityKeyPair(dummyIdentity());
store.close();