Files
Shade/packages/shade-sdk/src/broadcast.ts
Sterister 2c400d7094
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
release(v4.6.0): broadcast channels — Signal sender-keys for one-to-many fan-out
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>
2026-05-07 15:55:34 +02:00

557 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* V4.6 — Broadcast channels for one-to-many fan-out.
*
* Wraps the Signal-style sender-key primitives (`@shade/core/sender-keys`)
* in a persistent, app-friendly handle. The crypto is unchanged from
* `sender-keys.ts`; this module wires it into:
*
* - `StorageProvider.{save,get,list,remove}BroadcastChannel` for state
* - `Shade.send` for distribution (sealed by the bilateral ratchet)
* - `@shade/proto.encodeBroadcast` for the on-wire broadcast envelope
*
* The model is **strictly one-to-many**: the channel owner is the only
* sender. Members hold a tracking copy of the chain key (no signing
* privkey) and can decrypt — they cannot send into the channel. This
* matches the Prism use case (PC desktop fans output frames out to
* paired peers); a future symmetric-group API would build on the same
* persistence layer.
*/
import {
type CryptoProvider,
type StorageProvider,
type BroadcastChannelRecord,
type BroadcastMemberRecord,
type GroupSession,
type SenderKeyState,
buildDistribution,
createSenderKey,
installDistribution,
senderKeyEncrypt,
senderKeyDecrypt,
toBase64,
fromBase64,
} from '@shade/core';
import {
encodeBroadcast,
decodeBroadcast,
inspectEnvelopeType,
type BroadcastWire,
} from '@shade/proto';
import type { ShadeEnvelope } from '@shade/core';
/**
* Magic prefix used to embed broadcast control messages inside a regular
* bilateral plaintext (so they ride the existing `shade.send` /
* `shade.receive` path without a new ratchet wire type). The leading
* NULs make a collision with user-app text astronomically unlikely.
*/
const CONTROL_MAGIC = 'SHADE-BC/v1:';
/** Discriminator used in `Shade.onMessage`'s third arg. */
export type MessageMeta =
| { kind: 'direct' }
| { kind: 'broadcast'; channelId: string; sender: string; generation: number; iteration: number };
/** Snapshot returned by `Shade.listBroadcastChannels()`. */
export interface BroadcastChannelSummary {
id: string;
ownerRole: 'sender' | 'receiver';
ownerAddress: string;
label?: string;
generation: number;
members: readonly string[];
}
// ─── Control-message framing ─────────────────────────────────
interface DistributionControl {
kind: 'distribution';
channelId: string;
ownerAddress: string;
label?: string;
generation: number;
chainKey: string; // base64
iteration: number;
signingPublicKey: string; // base64
}
interface RevocationControl {
kind: 'revocation';
channelId: string;
}
type ControlMessage = DistributionControl | RevocationControl;
export function isControlPlaintext(plaintext: string): boolean {
return plaintext.startsWith(CONTROL_MAGIC);
}
function packControl(c: ControlMessage): string {
return CONTROL_MAGIC + JSON.stringify(c);
}
function unpackControl(plaintext: string): ControlMessage {
if (!plaintext.startsWith(CONTROL_MAGIC)) {
throw new Error('not a broadcast control plaintext');
}
const json = plaintext.slice(CONTROL_MAGIC.length);
return JSON.parse(json) as ControlMessage;
}
// ─── BroadcastChannel handle (sender-side) ───────────────────
/**
* Sender-side handle for a broadcast channel. Returned by
* `Shade.createBroadcastChannel()` and `Shade.getBroadcastChannel()` for
* channels where this device is the owner.
*
* Receiving devices DON'T expose a `BroadcastChannel` handle — they get
* a `meta.kind === 'broadcast'` callback into `onMessage` instead. There
* is nothing for the receiver to do beyond decrypting; the SDK manages
* the chain-key state under the hood.
*/
export interface BroadcastChannel {
/** Stable, opaque channel identifier. */
readonly id: string;
/** Snapshot of currently active member addresses (excludes revoked). */
members(): Promise<readonly string[]>;
/**
* Add `peerAddress` to the channel and return a control envelope to
* deliver to that peer (sealed under the bilateral ratchet). The peer
* must already have a verified bilateral session before this is called
* — typically from the app's pair handler after `markPeerVerified`.
*/
addMember(peerAddress: string): Promise<{ envelope: ShadeEnvelope }>;
/**
* Remove `peerAddress` and rotate the sender-key. Returns one envelope
* per surviving member carrying the new chain key — deliver each to its
* `to` address.
*/
removeMember(peerAddress: string): Promise<{
rotations: Array<{ to: string; envelope: ShadeEnvelope }>;
}>;
/**
* Encrypt a plaintext once with the current sender chain. Returns a
* single broadcast envelope (the same byte sequence is delivered to
* every member by the caller's transport) plus the snapshot of active
* members.
*/
broadcast(plaintext: string | Uint8Array): Promise<{
envelope: Uint8Array;
members: readonly string[];
}>;
}
/**
* Internal hooks the SDK exposes to BroadcastChannel implementations.
* Lets us avoid a circular import at the value level — the Shade class
* stays the one place that owns sessions + delivery.
*/
export interface BroadcastSdkHooks {
/** Bilateral-ratchet `send` for delivering control envelopes. */
bilateralSend(peerAddress: string, plaintext: string): Promise<ShadeEnvelope>;
/** This device's own address (= channel owner address for sender-side). */
myAddress(): string;
/** Crypto + storage handles (for chain-key advancement and persistence). */
crypto: CryptoProvider;
storage: StorageProvider;
}
class BroadcastChannelImpl implements BroadcastChannel {
constructor(
readonly id: string,
private readonly hooks: BroadcastSdkHooks,
) {}
async members(): Promise<readonly string[]> {
const rows = await this.hooks.storage.getBroadcastMembers!(this.id);
return rows.filter((m) => m.removedAt === null).map((m) => m.peerAddress);
}
async addMember(peerAddress: string): Promise<{ envelope: ShadeEnvelope }> {
const channel = await loadChannel(this.hooks.storage, this.id, 'sender');
const now = Date.now();
const member: BroadcastMemberRecord = {
channelId: this.id,
peerAddress,
joinedAt: now,
removedAt: null,
};
await this.hooks.storage.saveBroadcastMember!(member);
const control: DistributionControl = {
kind: 'distribution',
channelId: this.id,
ownerAddress: channel.ownerAddress,
generation: channel.generation,
chainKey: toBase64(channel.chainKey),
iteration: channel.iteration,
signingPublicKey: toBase64(channel.signingPublicKey),
};
if (channel.label !== undefined) control.label = channel.label;
const envelope = await this.hooks.bilateralSend(peerAddress, packControl(control));
return { envelope };
}
async removeMember(peerAddress: string): Promise<{
rotations: Array<{ to: string; envelope: ShadeEnvelope }>;
}> {
const channel = await loadChannel(this.hooks.storage, this.id, 'sender');
const now = Date.now();
// Mark target removed.
await this.hooks.storage.saveBroadcastMember!({
channelId: this.id,
peerAddress,
joinedAt: now,
removedAt: now,
});
// Rotate: fresh chain + signing keypair, generation++.
const fresh = await createSenderKey(this.hooks.crypto);
if (channel.signingPrivateKey !== undefined) {
this.hooks.crypto.zeroize(channel.signingPrivateKey);
}
this.hooks.crypto.zeroize(channel.chainKey);
const rotated: BroadcastChannelRecord = {
...channel,
generation: channel.generation + 1,
chainKey: fresh.chainKey,
iteration: 0,
signingPublicKey: fresh.signingPublicKey,
...(fresh.signingPrivateKey !== undefined ? { signingPrivateKey: fresh.signingPrivateKey } : {}),
updatedAt: now,
};
await this.hooks.storage.saveBroadcastChannel!(rotated);
// Tell the revoked peer they're gone (best-effort; rotation already
// means they can't decrypt new traffic, but the explicit signal lets
// their UI reflect the change immediately).
const revocation: RevocationControl = { kind: 'revocation', channelId: this.id };
try {
await this.hooks.bilateralSend(peerAddress, packControl(revocation));
} catch {
// ignore — the revoked peer might be offline; rotation has already
// happened locally so they're cryptographically out either way.
}
// Distribute new chain to surviving members.
const survivors = (await this.members()).filter((m) => m !== peerAddress);
const rotations: Array<{ to: string; envelope: ShadeEnvelope }> = [];
for (const peer of survivors) {
const control: DistributionControl = {
kind: 'distribution',
channelId: this.id,
ownerAddress: rotated.ownerAddress,
generation: rotated.generation,
chainKey: toBase64(rotated.chainKey),
iteration: rotated.iteration,
signingPublicKey: toBase64(rotated.signingPublicKey),
};
if (rotated.label !== undefined) control.label = rotated.label;
const envelope = await this.hooks.bilateralSend(peer, packControl(control));
rotations.push({ to: peer, envelope });
}
return { rotations };
}
async broadcast(plaintext: string | Uint8Array): Promise<{
envelope: Uint8Array;
members: readonly string[];
}> {
const channel = await loadChannel(this.hooks.storage, this.id, 'sender');
const session = recordToGroupSession(channel);
const ptBytes = typeof plaintext === 'string'
? new TextEncoder().encode(plaintext)
: plaintext;
const msg = await senderKeyEncrypt(
this.hooks.crypto,
session,
channel.ownerAddress,
ptBytes,
);
// Persist updated chain state (the sender-key encrypt mutated the
// SenderKeyState in `session.senderKeys`).
const advanced = session.senderKeys.get(channel.ownerAddress)!;
const updated: BroadcastChannelRecord = {
...channel,
chainKey: advanced.chainKey,
iteration: advanced.iteration,
updatedAt: Date.now(),
};
await this.hooks.storage.saveBroadcastChannel!(updated);
const wire: BroadcastWire = {
channelId: this.id,
senderAddress: channel.ownerAddress,
generation: channel.generation,
iteration: msg.iteration,
nonce: msg.nonce,
signature: msg.signature,
ciphertext: msg.ciphertext,
};
return {
envelope: encodeBroadcast(wire),
members: await this.members(),
};
}
}
// ─── Receiver-side: handle an incoming broadcast envelope ────
/**
* Decrypt an inbound broadcast wire envelope. Returns `{ plaintext, meta }`
* the SDK then dispatches to `onMessage` handlers. Throws if no matching
* channel exists or the ciphertext fails verification.
*
* Stale generations (older than the receiver's currently-installed
* sender-key) are silently dropped: returns `null`.
*/
export async function acceptBroadcastEnvelope(
hooks: BroadcastSdkHooks,
envelopeBytes: Uint8Array,
): Promise<{ plaintext: string; meta: MessageMeta } | null> {
const kind = inspectEnvelopeType(envelopeBytes);
if (kind !== 'broadcast') {
throw new Error(`acceptBroadcastEnvelope: not a broadcast (type=${kind})`);
}
const wire = decodeBroadcast(envelopeBytes);
const channel = await hooks.storage.getBroadcastChannel?.(wire.channelId);
if (!channel) {
throw new Error(`unknown broadcast channel: ${wire.channelId}`);
}
// Only act on broadcasts from the recorded owner.
if (channel.ownerAddress !== wire.senderAddress) {
throw new Error(
`broadcast sender ${wire.senderAddress} does not match channel owner ${channel.ownerAddress}`,
);
}
if (wire.generation < channel.generation) {
// Stale: silently drop (sender's old chain post-rotation).
return null;
}
if (wire.generation > channel.generation) {
throw new Error(
`broadcast generation ${wire.generation} ahead of installed ${channel.generation} — missing distribution`,
);
}
const session = recordToGroupSession(channel);
const plaintextBytes = await senderKeyDecrypt(hooks.crypto, session, channel.channelId, {
senderAddress: channel.ownerAddress,
iteration: wire.iteration,
ciphertext: wire.ciphertext,
nonce: wire.nonce,
signature: wire.signature,
});
// Persist advanced chain state.
const advanced = session.senderKeys.get(channel.ownerAddress)!;
await hooks.storage.saveBroadcastChannel!({
...channel,
chainKey: advanced.chainKey,
iteration: advanced.iteration,
updatedAt: Date.now(),
});
const plaintext = new TextDecoder().decode(plaintextBytes);
return {
plaintext,
meta: {
kind: 'broadcast',
channelId: wire.channelId,
sender: wire.senderAddress,
generation: wire.generation,
iteration: wire.iteration,
},
};
}
/**
* Detect whether an inbound bilateral plaintext is a broadcast control
* message (distribution / revocation) and consume it. Returns `true`
* when the plaintext was consumed (caller MUST NOT dispatch it to
* onMessage handlers); `false` for normal direct plaintexts.
*/
export async function maybeHandleControlPlaintext(
hooks: BroadcastSdkHooks,
from: string,
plaintext: string,
): Promise<boolean> {
if (!isControlPlaintext(plaintext)) return false;
const ctrl = unpackControl(plaintext);
if (ctrl.kind === 'distribution') {
await installIncomingDistribution(hooks, from, ctrl);
} else if (ctrl.kind === 'revocation') {
await applyRevocation(hooks, ctrl.channelId);
}
return true;
}
async function installIncomingDistribution(
hooks: BroadcastSdkHooks,
from: string,
d: DistributionControl,
): Promise<void> {
// The control message arrived over a bilateral session with `from`.
// For trust, require that the channel owner address matches the
// bilateral peer that delivered it — otherwise a paired peer could
// smuggle a fake distribution claiming to come from another address.
if (d.ownerAddress !== from) {
throw new Error(
`broadcast distribution owner ${d.ownerAddress} does not match bilateral sender ${from}`,
);
}
const existing = await hooks.storage.getBroadcastChannel?.(d.channelId);
if (existing && existing.generation > d.generation) {
// Stale distribution — keep what we have.
return;
}
const now = Date.now();
const record: BroadcastChannelRecord = {
channelId: d.channelId,
ownerRole: 'receiver',
ownerAddress: d.ownerAddress,
generation: d.generation,
chainKey: fromBase64(d.chainKey),
iteration: d.iteration,
signingPublicKey: fromBase64(d.signingPublicKey),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
if (d.label !== undefined) record.label = d.label;
await hooks.storage.saveBroadcastChannel!(record);
}
async function applyRevocation(
hooks: BroadcastSdkHooks,
channelId: string,
): Promise<void> {
// Drop our local copy entirely. The owner has rotated; without a fresh
// distribution we couldn't decrypt new traffic anyway.
await hooks.storage.removeBroadcastChannel?.(channelId);
}
// ─── Channel creation + summaries ────────────────────────────
export async function createBroadcastChannelImpl(
hooks: BroadcastSdkHooks,
opts: { label?: string } = {},
): Promise<BroadcastChannel> {
ensureBroadcastSupport(hooks.storage);
const channelId = generateChannelId(hooks.crypto);
const seed = await createSenderKey(hooks.crypto);
const now = Date.now();
const record: BroadcastChannelRecord = {
channelId,
ownerRole: 'sender',
ownerAddress: hooks.myAddress(),
generation: 0,
chainKey: seed.chainKey,
iteration: seed.iteration,
signingPublicKey: seed.signingPublicKey,
...(seed.signingPrivateKey !== undefined ? { signingPrivateKey: seed.signingPrivateKey } : {}),
createdAt: now,
updatedAt: now,
};
if (opts.label !== undefined) record.label = opts.label;
await hooks.storage.saveBroadcastChannel!(record);
return new BroadcastChannelImpl(channelId, hooks);
}
export async function getBroadcastChannelImpl(
hooks: BroadcastSdkHooks,
channelId: string,
): Promise<BroadcastChannel | null> {
ensureBroadcastSupport(hooks.storage);
const record = await hooks.storage.getBroadcastChannel!(channelId);
if (!record || record.ownerRole !== 'sender') return null;
return new BroadcastChannelImpl(channelId, hooks);
}
export async function listBroadcastChannelsImpl(
hooks: BroadcastSdkHooks,
): Promise<readonly BroadcastChannelSummary[]> {
if (hooks.storage.listBroadcastChannels === undefined) return [];
const records = await hooks.storage.listBroadcastChannels();
const out: BroadcastChannelSummary[] = [];
for (const r of records) {
const memberRows = (await hooks.storage.getBroadcastMembers?.(r.channelId)) ?? [];
const summary: BroadcastChannelSummary = {
id: r.channelId,
ownerRole: r.ownerRole,
ownerAddress: r.ownerAddress,
generation: r.generation,
members: memberRows.filter((m) => m.removedAt === null).map((m) => m.peerAddress),
};
if (r.label !== undefined) summary.label = r.label;
out.push(summary);
}
return out;
}
// ─── Helpers ─────────────────────────────────────────────────
function ensureBroadcastSupport(storage: StorageProvider): void {
if (
storage.saveBroadcastChannel === undefined ||
storage.getBroadcastChannel === undefined ||
storage.saveBroadcastMember === undefined ||
storage.getBroadcastMembers === undefined
) {
throw new Error(
'StorageProvider does not implement broadcast-channel APIs. Use a backend ≥ Shade 4.6 (memory/sqlite/indexeddb/postgres + their encrypted variants).',
);
}
}
async function loadChannel(
storage: StorageProvider,
channelId: string,
expectRole: 'sender' | 'receiver',
): Promise<BroadcastChannelRecord> {
const c = await storage.getBroadcastChannel?.(channelId);
if (!c) throw new Error(`broadcast channel ${channelId} not found`);
if (c.ownerRole !== expectRole) {
throw new Error(
`broadcast channel ${channelId} role mismatch: have ${c.ownerRole}, expected ${expectRole}`,
);
}
return c;
}
function recordToGroupSession(record: BroadcastChannelRecord): GroupSession {
// Reconstruct a minimal GroupSession with one entry (the channel owner).
const state: SenderKeyState = {
chainKey: new Uint8Array(record.chainKey),
iteration: record.iteration,
signingPublicKey: new Uint8Array(record.signingPublicKey),
};
if (record.signingPrivateKey !== undefined) {
state.signingPrivateKey = new Uint8Array(record.signingPrivateKey);
}
return {
groupId: record.channelId,
senderKeys: new Map([[record.ownerAddress, state]]),
};
}
function generateChannelId(crypto: CryptoProvider): string {
// 16 bytes = 22-char base64url. Stable across restarts because we
// persist it before returning.
const bytes = crypto.randomBytes(16);
return bytesToBase64Url(bytes);
}
function bytesToBase64Url(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Re-export `buildDistribution` + `installDistribution` for tests + advanced
// callers — internal implementations use them already.
export { buildDistribution, installDistribution };