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

@@ -1,5 +1,7 @@
import { openDB, type IDBPDatabase, type DBSchema } from 'idb';
import type {
BroadcastChannelRecord,
BroadcastMemberRecord,
IdentityKeyPair,
OneTimePreKey,
PeerVerification,
@@ -13,10 +15,10 @@ import type {
import { constantTimeEqual, toBase64 } from '@shade/core';
import { KeyManager } from '../crypto/key-manager.js';
import {
openConfig, openIdentity, openOneTimePreKey, openRetired, openSession,
openSignedPreKey, openStreamSensitive, openTrust, sealConfig, sealIdentity,
sealOneTimePreKey, sealRetired, sealSession, sealSignedPreKey,
sealStreamSensitive, sealTrust,
openBroadcastChannelSensitive, openConfig, openIdentity, openOneTimePreKey,
openRetired, openSession, openSignedPreKey, openStreamSensitive, openTrust,
sealBroadcastChannelSensitive, sealConfig, sealIdentity, sealOneTimePreKey,
sealRetired, sealSession, sealSignedPreKey, sealStreamSensitive, sealTrust,
} from '../crypto/row-codec.js';
/**
@@ -91,6 +93,13 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
db.createObjectStore('peer_verifications_enc', { keyPath: 'peerAddress' });
db.createObjectStore('peer_identity_versions_enc', { keyPath: 'peerAddress' });
}
if (oldVersion < 2) {
db.createObjectStore('broadcast_channels_enc', { keyPath: 'channelId' });
const members = db.createObjectStore('broadcast_members_enc', {
keyPath: ['channelId', 'peerAddress'],
});
members.createIndex('byChannelId', 'channelId');
}
},
});
const store = new EncryptedIndexedDBStorage(db, opts.keyManager);
@@ -341,6 +350,99 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
return next;
}
// ─── Broadcast channels (V4.6) ────────────────────────────
async saveBroadcastChannel(channel: BroadcastChannelRecord): Promise<void> {
const sealed = await sealBroadcastChannelSensitive(this.km, channel.channelId, {
chainKey: channel.chainKey,
iteration: channel.iteration,
signingPublicKey: channel.signingPublicKey,
...(channel.signingPrivateKey !== undefined ? { signingPrivateKey: channel.signingPrivateKey } : {}),
});
await this.db.put('broadcast_channels_enc', {
channelId: channel.channelId,
ownerRole: channel.ownerRole,
ownerAddress: channel.ownerAddress,
label: channel.label ?? null,
generation: channel.generation,
ciphertext: sealed,
createdAt: channel.createdAt,
updatedAt: channel.updatedAt,
});
}
async getBroadcastChannel(channelId: string): Promise<BroadcastChannelRecord | null> {
const row = await this.db.get('broadcast_channels_enc', channelId);
if (!row) return null;
return this.encRowToChannel(row);
}
async listBroadcastChannels(): Promise<BroadcastChannelRecord[]> {
const rows = await this.db.getAll('broadcast_channels_enc');
rows.sort((a, b) => a.createdAt - b.createdAt);
return Promise.all(rows.map((r) => this.encRowToChannel(r)));
}
async removeBroadcastChannel(channelId: string): Promise<void> {
const tx = this.db.transaction(
['broadcast_channels_enc', 'broadcast_members_enc'],
'readwrite',
);
const memIdx = tx.objectStore('broadcast_members_enc').index('byChannelId');
let cursor = await memIdx.openCursor(IDBKeyRange.only(channelId));
while (cursor) {
await cursor.delete();
cursor = await cursor.continue();
}
await tx.objectStore('broadcast_channels_enc').delete(channelId);
await tx.done;
}
async saveBroadcastMember(member: BroadcastMemberRecord): Promise<void> {
await this.db.put('broadcast_members_enc', { ...member });
}
async getBroadcastMembers(channelId: string): Promise<BroadcastMemberRecord[]> {
const rows = await this.db.getAllFromIndex(
'broadcast_members_enc',
'byChannelId',
IDBKeyRange.only(channelId),
);
rows.sort((a, b) => a.joinedAt - b.joinedAt);
return rows.map((r) => ({
channelId: r.channelId,
peerAddress: r.peerAddress,
joinedAt: r.joinedAt,
removedAt: r.removedAt,
}));
}
async removeBroadcastMember(channelId: string, peerAddress: string): Promise<void> {
await this.db.delete('broadcast_members_enc', [channelId, peerAddress]);
}
private async encRowToChannel(row: BroadcastChannelEncRow): Promise<BroadcastChannelRecord> {
const sensitive = await openBroadcastChannelSensitive(
this.km,
row.channelId,
row.ciphertext,
);
const out: BroadcastChannelRecord = {
channelId: row.channelId,
ownerRole: row.ownerRole,
ownerAddress: row.ownerAddress,
generation: row.generation,
chainKey: sensitive.chainKey,
iteration: sensitive.iteration,
signingPublicKey: sensitive.signingPublicKey,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
if (row.label !== null) out.label = row.label;
if (sensitive.signingPrivateKey !== undefined) out.signingPrivateKey = sensitive.signingPrivateKey;
return out;
}
private async rowToStreamState(row: StreamStateEncRow): Promise<PersistedStreamState> {
const sensitive = await openStreamSensitive(this.km, row.streamId, row.ciphertext);
const out: PersistedStreamState = {
@@ -364,7 +466,7 @@ export class EncryptedIndexedDBStorage implements StorageProvider {
// ─── Schema ────────────────────────────────────────────────
const SCHEMA_VERSION = 1;
const SCHEMA_VERSION = 2;
interface MetaRow { key: string; value: string }
interface IdentityRow { id: 1; ciphertext: Uint8Array }
@@ -395,6 +497,24 @@ interface PeerVerificationRow {
interface PeerIdentityVersionRow { peerAddress: string; version: number }
interface BroadcastChannelEncRow {
channelId: string;
ownerRole: 'sender' | 'receiver';
ownerAddress: string;
label: string | null;
generation: number;
ciphertext: Uint8Array;
createdAt: number;
updatedAt: number;
}
interface BroadcastMemberEncRow {
channelId: string;
peerAddress: string;
joinedAt: number;
removedAt: number | null;
}
interface EncryptedShadeSchema extends DBSchema {
meta_enc: { key: string; value: MetaRow };
identity_enc: { key: number; value: IdentityRow };
@@ -419,4 +539,10 @@ interface EncryptedShadeSchema extends DBSchema {
};
peer_verifications_enc: { key: string; value: PeerVerificationRow };
peer_identity_versions_enc: { key: string; value: PeerIdentityVersionRow };
broadcast_channels_enc: { key: string; value: BroadcastChannelEncRow };
broadcast_members_enc: {
key: [string, string];
value: BroadcastMemberEncRow;
indexes: { byChannelId: string };
};
}