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,6 +1,6 @@
{
"name": "@shade/core",
"version": "4.5.0",
"version": "4.6.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -46,6 +46,50 @@ export interface PersistedStreamState {
*/
export type PeerVerificationSource = 'user' | 'transitive' | 'tofu-after-warning';
/**
* Persisted broadcast-channel state (V4.6). Holds the sender-key chain that
* lets the channel owner encrypt a single ciphertext and fan it out to all
* paired members.
*
* - `ownerRole === 'sender'`: this device created the channel; the record
* carries the full chain (chainKey + iteration + signing keypair).
* - `ownerRole === 'receiver'`: this device joined a channel that
* `ownerAddress` owns; we hold a tracking copy of the chain (chainKey +
* iteration + signing public key only — no private signing key).
*
* `generation` is bumped each time `removeMember` rotates the chain. Stale
* broadcasts at lower generations are silently dropped on receive.
*/
export interface BroadcastChannelRecord {
/** Opaque, stable across restarts. UUID-style. */
channelId: string;
ownerRole: 'sender' | 'receiver';
/** The address that owns the channel (i.e. the only sender). */
ownerAddress: string;
label?: string;
/** Sender-key generation — bumped on rotation. Starts at 0. */
generation: number;
/** Current chain key (32 bytes). */
chainKey: Uint8Array;
/** Counter advanced by `senderKeyEncrypt` / `senderKeyDecrypt`. */
iteration: number;
/** Owner's Ed25519 signing public key (32 bytes). */
signingPublicKey: Uint8Array;
/** Owner's Ed25519 signing private key. Present iff `ownerRole === 'sender'`. */
signingPrivateKey?: Uint8Array;
createdAt: number;
updatedAt: number;
}
/** Membership row for a broadcast channel (sender-side only). */
export interface BroadcastMemberRecord {
channelId: string;
peerAddress: string;
joinedAt: number;
/** When this peer was revoked. `null` while still active. */
removedAt: number | null;
}
/**
* Persistent record that a peer's safety number was verified at a point
* in time. `identityVersion` is the local counter for that peer's identity:
@@ -188,4 +232,35 @@ export interface StorageProvider {
/** Prune stream-state rows in `'finished' | 'aborted'` status older than `olderThan`. */
pruneStreamStates?(olderThan: number): Promise<void>;
// ─── Broadcast channels (V4.6) — optional ─────────────────
/**
* Persist or replace the broadcast-channel record. Idempotent upsert on
* `channelId`. Backends that don't implement broadcast-channel support
* may omit this; the SDK throws a clear error when the app tries to
* call `createBroadcastChannel` on such a backend.
*/
saveBroadcastChannel?(channel: BroadcastChannelRecord): Promise<void>;
/** Look up a broadcast channel by id. */
getBroadcastChannel?(channelId: string): Promise<BroadcastChannelRecord | null>;
/** Enumerate all broadcast channels persisted on this device. */
listBroadcastChannels?(): Promise<BroadcastChannelRecord[]>;
/** Drop a broadcast channel and all its membership rows. */
removeBroadcastChannel?(channelId: string): Promise<void>;
/** Persist or replace a broadcast-membership row (sender-side only). */
saveBroadcastMember?(member: BroadcastMemberRecord): Promise<void>;
/**
* List membership rows for a channel. Includes revoked members (with
* `removedAt !== null`); callers filter as needed.
*/
getBroadcastMembers?(channelId: string): Promise<BroadcastMemberRecord[]>;
/** Hard-delete a single membership row. */
removeBroadcastMember?(channelId: string, peerAddress: string): Promise<void>;
}